Relevant source files
The following files were used as context for generating this wiki page:
- apps/web/app/ee/api/cron/streams/send-link-clicked-webhooks/route.ts
- apps/web/app/ee/api/cron/streams/update-click-stats/route.ts
- apps/web/app/ee/api/cron/streams/update-workspace-links-usage/route.ts
- apps/web/app/ee/api/cron/export/events/workspace/route.ts
- apps/web/app/ee/api/cron/usage/utils.ts
- apps/web/lib/upstash/redis-streams/workspace-links-usage.ts
- apps/web/app/ee/api/cron/queue/retry/route.ts
- apps/web/app/ee/api/cron/rewards/process/route.ts
- apps/web/app/ee/api/cron/domains/transfer/route.ts
- apps/web/lib/upstash/redis-streams/workspace-click-events.ts
- apps/web/lib/upstash/redis-streams/link-click-events.ts
- apps/web/lib/jobs/outbox.ts
- apps/web/app/ee/api/cloudflare/webhook/r2-object-created/route.ts
- apps/web/app/ee/api/cron/sync-redis-resources/route.ts
- apps/web/app/ee/api/cron/aggregate-clicks/route.ts
- apps/web/lib/jobs/send-workflows.ts
- apps/web/app/ee/api/stripe/webhook/utils/update-workspace-plan.ts
- apps/web/app/ee/api/cron/workspaces/delete/utils.ts
- apps/web/app/ee/api/cron/streams/update-partner-stats/route.ts
- apps/web/app/ee/api/cron/cleanup/link-retention/route.ts
- apps/web/lib/jobs/handlers/process-shopify-order-job.ts
- apps/web/lib/webhook/failure.ts
- apps/web/lib/webhook/click-webhook-workspaces.ts
- apps/web/app/api/jobs/process/jobName/route.ts
- apps/web/lib/upstash/redis-streams/client.ts
- apps/web/lib/api/rewards/queue-reward-processing.ts
- apps/web/app/ee/api/cron/sync-redis-resources/cleanup-redundant-link-webhook-entries.ts
- apps/web/lib/jobs/handlers/partner-search-sync-job.ts
- apps/web/lib/integrations/shopify/checkout-cache.ts
- apps/web/lib/jobs/handlers/process-partner-group-change-job.ts
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 Constant | Module File | Payload Interface / Schema | Database Fallback Action |
|---|---|---|---|
link:click:events | link-click-events.ts | LinkClickEvent | UPDATE Link SET clicks = clicks + 1... & optional Project/Program update |
workspace:click:events | workspace-click-events.ts | clickEventSchemaTB | Log error to Axiom and rethrow |
workspace:linksUsage:updates | workspace-links-usage.ts | WorkspaceLinksUsageEvent | UPDATE 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
Link Click Event Processing
Link Click Event Processing and Webhook Dispatches
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 Path | Redis Stream Key | Lock Key | Lock TTL (s) | Batch Size |
|---|---|---|---|---|
/api/cron/streams/update-click-stats | link:click:events | lock:update-click-stats | 600 | 10,000 |
/api/cron/streams/send-link-clicked-webhooks | workspace:click:events | lock:send-link-clicked-webhooks | 600 | 6,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
Aggregating Link and Partner Statistics
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
Workspace Link Usage Accounting
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 Range | Mapped 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 Status | Action Taken | Prisma Operation |
|---|---|---|
published | Delete successfully dispatched jobs | prisma.job.deleteMany |
failed | Increment attempt counter and record lastError | prisma.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
Related
On This Page
OverviewRedis Stream Pipeline InfrastructureStream Operations and Client AbstractionEvent Publishers and Database FallbacksLink Click Event ProcessingLink Click Event Processing and Webhook DispatchesAggregating Link and Partner StatisticsWorkspace Webhook Dispatch WorkflowsWorkspace Link Usage AccountingStream Consumer Workflows and BatchingQuota Thresholds and Limit AlertsTransactional Job Outbox PatternJob Persistence and Deferred ExecutionCron Retry Execution and Settle MechanicsAsynchronous Job Handlers ExecutionAsynchronous Job Execution RouterDomain-Specific Job HandlersWebhook Retries and Failure HandlingConsecutive Failure Tracking and Circuit BreakingFailure Notifications and Workspace DeactivationRelated