Skip to main content
doc0blade47/dubdocumentation home
Sign in

Queue Workers

Last updated Oct 12, 202686% grounded
Relevant source files

The following files were used as context for generating this wiki page:

Overview

Dub implements a resilient queue worker and event-streaming architecture built around Upstash Redis streams and QStash job dispatching. High-frequency telemetry—such as link clicks and workspace usage updates—is ingested into Redis streams and processed in batch consumers by cron routines with distributed locking. For transactional background tasks, the system uses a database-backed outbox pattern that persists failed QStash publishes for automated retry recovery. Asynchronous jobs route through dynamic endpoints and type-safe job definitions to execute domain-specific workflows for partner searches, order processing, and aggregate analytics.

Sources: apps/web/app/ee/api/cron/streams/send-link-clicked-webhooks/route.ts:5-65, apps/web/app/ee/api/cron/streams/update-click-stats/route.ts:3-112, apps/web/app/ee/api/cron/queue/retry/route.ts:1-46, apps/web/lib/jobs/outbox.ts:88-122, apps/web/app/api/jobs/process/jobName/route.ts:11-105

Redis Stream Pipeline Infrastructure

Stream Operations and Client Abstraction

The RedisStream class encapsulates interaction with Upstash Redis streams, providing methods for batch message reading, transactional deletion, and metadata inspection. It initializes with a designated streamKey and exposes processBatch to fetch ranges of stream entries using redis.xrange, execute a handler function against them, and optionally delete successfully processed messages via redis.xdel to prevent memory accumulation.

export class RedisStream {
  private streamKey: string;
 
  constructor(streamKey: string) {
    this.streamKey = streamKey;
  }
 
  /**
   * Read and process a batch of items from the stream
   * This provides a consumable batch of messages with the ability to delete them upon successful completion of the handler
   */
  async processBatch<T>(
    handler: (
      records: RedisStreamEntry<T>[],
    ) => Promise<any & { processedEntryIds: string[] }>,
    options: {
      startId?: string;
      endId?: string;
      count?: number;
      deleteAfterRead?: boolean;
    } = {},
  ): Promise<any> {
    const {
      startId = "0",
      endId = "+",
      count = 1000,
      deleteAfterRead = true,
    } = options;
 
    try {
      // Read entries from the stream
      const entriesMap = await redis.xrange(
        this.streamKey,
        startId,
        endId,
        count,
      );
 
      const entries: RedisStreamEntry<T>[] = Object.entries(entriesMap).map(
        ([id, data]) => ({
          id,
          data: data as any,
        }),
      );
 
      const { processedEntryIds, ...response } = await handler(entries);
 
      // Optionally delete processed entries to prevent memory buildup
      if (deleteAfterRead) {
        try {
          if (processedEntryIds.length > 0) {
            // xdel supports deleting multiple IDs at once
            await redis.xdel(this.streamKey, processedEntryIds);
          }
        } catch (error) {
          console.warn("Failed to clean up processed stream entries:", error);
          // Don't throw - this is not critical
        }
      }
 
      return response as Response;
    } catch (error) {
      console.error(
        "Failed to read workspace usage updates from stream:",
        error,
      );
      throw error;
    }
  }

Sources: apps/web/lib/upstash/redis-streams/client.ts:8-76

The client also provides a getStreamInfo method to inspect the current state of a stream by querying its length, first entry ID via redis.xrange, and last entry ID via redis.xrevrange. If a stream does not exist or remains empty, the inspection catches the error state and returns zeroed metrics.

/**
 * Get stream information (length, first/last entry, etc.)
 */
async getStreamInfo(): Promise<{
  length: number;
  firstEntryId: string | null;
  lastEntryId: string | null;
}> {
  try {
    // Get stream length - check if stream exists first
    let length = 0;
    let firstEntryId: string | null = null;
    let lastEntryId: string | null = null;
 
    try {
      // Try to get first entry to check if stream exists
      const firstEntry = await redis.xrange(this.streamKey, "-", "+", 1);
 
      if (firstEntry && Object.keys(firstEntry).length > 0) {
        firstEntryId = Object.keys(firstEntry)[0];
 
        // If stream exists, get its length and last entry
        const entries = await redis.xrange(this.streamKey, "-", "+");
        length = Object.keys(entries || {}).length;
 
        if (length > 0) {
          // Use "$" to get the last entry instead of "+"
          const lastEntry = await redis.xrevrange(
            this.streamKey,
            "+",
            "-",
            1,
          );
 
          if (lastEntry && Object.keys(lastEntry).length > 0) {
            const entryId = Object.keys(lastEntry)[0];
 
            if (entryId !== firstEntryId) {
              lastEntryId = entryId;
            }
          }
        }
      }
    } catch (streamError) {
      // Stream might not exist yet, which is fine
      console.log("Stream does not exist or is empty:", streamError);
      console.log(streamError.message);
      console.log(streamError.type);
      console.log(JSON.stringify(streamError, null, 2));
    }
 
    return {
      length,
      firstEntryId,
      lastEntryId,
    };
  } catch (error) {
    console.error("Failed to get stream info:", error);
    throw error;
  }
}

Sources: apps/web/lib/upstash/redis-streams/client.ts:78-138

Note

RedisStream handles missing streams gracefully during inspection by catching stream errors and returning null identifiers with a zero length, preventing worker crashes when polling uninitialized streams.

Sources: apps/web/lib/upstash/redis-streams/client.ts:121-127

Event Publishers and Database Fallbacks

Three primary stream publishers handle high-throughput event ingestion for link clicks, workspace click telemetry, and workspace link usage counts. Each publisher attempts an asynchronous xadd operation onto its respective Upstash Redis stream key, falling back to direct database execution if the Redis connection fails.

Stream Key ConstantModule FilePayload Interface / SchemaDatabase Fallback Action
link:click:eventslink-click-events.tsLinkClickEventUPDATE Link SET clicks = clicks + 1... & optional Project/Program update
workspace:click:eventsworkspace-click-events.tsclickEventSchemaTBLog error to Axiom and rethrow
workspace:linksUsage:updatesworkspace-links-usage.tsWorkspaceLinksUsageEventUPDATE Project SET linksUsage = linksUsage + ?, totalLinks = totalLinks + ? WHERE id = ?

Sources: apps/web/lib/upstash/redis-streams/link-click-events.ts:6-73, apps/web/lib/upstash/redis-streams/workspace-click-events.ts:7-43, apps/web/lib/upstash/redis-streams/workspace-links-usage.ts:6-40

The publishLinkClickEvent function constructs payloads containing linkId, timestamp, and optional workspaceId, programId, and partnerId. If publishing fails, it executes Promise.allSettled to increment link click counters and workspace usage directly in PlanetScale while flushing error logs.

export const publishLinkClickEvent = async ({
  linkId,
  timestamp,
  workspaceId,
  programId,
  partnerId,
}: LinkClickEvent) => {
  const payload = {
    linkId,
    timestamp,
    ...(workspaceId && { workspaceId }),
    ...(programId && partnerId && { programId, partnerId }),
  };
 
  try {
    return await redis.xadd(STREAM_KEY, "*", payload);
  } catch (error) {
    logger.error("stream.publish_failed", {
      service: "upstash",
      streamKey: STREAM_KEY,
      error: toErrorFields(error),
      correlation: {
        linkId,
        workspaceId,
        programId,
        partnerId,
      },
    });
 
    return await Promise.allSettled([
      conn.execute(
        "UPDATE Link SET clicks = clicks + 1, lastClicked = NOW() WHERE id = ?",
        [linkId],
      ),
 
      workspaceId
        ? conn.execute(
            "UPDATE Project p SET p.usage = p.usage + 1, p.totalClicks = p.totalClicks + 1 WHERE id = ?",
            [workspaceId],
          )
        : null,
 
      programId && partnerId
        ? conn.execute(
            "UPDATE ProgramEnrollment SET totalClicks = totalClicks + 1 WHERE programId = ? AND partnerId = ?",
            [programId, partnerId],
          )
        : null,
 
      logger.flush(),
    ]);
  }

Sources: apps/web/lib/upstash/redis-streams/link-click-events.ts:22-73

Warning

publishWorkspaceClickEvent validates incoming events against the clickEventSchemaTB Zod schema and checks clickWebhookWorkspaces membership before adding to the stream; events for workspaces without active link.clicked webhooks are dropped entirely.

Sources: apps/web/lib/upstash/redis-streams/workspace-click-events.ts:12-26

Consuming click event streams involves cron-triggered batch workers that drain Redis streams, aggregate statistics into PlanetScale, and dispatch workspace webhook notifications via QStash. Two independent cron routines handle these operations: update-click-stats processes high-throughput click telemetry to increment link and workspace counters, while send-link-clicked-webhooks scans workspace click streams to trigger asynchronous delivery.

Cron Route PathRedis Stream KeyLock KeyLock TTL (s)Batch Size
/api/cron/streams/update-click-statslink:click:eventslock:update-click-stats60010,000
/api/cron/streams/send-link-clicked-webhooksworkspace:click:eventslock:send-link-clicked-webhooks6006,000

Sources: apps/web/app/ee/api/cron/streams/send-link-clicked-webhooks/route.ts:18-27, apps/web/app/ee/api/cron/streams/update-click-stats/route.ts:15-20

Caution

Cron handlers acquire a distributed Redis lock with nx: true and an expiration matching the Vercel execution maximum duration (600s). If a run fails to acquire lock:send-link-clicked-webhooks or lock:update-click-stats, the execution exits immediately to prevent concurrent duplicate updates or double-counted webhook deliveries.

Sources: apps/web/app/ee/api/cron/streams/send-link-clicked-webhooks/route.ts:32-42, apps/web/app/ee/api/cron/streams/update-click-stats/route.ts:19-20

The update-click-stats cron job reads up to 10,000 entries per run from the link:click:events stream using linkClickEventStream.processBatch. The aggregateClickStats function groups incoming entries into map structures to batch database writes across three distinct aggregates: link click counters, workspace totals, and program enrollment stats.

const aggregateClickStats = (
  entries: RedisStreamEntry<LinkClickEvent>[],
): {
  linkUpdates: LinkAggregate[];
  workspaceUpdates: WorkspaceAggregate[];
  enrollmentUpdates: EnrollmentAggregate[];
} => {
  const links = new Map<string, LinkAggregate>();
  const workspaces = new Map<string, WorkspaceAggregate>();
  const enrollments = new Map<string, EnrollmentAggregate>();
 
  for (const entry of entries) {
    const { linkId, workspaceId, programId, partnerId, timestamp } = entry.data;
 
    if (!linkId) {
      continue;
    }
 
    const parsedTimestamp = Date.parse(timestamp);
    const lastClicked = Number.isFinite(parsedTimestamp)
      ? parsedTimestamp
      : Date.now();
 
    const existingLink = links.get(linkId);
    if (existingLink) {
      existingLink.clicks += 1;
      existingLink.lastClicked = Math.max(
        existingLink.lastClicked,
        lastClicked,
      );
      existingLink.entryIds.push(entry.id);
    } else {
      links.set(linkId, {
        linkId,
        clicks: 1,
        lastClicked,
        entryIds: [entry.id],
      });
    }
 
    if (workspaceId) {
      const existingWorkspace = workspaces.get(workspaceId);
      if (existingWorkspace) {
        existingWorkspace.clicks += 1;
      } else {
        workspaces.set(workspaceId, {
          workspaceId,
          clicks: 1,
        });
      }
    }
 
    if (programId && partnerId) {
      const key = `${programId}:${partnerId}`;
      const existingEnrollment = enrollments.get(key);
      if (existingEnrollment) {
        existingEnrollment.clicks += 1;
      } else {
        enrollments.set(key, {
          programId,
          partnerId,
          clicks: 1,
        });
      }
    }
  }
 
  return {
    linkUpdates: Array.from(links.values()),
    workspaceUpdates: Array.from(workspaces.values()),
    enrollmentUpdates: Array.from(enrollments.values()),
  };
};

Sources: apps/web/app/ee/api/cron/streams/update-click-stats/route.ts:40-112

Database updates execute within parallel sub-batches of 50 items using processInSubBatches. Link click increments execute raw SQL statements against PlanetScale to update row totals and reconcile timestamps with GREATEST.

const processInSubBatches = async <T>(
  items: T[],
  handler: (item: T) => Promise<{ success: boolean; error?: unknown }>,
) => {
  const errors: unknown[] = [];
  let totalProcessed = 0;
 
  for (let i = 0; i < items.length; i += SUB_BATCH_SIZE) {
    const batch = items.slice(i, i + SUB_BATCH_SIZE);
    const results = await Promise.allSettled(batch.map(handler));
 
    for (const result of results) {
      if (result.status === "fulfilled" && result.value.success) {
        totalProcessed++;
      } else if (result.status === "fulfilled" && result.value.error) {
        errors.push(result.value.error);
      } else if (result.status === "rejected") {
        errors.push(result.reason);
      }
    }
  }
 
  return { totalProcessed, errors };
};
 
const getStreamEntryAgeMs = (entryId: string | null) => {
  if (!entryId) {
    return null;
  }
 
  const timestampMs = Number(entryId.split("-")[0]);
  if (!Number.isFinite(timestampMs)) {
    return null;
  }
 
  return Date.now() - timestampMs;
};
 
const processClickStatsStreamBatch = () =>
  linkClickEventStream.processBatch<LinkClickEvent>(
    async (entries) => {
      if (!entries || entries.length === 0) {
        return {
          success: true,
          linkUpdates: [],
          workspaceUpdates: [],
          enrollmentUpdates: [],
          processedEntryIds: [],
          totalProcessed: 0,
          errors: [],
        };
      }
 
      console.log(`Aggregating ${entries.length} click stats events`);
 
      const { linkUpdates, workspaceUpdates, enrollmentUpdates } =
        aggregateClickStats(entries);
 
      if (linkUpdates.length === 0) {
        console.log("No click stats updates to process");
        return {
          success: true,
          linkUpdates: [],
          workspaceUpdates: [],
          enrollmentUpdates: [],
          processedEntryIds: entries.map((entry) => entry.id),
          totalProcessed: 0,
          errors: [],
        };
      }
 
      console.log(
        `Processing ${linkUpdates.length} link, ${workspaceUpdates.length} workspace, ${enrollmentUpdates.length} enrollment click stats updates...`,
      );
 
      const processedEntryIds: string[] = [];
      const errors: unknown[] = [];
 
      const linkResult = await processInSubBatches(
        linkUpdates,

Sources: apps/web/app/ee/api/cron/streams/update-click-stats/route.ts:114-215

Tip

Partner activity statistics in update-partner-stats filter out undefined and non-finite numeric values before constructing dynamic UPDATE ProgramEnrollment queries, formatting JavaScript Date instances into yyyy-MM-dd HH:mm:ss strings.

Sources: apps/web/app/ee/api/cron/streams/update-partner-stats/route.ts:277-301

Workspace Webhook Dispatch Workflows

The send-link-clicked-webhooks worker drains the workspace:click:events stream, batching up to 6,000 entries per run. For each event batch, it queries Prisma for matching workspaces, links, and associated webhook configurations filtered by LINK_CLICK_WEBHOOK_TRIGGER and non-null disabledAt timestamps.

const processStreamBatch = (): Promise<{
  processed: number;
  sent: number;
  failed: number;
  processedEntryIds: string[];
}> =>
  workspaceClickEventStream.processBatch<ClickEventTB>(
    async (entries) => {
      if (!entries || entries.length === 0) {
        return {
          processed: 0,
          sent: 0,
          failed: 0,
          processedEntryIds: [],
        };
      }
 
      const workspaceIds = [
        ...new Set(entries.map((e) => e.data.workspace_id).filter(Boolean)),
      ];
      const linkIds = [
        ...new Set(entries.map((e) => e.data.link_id).filter(Boolean)),
      ];
 
      const [workspaces, links] = await Promise.all([
        prisma.project.findMany({
          where: {
            id: {
              in: workspaceIds,
            },
          },
          select: {
            id: true,
            usage: true,
            usageLimit: true,
            webhooks: {
              where: {
                disabledAt: null,
                triggers: {
                  array_contains: [LINK_CLICK_WEBHOOK_TRIGGER],
                },
              },
              select: {
                id: true,
                url: true,
                secret: true,
                linkScope: true,
                projectId: true,
              },
            },
          },
        }),
 
        prisma.link.findMany({
          where: {
            id: {
              in: linkIds,
            },
          },
          include: {
            tags: {
              select: {
                tag: {
                  select: {
                    id: true,
                    name: true,
                    color: true,
                  },
                },
              },
            },
          },
        }),
      ]);
 
      const webhooks = workspaces.flatMap((w) => w.webhooks);

Sources: apps/web/app/ee/api/cron/streams/send-link-clicked-webhooks/route.ts:67-142

Webhooks support scoping rules via the linkScope property, segregating dispatches between link-specific associations (linkWebhook) and folder-level groupings (folderWebhook). Redundant entries are cleaned up periodically by cleanupRedundantLinkWebhookEntries, which removes orphaned LinkWebhook rows linked to non-link-scoped webhooks.

Sources: apps/web/app/ee/api/cron/sync-redis-resources/cleanup-redundant-link-webhook-entries.ts:7-20

Stream Consumer Workflows and Batching

The update-workspace-links-usage cron job consumes from the workspace:links-usage stream, pulling batches of up to 10,000 entries using workspaceLinksUsageStream.processBatch. The aggregateWorkspaceLinksUsage helper iterates over the raw stream entries, validating that each entry contains a valid workspaceId and a finite, positive linksCount. Valid items are grouped into a Map keyed by workspaceId to accumulate link counts and track associated stream entry IDs for acknowledgment.

const aggregateWorkspaceLinksUsage = (
  entries: RedisStreamEntry<WorkspaceLinksUsageEvent>[],
): {
  updates: WorkspaceLinksUsageAggregate[];
  lastProcessedId: string | null;
} => {
  const aggregatedUsage = new Map<string, WorkspaceLinksUsageAggregate>();
  let lastProcessedId: string | null = null;
 
  for (const entry of entries) {
    const workspaceId = entry.data.workspaceId;
    const linksCount = Number(entry.data.linksCount);
 
    if (!workspaceId || !Number.isFinite(linksCount) || linksCount <= 0) {
      continue;
    }
 
    lastProcessedId = entry.id;
 
    if (aggregatedUsage.has(workspaceId)) {
      const existing = aggregatedUsage.get(workspaceId)!;
      existing.linksCount += linksCount;
      existing.entryIds.push(entry.id);
      continue;
    }
 
    aggregatedUsage.set(workspaceId, {
      workspaceId,
      linksCount,
      entryIds: [entry.id],
    });
  }
 
  return {
    updates: Array.from(aggregatedUsage.values()),
    lastProcessedId,
  };
};

Sources: apps/web/app/ee/api/cron/streams/update-workspace-links-usage/route.ts:40-77

The aggregated updates are split into sub-batches of 50 items and processed concurrently via Promise.allSettled. For each workspace in a sub-batch, Prisma updates the Project model by atomically incrementing both linksUsage and totalLinks, returning the updated usage and quota limits.

const SUB_BATCH_SIZE = 50;
const batches: WorkspaceLinksUsageAggregate[][] = [];
 
for (let i = 0; i < updates.length; i += SUB_BATCH_SIZE) {
  batches.push(updates.slice(i, i + SUB_BATCH_SIZE));
}
 
const processedEntryIds: string[] = [];
const errors: { workspaceId: string; error: unknown }[] = [];
const updatedWorkspaces: (WorkspaceProps & {
  linksUsage: number;
  linksLimit: number;
})[] = [];
let totalProcessed = 0;
 
for (const batch of batches) {
  const batchResults = await Promise.allSettled(
    batch.map(async (update) => {
      const workspace = await prisma.project.update({
        where: {
          id: update.workspaceId,
        },
        data: {
          linksUsage: {
            increment: update.linksCount,
          },
          totalLinks: {
            increment: update.linksCount,
          },
        },
        select: {
          id: true,
          name: true,
          slug: true,
          linksUsage: true,
          linksLimit: true,
          plan: true,
        },
      });
 
      processedEntryIds.push(...update.entryIds);
      return workspace;
    }),
  );

Sources: apps/web/app/ee/api/cron/streams/update-workspace-links-usage/route.ts:101-144

Warning

If a workspace update fails within Promise.allSettled, the failure is captured in an errors array alongside the workspaceId, but execution continues for the remainder of the sub-batch. Successfully processed entry IDs are accumulated independently to ensure downstream acknowledgment succeeds for valid writes.

Sources: apps/web/app/ee/api/cron/streams/update-workspace-links-usage/route.ts:146-160

Quota Thresholds and Limit Alerts

After updating workspace link counters, the worker calculates the percentage of quota consumed against each workspace's linksLimit. The getLimitEmailType utility function maps consumption percentages to notification types: percentages below 80% return null, values from 80% up to 100% return firstLinksLimitEmail, and values reaching or exceeding 100% return secondLinksLimitEmail.

Percentage RangeMapped Limit Email Type
< 80%null (No alert)
80% ≤ percentage < 100%firstLinksLimitEmail
≥ 100%secondLinksLimitEmail

Sources: apps/web/app/ee/api/cron/streams/update-workspace-links-usage/route.ts:28-38, apps/web/app/ee/api/cron/streams/update-workspace-links-usage/route.ts:162-184

Workspaces crossing the alert threshold are filtered against previously dispatched notifications stored in the sentEmail table. Simultaneously, team member emails and workspace-level Slack webhooks are fetched in parallel to dispatch limit warnings.

const workspaceIds = workspacesAboveThreshold.map(
  ({ workspace }) => workspace.id,
);
let notificationsSent = 0;
 
if (workspaceIds.length > 0) {
  const [sentEmails, users, slackWebhookByWorkspace] = await Promise.all([
    prisma.sentEmail.findMany({
      where: {
        projectId: {
          in: workspaceIds,
        },
        type: {
          in: ["firstLinksLimitEmail", "secondLinksLimitEmail"],
        },
      },
      select: {
        projectId: true,
        type: true,
      },
    }),
 
    prisma.user.findMany({
      where: {
        projects: {
          some: {
            projectId: {
              in: workspaceIds,
            },
          },
        },
      },
      select: {
        email: true,
        projects: {
          where: {
            projectId: {
              in: workspaceIds,
            },
          },
          select: {
            projectId: true,
          },
        },
      },
    }),
 
    getSlackWebhooks(workspaceIds),
  ]);
 

Sources: apps/web/app/ee/api/cron/streams/update-workspace-links-usage/route.ts:185-234

Transactional Job Outbox Pattern

Job Persistence and Deferred Execution

The outbox pattern ensures guaranteed delivery of background jobs and workflows by persisting failed dispatches to the Prisma Job model before retry attempts. When background jobs are created via persistBackgroundJobs, scheduledAt timestamps are computed from options such as notBefore or delay offsets, and records are written with unique identifiers generated with the job_ prefix.

export async function persistBackgroundJobs(inputs: DispatchJobInput[]) {
  const jobs: PersistableJob[] = inputs.map(({ name, payload, options }) => {
    let scheduledAt = new Date();
 
    if (options?.notBefore) {
      scheduledAt = new Date(options.notBefore * 1000);
    } else if (typeof options?.delay === "number") {
      scheduledAt = new Date(Date.now() + options.delay * 1000);
    }
 
    const replayOptions = {
      ...(options?.deduplicationId && {
        deduplicationId: options.deduplicationId,
      }),
      ...(options?.retries !== undefined && { retries: options.retries }),
      ...(options?.queue && { queue: options.queue }),
      ...(options?.flowControl && { flowControl: options.flowControl }),
      ...(options?.label && { label: options.label }),
    };
 
    return {
      id: createId({ prefix: "job_" }),
      name,
      payload,
      options: replayOptions,
      scheduledAt,
    };
  });
 
  await prisma.job.createMany({
    data: jobs.map((job) => toJobCreateInput({ job })),
  });
 
  return jobs;
}

Sources: apps/web/lib/jobs/outbox.ts:88-122

Two primary transports handle job distribution through transports: workflow triggers mapped via isWorkflowName and standard job dispatches matched via isDefineJobName. The publishPendingJobs function queries due rows where scheduledAt is less than or equal to the current date and attempts is strictly less than MAX_JOB_ATTEMPTS, ordered by creation date ascending up to MAX_JOBS_PER_BATCH.

type JobTransport = {
  matches: (name: string) => boolean;
  send: (jobs: Job[]) => Promise<PublishResult[]>;
};
 
const transports: JobTransport[] = [
  {
    matches: isWorkflowName,
    send: triggerWorkflows,
  },
  {
    matches: isDefineJobName,
    send: sendJobs,
  },
];

Sources: apps/web/lib/jobs/outbox.ts:10-24, apps/web/lib/jobs/outbox.ts:202-220

Note

The first failed publish attempt counts as attempt 1 so that the retry budget accurately matches MAX_JOB_ATTEMPTS. If a job name does not match any registered transport, it is treated as an unknown kind, generating a failed result with an error message and logging the event.

Sources: apps/web/lib/jobs/outbox.ts:26-43, apps/web/lib/jobs/outbox.ts:245-261

Cron Retry Execution and Settle Mechanics

The cron endpoint GET /api/cron/queue/retry orchestrates periodic reprocessing of pending outbox items. A distributed Redis lock using LOCK_KEY (lock:queue-retry) with a TTL of 600 seconds prevents concurrent minute-cron overlapping invocations.

const LOCK_KEY = "lock:queue-retry";
const LOCK_TTL_SECONDS = 600;
 
// GET /api/cron/queue/retry – republish jobs that failed to publish to QStash
// at dispatch time; rows are deleted on successful publish
export const GET = withCron(async () => {
  const acquired = await redis.set(LOCK_KEY, "1", {
    nx: true,
    ex: LOCK_TTL_SECONDS,
  });
 
  if (!acquired) {
    return logAndRespond(
      "[queue-retry] Another run is in progress. Skipping...",
    );
  }

Sources: apps/web/app/ee/api/cron/queue/retry/route.ts:11-26

Once the lock is acquired, publishPendingJobs runs and delegates results to settlePublishResults. Successfully published job IDs are deleted from the database via prisma.job.deleteMany, while failed dispatches increment their attempt counters and update lastError fields.

try {
  const { attempted, published, failed } = await publishPendingJobs();
 
  if (attempted === 0) {
    return logAndRespond("No background jobs to retry.");
  }
 
  if (failed === 0) {
    return logAndRespond(
      `Republished ${published} background jobs to QStash.`,
    );
  }
 
  return logAndRespond(
    `Republished ${published} background jobs to QStash; failed to republish ${failed}.`,
  );
} finally {
  await redis.del(LOCK_KEY);
}

Sources: apps/web/app/ee/api/cron/queue/retry/route.ts:28-46, apps/web/lib/jobs/outbox.ts:125-180

Result StatusAction TakenPrisma Operation
publishedDelete successfully dispatched jobsprisma.job.deleteMany
failedIncrement attempt counter and record lastErrorprisma.job.updateMany

Sources: apps/web/lib/jobs/outbox.ts:125-180

Warning

When jobs exceed or equal MAX_JOB_ATTEMPTS during settlement failures, an error event (jobs.retry_exhausted) is logged to Axiom with the exhausted job identifiers and names, signaling permanent delivery abandonment.

Sources: apps/web/lib/jobs/outbox.ts:182-194

Asynchronous Job Handlers Execution

Asynchronous Job Execution Router

The POST endpoint POST /api/jobs/process/[jobName] handles incoming background job execution requests originating from QStash triggers, with a maximum execution duration configured to 600 seconds. Every incoming request must pass cryptographic verification via verifyQstashSignature using the raw request body and request headers before any parsing or processing occurs. If signature verification fails, an error response is returned immediately.

export const maxDuration = 600;
 
// POST /api/jobs/process/[jobName] – shared executor for all background jobs
export const POST = withAxiomBodyLog(
  async (
    req: Request,
    { params }: { params: Promise<{ jobName: string }> },
  ) => {
    const { jobName: urlJobName } = await params;
    const clonedReq = req.clone();
    const rawBody = await clonedReq.text();
 
    try {
      await verifyQstashSignature({ req, rawBody });
    } catch (error) {
      return handleAndReturnErrorResponse(error);
    }
 

Sources: apps/web/app/api/jobs/process/jobName/route.ts:8-25

After successful verification, the request body is parsed as JSON and validated against the jobEnvelopeSchema envelope structure. If the envelope is malformed or invalid, an error is logged to Axiom and the endpoint returns a non-retryable response status to prevent QStash from retrying permanently corrupted payloads. The URL parameter urlJobName is also validated against the job name contained within the envelope data; any mismatch triggers an immediate non-retryable response.

const envelope = jobEnvelopeSchema.safeParse(parsedBody);
 
// Permanently invalid — returning 2xx stops QStash retries
if (!envelope.success) {
  logger.error("jobs.invalid_envelope", {
    error: envelope.error.message,
  });
  await logger.flush();
 
  return new Response("Invalid job envelope (non-retryable).");
}
 
const { name: jobName } = envelope.data;
 
if (urlJobName !== jobName) {
  logger.error("jobs.job_name_mismatch", {
    urlJobName,
    envelopeJobName: jobName,
  });
  await logger.flush();
 
  return new Response(
    `Job name mismatch: URL "${urlJobName}" does not match envelope "${jobName}" (non-retryable).`,
  );
}

Sources: apps/web/app/api/jobs/process/jobName/route.ts:34-58

Once the job name and envelope are verified, the executor retrieves the registered job definition using loadJob(jobName). If no matching job definition is found in the registry, an unknown job error is logged and a non-retryable response is returned.

console.log(`[jobs:${jobName}] Executing job...`, envelope.data);
 
// Get the job definition
const job = await loadJob(jobName);
 
if (!job) {
  logger.error("jobs.unknown_job", {
    jobName,
  });
  await logger.flush();
 
  return new Response(`Unknown job "${jobName}" (non-retryable).`);
}

Sources: apps/web/app/api/jobs/process/jobName/route.ts:60-72

Warning

If a job's execution throws a Zod validation error (z.ZodError), the error is logged as an invalid payload and treated as permanent, returning a 2xx response so that QStash halts retries. Transient execution failures throw a 500 status code, signaling QStash to re-attempt delivery according to the job's retry policy.

Sources: apps/web/app/api/jobs/process/jobName/route.ts:74-102

Domain-Specific Job Handlers

Specific background job processors handle complex partner, group, and integration workflows dispatched through the central router. The partnerSearchSyncJob handles synchronization tasks with a parallelism limit of 20 concurrent executions using a flow control key (partner-search-sync), supporting a discriminated union input schema for either individual enrollments or bulk partners sync batches.

const SYNC_PARALLELISM = 20;
 
/**
 * Two shapes, because the two kinds of change have different blast radii.
 *
 * `enrollments` carries IDs the caller already has, and is the only shape that
 * can express a deletion, since the handler finds out by failing to read one
 * back. `partners` is for changes that fan out past one enrollment, where the
 * caller knows the partner but not how many programs it reaches.
 */
const inputSchema = z.discriminatedUnion("type", [
  z.object({
    type: z.literal("enrollments"),
    enrollmentIds: z
      .array(z.string())
      .min(1)
      .max(PARTNER_SEARCH_SYNC_BATCH_SIZE),
  }),
  z.object({
    type: z.literal("partners"),
    partnerIds: z.array(z.string()).min(1).max(PARTNER_SEARCH_SYNC_BATCH_SIZE),
    programId: z.string().optional(),
    after: z.string().optional(),
  }),
]);
 
export const partnerSearchSyncJob = defineJob({
  name: "partner-search-sync-job",
  schema: inputSchema,
  defaults: {
    retries: 3,
    flowControl: {
      key: "partner-search-sync",
      parallelism: SYNC_PARALLELISM,
    },
  },

Sources: apps/web/lib/jobs/handlers/partner-search-sync-job.ts:16-51

If no search provider is configured via getPartnerSearchProvider(), the search sync job exits early to prevent accumulating an unprocessable backlog. When processing partners input types, if the number of upserted items reaches the batch size limit PARTNER_SEARCH_SYNC_BATCH_SIZE alongside a valid lastEnrollmentId, the handler recursively dispatches another job instance with a 1-second delay.

async handle(input) {
  const searchProvider = getPartnerSearchProvider();
 
  // An environment without the key should not accumulate a backlog of jobs
  // that can never succeed.
  if (!searchProvider) {
    console.log(
      "[partnerSearchSyncJob] No search provider configured. Skipping...",
    );
    return;
  }
 
  if (input.type === "enrollments") {
    const { upserted, deleted } = await syncPartnerSearchDocuments({
      enrollmentIds: input.enrollmentIds,
      searchProvider,
    });
 
    console.log(
      `[partnerSearchSyncJob] Synced ${upserted} and removed ${deleted} enrollment documents.`,
    );
 
    return;
  }
 
  const { upserted, lastEnrollmentId } = await syncPartnerEnrollments({
    partnerIds: input.partnerIds,
    programId: input.programId,
    after: input.after,
    take: PARTNER_SEARCH_SYNC_BATCH_SIZE,
    searchProvider,
  });
 
  if (upserted === 0) {
    return;
  }
 
  console.log(
    `[partnerSearchSyncJob] Synced ${upserted} enrollment documents for ${input.partnerIds.length} partner(s).`,
  );
 
  if (upserted === PARTNER_SEARCH_SYNC_BATCH_SIZE && lastEnrollmentId) {
    await partnerSearchSyncJob.dispatch(
      {
        ...input,
        after: lastEnrollmentId,
      },
      {
        delay: 1,
      },
    );
  }
},

Sources: apps/web/lib/jobs/handlers/partner-search-sync-job.ts:52-104

Sources: apps/web/lib/jobs/handlers/partner-search-sync-job.ts:52-104

Order and partner group modification workflows manage transactional updates and external integrations. The processShopifyOrderJob validates order schemas and workspace settings, capturing webhook logs with status codes 200 on success or 400 on error. Concurrently, processPartnerGroupChangeJob validates group existence, clears stale link rewards and Redis link caches, queues search index updates, remaps default links, records link analytics, and triggers partner notifications.

export const processShopifyOrderJob = defineJob({
  name: "process-shopify-order-job",
  schema: inputSchema,
  async handle({ workspaceId, clickId, order }) {
    const startTime = Date.now();
 
    const workspace = await prisma.project.findUniqueOrThrow({
      where: {
        id: workspaceId,
      },
      select: {
        id: true,
        defaultProgramId: true,
        webhookEnabled: true,
      },
    });
 
    const requestLog = {
      workspaceId: workspace.id,
      method: "POST",
      path: "/shopify/integration/webhook" as const,
      requestBody: order,
      userAgent: "Shopify Order Job",
    };
 
    try {
      const result = await processShopifyOrder({
        order,
        workspace,
        clickId,
      });
 
      await logger.flush();
 
      await captureWebhookLog({
        ...requestLog,
        statusCode: 200,
        duration: Date.now() - startTime,
        responseBody: result,
      });
    } catch (error) {
      await captureWebhookLog({
        ...requestLog,
        statusCode: 400,
        duration: Date.now() - startTime,
        responseBody: serializeError(error),
      });
 
      await logger.flush();
    }
  },

Sources: apps/web/lib/jobs/handlers/process-shopify-order-job.ts:17-67, apps/web/lib/jobs/handlers/process-partner-group-change-job.ts:28-145

Webhook Retries and Failure Handling

Consecutive Failure Tracking and Circuit Breaking

When a webhook delivery fails, handleWebhookFailure increments the consecutiveFailures counter and updates lastFailedAt on the targeted webhook record in the database. If the webhook has already been marked with a disabledAt timestamp, the function returns immediately without further processing.

export const handleWebhookFailure = async (webhookId: string) => {
  const webhook = await prisma.webhook.update({
    where: {
      id: webhookId,
    },
    data: {
      consecutiveFailures: { increment: 1 },
      lastFailedAt: new Date(),
    },
    select: {
      id: true,
      url: true,
      secret: true,
      triggers: true,
      disabledAt: true,
      consecutiveFailures: true,
      lastFailedAt: true,
      projectId: true,
    },
  });
 
  if (webhook.disabledAt) {
    return;
  }

Sources: apps/web/lib/webhook/failure.ts:12-35

Note

Successful deliveries reset the consecutive failure count and clear lastFailedAt back to null by calling resetWebhookFailureCount.

Sources: apps/web/lib/webhook/failure.ts:65-73

Failure Notifications and Workspace Deactivation

As failures accumulate, the system dispatches notification emails to workspace owners and eventually deactivates the webhook once it breaches the disable threshold. If consecutiveFailures matches any value defined in WEBHOOK_FAILURE_NOTIFY_THRESHOLDS, notifyWebhookFailure queries the project's owner and sends a WebhookFailed email containing the webhook ID, URL, consecutive failure count, and disable threshold.

if (
  WEBHOOK_FAILURE_NOTIFY_THRESHOLDS.includes(
    webhook.consecutiveFailures as any,
  )
) {
  await notifyWebhookFailure(webhook);
  return;
}

Sources: apps/web/lib/webhook/failure.ts:37-44

When consecutiveFailures reaches or exceeds WEBHOOK_FAILURE_DISABLE_THRESHOLD, the webhook is permanently disabled by setting its disabledAt field to the current timestamp. Concurrently, Promise.allSettled triggers notifyWebhookDisabled to email workspace owners via the WebhookDisabled template and invokes syncWorkspaceWebhookStatus to update the project's webhook-enabled status.

  if (webhook.consecutiveFailures >= WEBHOOK_FAILURE_DISABLE_THRESHOLD) {
    // Disable the webhook
    const updatedWebhook = await prisma.webhook.update({
      where: { id: webhookId },
      data: {
        disabledAt: new Date(),
      },
    });
 
    await Promise.allSettled([
      // Notify the user
      notifyWebhookDisabled(updatedWebhook),
 
      // Update the project webhookEnabled flag
      syncWorkspaceWebhookStatus(webhook.projectId),
    ]);
  }
};

Sources: apps/web/lib/webhook/failure.ts:46-63

Was this page helpful?