Skip to content

Commit 2a5b22c

Browse files
fix(outbox): process cron batches outside HTTP requests (#7911)
* fix(outbox): process cron batches outside HTTP requests * fix(outbox): preserve polling capacity with larger workers
1 parent f2a89aa commit 2a5b22c

9 files changed

Lines changed: 460 additions & 98 deletions

File tree

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
/**
2+
* @vitest-environment node
3+
*/
4+
import { createMockRequest } from '@sim/testing'
5+
import { beforeEach, describe, expect, it, vi } from 'vitest'
6+
7+
const mocks = vi.hoisted(() => ({ enqueue: vi.fn(), verifyCronAuth: vi.fn() }))
8+
vi.mock('@/lib/auth/internal', () => ({ verifyCronAuth: mocks.verifyCronAuth }))
9+
vi.mock('@/lib/core/outbox/enqueue', () => ({ enqueueOutboxProcessor: mocks.enqueue }))
10+
11+
import { GET } from '@/app/api/webhooks/outbox/process/route'
12+
13+
const request = () =>
14+
createMockRequest('GET', undefined, {}, 'http://localhost:3000/api/webhooks/outbox/process')
15+
16+
describe('outbox cron route', () => {
17+
beforeEach(() => {
18+
vi.clearAllMocks()
19+
mocks.verifyCronAuth.mockReturnValue(null)
20+
})
21+
it('authenticates before accepting any background work', async () => {
22+
mocks.verifyCronAuth.mockReturnValue(new Response(null, { status: 401 }))
23+
expect((await GET(request())).status).toBe(401)
24+
expect(mocks.enqueue).not.toHaveBeenCalled()
25+
})
26+
it('acknowledges a durably accepted task with 202', async () => {
27+
mocks.enqueue.mockResolvedValue({ backend: 'trigger-dev', jobId: 'run-1' })
28+
const response = await GET(request())
29+
expect(response.status).toBe(202)
30+
await expect(response.json()).resolves.toEqual({
31+
success: true,
32+
requestId: expect.any(String),
33+
triggered: true,
34+
backend: 'trigger-dev',
35+
jobId: 'run-1',
36+
})
37+
})
38+
it('preserves the existing 200 response for synchronous self-hosted runs', async () => {
39+
const output = {
40+
result: { processed: 1, retried: 0, deadLettered: 0, leaseLost: 0, reaped: 0 },
41+
reapedBackgroundWork: 0,
42+
recoveredDocuments: 0,
43+
}
44+
mocks.enqueue.mockResolvedValue({ backend: 'inline', output })
45+
const response = await GET(request())
46+
expect(response.status).toBe(200)
47+
await expect(response.json()).resolves.toEqual({
48+
success: true,
49+
requestId: expect.any(String),
50+
...output,
51+
})
52+
})
53+
it('returns an error when durable acceptance fails', async () => {
54+
mocks.enqueue.mockRejectedValue(new Error('Trigger unavailable'))
55+
const response = await GET(request())
56+
expect(response.status).toBe(500)
57+
await expect(response.json()).resolves.toMatchObject({
58+
success: false,
59+
error: 'Trigger unavailable',
60+
})
61+
})
62+
})

‎apps/sim/app/api/webhooks/outbox/process/route.ts‎

Lines changed: 15 additions & 98 deletions
Original file line numberDiff line numberDiff line change
@@ -1,118 +1,35 @@
1-
import { db } from '@sim/db'
21
import { createLogger } from '@sim/logger'
32
import { toError } from '@sim/utils/errors'
43
import { type NextRequest, NextResponse } from 'next/server'
5-
import { adminInvitationOperationOutboxHandlers } from '@/lib/admin/invitation-operation'
6-
import { adminMemberOperationOutboxHandlers } from '@/lib/admin/member-operation'
74
import { verifyCronAuth } from '@/lib/auth/internal'
8-
import { enterpriseOwnerClaimOutboxHandlers } from '@/lib/billing/enterprise-owner-claim'
9-
import { enterpriseIssuanceOutboxHandlers } from '@/lib/billing/enterprise-provisioning'
10-
import { membershipBillingOutboxHandlers } from '@/lib/billing/organizations/membership-reconciliation'
11-
import { billingOutboxHandlers } from '@/lib/billing/webhooks/outbox-handlers'
12-
import { processOutboxEvents } from '@/lib/core/outbox/service'
13-
import { DeadlineExceededError } from '@/lib/core/utils/deadline'
5+
import { enqueueOutboxProcessor } from '@/lib/core/outbox/enqueue'
146
import { generateRequestId } from '@/lib/core/utils/request'
157
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
16-
import { directGrantOutboxHandlers } from '@/lib/invitations/direct-grant'
17-
import { slackSearchOutboxHandlers } from '@/lib/knowledge/application/slack-search/outbox'
18-
import { getConnectorFailureDiagnostic } from '@/lib/knowledge/connectors/connector-error'
19-
import { knowledgeDocumentProcessingOutboxHandlers } from '@/lib/knowledge/documents/processing-outbox-handler'
20-
import { recoverKnowledgeDocumentProcessing } from '@/lib/knowledge/documents/processing-recovery'
21-
import { organizationResourceCleanupOutboxHandlers } from '@/lib/organizations/resource-cleanup'
22-
import { permissionAccessRequestOutboxHandlers } from '@/lib/permission-access-requests/notifications'
23-
import { workspaceFileLiveDocOutboxHandlers } from '@/lib/uploads/contexts/workspace/workspace-file-live-doc-outbox'
24-
import { workspaceFileStorageCleanupOutboxHandlers } from '@/lib/uploads/contexts/workspace/workspace-file-storage-cleanup-outbox'
25-
import { workflowDeploymentOutboxHandlers } from '@/lib/workflows/deployment-outbox'
26-
import { invitationMigrationOutboxHandlers } from '@/lib/workspaces/admin-move'
27-
import { workspaceOperationOutboxHandlers } from '@/lib/workspaces/operations/outbox'
28-
import { forkContentOutboxHandlers } from '@/ee/workspace-forking/application/content-outbox'
29-
import { reapStaleBackgroundWork } from '@/ee/workspace-forking/lib/background-work/store'
308

319
const logger = createLogger('OutboxProcessorAPI')
3210

3311
export const dynamic = 'force-dynamic'
12+
/** Self-hosted deployments without Trigger.dev retain the synchronous processing window. */
3413
export const maxDuration = 800
3514

36-
const handlers = {
37-
...slackSearchOutboxHandlers,
38-
...adminInvitationOperationOutboxHandlers,
39-
...adminMemberOperationOutboxHandlers,
40-
...billingOutboxHandlers,
41-
...membershipBillingOutboxHandlers,
42-
...enterpriseIssuanceOutboxHandlers,
43-
...enterpriseOwnerClaimOutboxHandlers,
44-
...invitationMigrationOutboxHandlers,
45-
...directGrantOutboxHandlers,
46-
...knowledgeDocumentProcessingOutboxHandlers,
47-
...organizationResourceCleanupOutboxHandlers,
48-
...permissionAccessRequestOutboxHandlers,
49-
...workspaceFileLiveDocOutboxHandlers,
50-
...workspaceFileStorageCleanupOutboxHandlers,
51-
...workflowDeploymentOutboxHandlers,
52-
...workspaceOperationOutboxHandlers,
53-
...forkContentOutboxHandlers,
54-
} as const
55-
15+
/** The cron secret authorizes this lifecycle endpoint; hosted processing runs outside the HTTP request. */
5616
export const GET = withRouteHandler(async (request: NextRequest) => {
57-
const requestId = generateRequestId()
17+
const authError = verifyCronAuth(request, 'Outbox processor')
18+
if (authError) return authError
5819

20+
const requestId = generateRequestId()
5921
try {
60-
const authError = verifyCronAuth(request, 'Outbox processor')
61-
if (authError) {
62-
return authError
63-
}
64-
65-
const startedAt = Date.now()
66-
const result = await processOutboxEvents(handlers, {
67-
batchSize: 500,
68-
maxRuntimeMs: 760_000,
69-
minRemainingMs: 95_000,
70-
})
71-
72-
let recoveredDocuments = 0
73-
try {
74-
if (Date.now() - startedAt < 770_000) {
75-
recoveredDocuments = await recoverKnowledgeDocumentProcessing()
76-
}
77-
} catch (error) {
78-
logger.error('Stored document recovery failed', {
79-
requestId,
80-
error: getConnectorFailureDiagnostic(error) ?? {
81-
category: error instanceof DeadlineExceededError ? 'deadline' : 'internal',
82-
message:
83-
error instanceof DeadlineExceededError
84-
? error.message
85-
: 'Unexpected stored-document recovery failure',
86-
},
87-
})
88-
}
89-
90-
// Reap fork background-work rows stuck `processing` past their TTL (worker crash /
91-
// restart has no in-task hook). Independent of the outbox; a failure here must not
92-
// fail the outbox run, so it's guarded separately.
93-
let reapedBackgroundWork = 0
94-
try {
95-
reapedBackgroundWork = await reapStaleBackgroundWork(db)
96-
} catch (error) {
97-
logger.error('Background-work reap failed', { requestId, error: toError(error).message })
22+
const accepted = await enqueueOutboxProcessor()
23+
if (accepted.backend === 'trigger-dev') {
24+
logger.info('Outbox processor accepted', { jobId: accepted.jobId })
25+
return NextResponse.json(
26+
{ success: true, requestId, triggered: true, ...accepted },
27+
{ status: 202 }
28+
)
9829
}
99-
100-
logger.info('Outbox processing completed', {
101-
requestId,
102-
...result,
103-
reapedBackgroundWork,
104-
recoveredDocuments,
105-
})
106-
107-
return NextResponse.json({
108-
success: true,
109-
requestId,
110-
result,
111-
reapedBackgroundWork,
112-
recoveredDocuments,
113-
})
30+
return NextResponse.json({ success: true, requestId, ...accepted.output })
11431
} catch (error) {
115-
logger.error('Outbox processing failed', { requestId, error: toError(error).message })
32+
logger.error('Outbox processing failed', { error: toError(error).message })
11633
return NextResponse.json(
11734
{ success: false, requestId, error: toError(error).message },
11835
{ status: 500 }
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
/**
2+
* @vitest-environment node
3+
*/
4+
import { beforeEach, describe, expect, it, vi } from 'vitest'
5+
6+
const mocks = vi.hoisted(() => ({ processor: vi.fn() }))
7+
vi.mock('@trigger.dev/sdk', () => ({ task: (config: unknown) => config }))
8+
vi.mock('@/lib/core/outbox/processor', () => ({ runOutboxProcessor: mocks.processor }))
9+
10+
import { processOutboxTask } from '@/background/process-outbox'
11+
12+
describe('outbox processor task', () => {
13+
beforeEach(() => vi.clearAllMocks())
14+
it('bounds worker concurrency and lets the durable outbox own event retries', async () => {
15+
expect(processOutboxTask).toMatchObject({
16+
id: 'process-outbox',
17+
machine: 'medium-2x',
18+
maxDuration: 900,
19+
retry: { maxAttempts: 1 },
20+
queue: { name: 'process-outbox', concurrencyLimit: 15 },
21+
})
22+
const output = { result: { processed: 3 }, recoveredDocuments: 0, reapedBackgroundWork: 0 }
23+
mocks.processor.mockResolvedValueOnce(output)
24+
await expect(processOutboxTask.run()).resolves.toEqual(output)
25+
})
26+
it('surfaces processor failures to Trigger', async () => {
27+
mocks.processor.mockRejectedValueOnce(new Error('database unavailable'))
28+
await expect(processOutboxTask.run()).rejects.toThrow('database unavailable')
29+
})
30+
})
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
import { task } from '@trigger.dev/sdk'
2+
import {
3+
OUTBOX_PROCESSOR_CONCURRENCY,
4+
OUTBOX_PROCESSOR_MAX_DURATION_SECONDS,
5+
} from '@/lib/core/outbox/constants'
6+
import { runOutboxProcessor } from '@/lib/core/outbox/processor'
7+
8+
/** Runs bounded outbox delivery beyond the cron caller's HTTP deadline. */
9+
export const processOutboxTask = task({
10+
id: 'process-outbox',
11+
machine: 'medium-2x',
12+
maxDuration: OUTBOX_PROCESSOR_MAX_DURATION_SECONDS,
13+
/** Per-event retries live in the outbox; a later cron tick recovers interrupted work. */
14+
retry: { maxAttempts: 1 },
15+
queue: {
16+
name: 'process-outbox',
17+
concurrencyLimit: OUTBOX_PROCESSOR_CONCURRENCY,
18+
},
19+
run: () => runOutboxProcessor(),
20+
})
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
export const OUTBOX_PROCESSOR_MAX_RUNTIME_MS = 760_000
2+
export const OUTBOX_PROCESSOR_RECOVERY_CUTOFF_MS = 770_000
3+
export const OUTBOX_PROCESSOR_MAX_DURATION_SECONDS = 900
4+
export const OUTBOX_PROCESSOR_INTERVAL_MS = 60_000
5+
/** Allow every scheduled tick to start even when earlier workers use their full execution window. */
6+
export const OUTBOX_PROCESSOR_CONCURRENCY = Math.ceil(
7+
(OUTBOX_PROCESSOR_MAX_DURATION_SECONDS * 1000) / OUTBOX_PROCESSOR_INTERVAL_MS
8+
)
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
/**
2+
* @vitest-environment node
3+
*/
4+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
5+
6+
const mocks = vi.hoisted(() => ({
7+
trigger: vi.fn(),
8+
processor: vi.fn(),
9+
enabled: true,
10+
}))
11+
vi.mock('@trigger.dev/sdk', () => ({ tasks: { trigger: mocks.trigger } }))
12+
vi.mock('@/lib/core/config/env-flags', () => ({
13+
get isTriggerDevEnabled() {
14+
return mocks.enabled
15+
},
16+
}))
17+
vi.mock('@/lib/core/async-jobs/region', () => ({ resolveTriggerRegion: async () => 'us-east-1' }))
18+
vi.mock('@/lib/core/outbox/processor', () => ({ runOutboxProcessor: mocks.processor }))
19+
20+
import { enqueueOutboxProcessor } from '@/lib/core/outbox/enqueue'
21+
22+
describe('outbox processor enqueue', () => {
23+
beforeEach(() => {
24+
vi.clearAllMocks()
25+
vi.useFakeTimers()
26+
vi.setSystemTime(new Date('2026-09-16T12:34:45Z'))
27+
mocks.enabled = true
28+
mocks.trigger.mockResolvedValue({ id: 'run-1' })
29+
})
30+
afterEach(() => vi.useRealTimers())
31+
32+
it('returns durable acceptance without doing outbox work in the request', async () => {
33+
await expect(enqueueOutboxProcessor()).resolves.toEqual({
34+
backend: 'trigger-dev',
35+
jobId: 'run-1',
36+
})
37+
expect(mocks.trigger).toHaveBeenCalledWith('process-outbox', undefined, {
38+
idempotencyKey: `process-outbox:${Math.floor(Date.now() / 60_000)}`,
39+
idempotencyKeyTTL: '5m',
40+
maxDuration: 900,
41+
region: 'us-east-1',
42+
})
43+
expect(mocks.processor).not.toHaveBeenCalled()
44+
})
45+
46+
it('deduplicates duplicate ticks while allowing the next minute to drain more work', async () => {
47+
await enqueueOutboxProcessor()
48+
await enqueueOutboxProcessor()
49+
vi.advanceTimersByTime(60_000)
50+
await enqueueOutboxProcessor()
51+
const keys = mocks.trigger.mock.calls.map((call) => call[2].idempotencyKey)
52+
expect(keys[0]).toBe(keys[1])
53+
expect(keys[2]).not.toBe(keys[0])
54+
})
55+
56+
it('fails closed on an enqueue error without starting concurrent inline work', async () => {
57+
mocks.trigger.mockRejectedValueOnce(new Error('Trigger unavailable'))
58+
await expect(enqueueOutboxProcessor()).rejects.toThrow('Trigger unavailable')
59+
expect(mocks.processor).not.toHaveBeenCalled()
60+
})
61+
62+
it('preserves synchronous processing for self-hosted deployments without Trigger', async () => {
63+
mocks.enabled = false
64+
const output = {
65+
result: { processed: 4, retried: 0, deadLettered: 0, leaseLost: 0, reaped: 0 },
66+
recoveredDocuments: 2,
67+
reapedBackgroundWork: 1,
68+
}
69+
mocks.processor.mockResolvedValueOnce(output)
70+
await expect(enqueueOutboxProcessor()).resolves.toEqual({ backend: 'inline', output })
71+
expect(mocks.trigger).not.toHaveBeenCalled()
72+
})
73+
})
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
import { isTriggerDevEnabled } from '@/lib/core/config/env-flags'
2+
import {
3+
OUTBOX_PROCESSOR_INTERVAL_MS,
4+
OUTBOX_PROCESSOR_MAX_DURATION_SECONDS,
5+
} from '@/lib/core/outbox/constants'
6+
import type { OutboxProcessorResult } from '@/lib/core/outbox/processor'
7+
import type { processOutboxTask } from '@/background/process-outbox'
8+
9+
type OutboxProcessorEnqueueResult =
10+
| { backend: 'trigger-dev'; jobId: string }
11+
| { backend: 'inline'; output: OutboxProcessorResult }
12+
13+
/** The database owns delivery state; the cron request waits only for durable worker acceptance. */
14+
export async function enqueueOutboxProcessor(): Promise<OutboxProcessorEnqueueResult> {
15+
if (!isTriggerDevEnabled) {
16+
const { runOutboxProcessor } = await import('@/lib/core/outbox/processor')
17+
return { backend: 'inline', output: await runOutboxProcessor() }
18+
}
19+
20+
const [{ tasks }, { resolveTriggerRegion }] = await Promise.all([
21+
import('@trigger.dev/sdk'),
22+
import('@/lib/core/async-jobs/region'),
23+
])
24+
const scheduleWindow = Math.floor(Date.now() / OUTBOX_PROCESSOR_INTERVAL_MS)
25+
const handle = await tasks.trigger<typeof processOutboxTask>('process-outbox', undefined, {
26+
idempotencyKey: `process-outbox:${scheduleWindow}`,
27+
idempotencyKeyTTL: '5m',
28+
maxDuration: OUTBOX_PROCESSOR_MAX_DURATION_SECONDS,
29+
region: await resolveTriggerRegion(),
30+
})
31+
return { backend: 'trigger-dev', jobId: handle.id }
32+
}

0 commit comments

Comments
 (0)