Skip to content

Commit ff8c92e

Browse files
committed
fix(outbox): prevent bulk cleanup from starving delivery
1 parent 0bba808 commit ff8c92e

14 files changed

Lines changed: 26331 additions & 41 deletions

File tree

‎.github/workflows/test-build.yml‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -153,14 +153,16 @@ jobs:
153153
lib/table/rows/secret-provenance.postgres.test.ts
154154
lib/memory/message-provenance.postgres.test.ts
155155
156-
- name: Verify Search progress and pagination in PostgreSQL
156+
- name: Verify Search progress, pagination, and outbox scheduling in PostgreSQL
157157
working-directory: apps/sim
158158
env:
159159
KNOWLEDGE_ACL_TEST_DATABASE_URL: postgresql://postgres:postgres@127.0.0.1:5432/sim_auth_scim
160160
run: >-
161161
bunx vitest run --mode integration
162162
lib/knowledge/__integration__/search-source-progress.integration.ts
163163
lib/knowledge/__integration__/search-source-pagination.integration.ts
164+
lib/core/outbox/service.integration.ts
165+
lib/knowledge/__integration__/connector-upload.integration.ts
164166
165167
test-build:
166168
name: Lint and Test

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,7 @@ export const GET = withRouteHandler(async (request: NextRequest) => {
5454
}
5555

5656
const result = await processOutboxEvents(handlers, {
57-
batchSize: 20,
57+
batchSize: 500,
5858
maxRuntimeMs: 790_000,
5959
minRemainingMs: 95_000,
6060
})
Lines changed: 233 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,233 @@
1+
/** Real PostgreSQL claims verify scheduling fairness and concurrent delivery. */
2+
import { db } from '@sim/db'
3+
import { outboxEvent } from '@sim/db/schema'
4+
import { generateId } from '@sim/utils/id'
5+
import { eq, inArray, sql } from 'drizzle-orm'
6+
import { afterAll, afterEach, describe, expect, it } from 'vitest'
7+
import {
8+
type OutboxHandler,
9+
processOutboxEvents,
10+
withOutboxHandlerTimeout,
11+
} from '@/lib/core/outbox/service'
12+
13+
interface QueryPlan {
14+
'Node Type': string
15+
'Index Name'?: string
16+
Plans?: QueryPlan[]
17+
}
18+
19+
function planNodes(plan: QueryPlan): QueryPlan[] {
20+
return [plan, ...(plan.Plans ?? []).flatMap(planNodes)]
21+
}
22+
23+
describe('outbox scheduling in PostgreSQL', () => {
24+
const eventTypes = new Set<string>()
25+
26+
afterEach(async () => {
27+
if (eventTypes.size) {
28+
await db.delete(outboxEvent).where(inArray(outboxEvent.eventType, [...eventTypes]))
29+
}
30+
eventTypes.clear()
31+
})
32+
33+
afterAll(async () => {
34+
await db.$client.end()
35+
})
36+
37+
async function enqueue(eventType: string, count: number, ageMs = 10_000) {
38+
if (count > 1_000) throw new Error('Use bounded SQL batches for large fixtures')
39+
const now = Date.now()
40+
const rows = Array.from({ length: count }, (_, index) => ({
41+
id: generateId(),
42+
eventType,
43+
payload: {},
44+
createdAt: new Date(now - ageMs + index),
45+
availableAt: new Date(now - 1),
46+
}))
47+
eventTypes.add(eventType)
48+
await db.insert(outboxEvent).values(rows)
49+
return rows
50+
}
51+
52+
async function seedBacklog(
53+
eventType: string,
54+
count: number,
55+
status: 'pending' | 'completed' | 'processing'
56+
) {
57+
const prefix = generateId()
58+
const availableAt = new Date(Date.now() - 60_000)
59+
const createdAt = new Date(Date.now() - 24 * 60 * 60_000)
60+
const lockedAt = status === 'processing' ? new Date(Date.now() - 11 * 60_000) : null
61+
eventTypes.add(eventType)
62+
for (let offset = 0; offset < count; offset += 1_000) {
63+
await db.execute(sql`
64+
INSERT INTO outbox_event
65+
(id, event_type, payload, status, available_at, created_at, locked_at)
66+
SELECT ${prefix} || ':' || n, ${eventType}, '{}'::json, ${status},
67+
${availableAt.toISOString()}::timestamp,
68+
${createdAt.toISOString()}::timestamp + n * interval '1 millisecond',
69+
${lockedAt?.toISOString() ?? null}::timestamp
70+
FROM generate_series(${offset}::integer, ${Math.min(offset + 999, count - 1)}::integer) AS n
71+
`)
72+
}
73+
}
74+
75+
it('serves newer event types before exhausting an older cleanup backlog', async () => {
76+
await enqueue('test.outbox.cleanup', 1_000)
77+
const [dispatch] = await enqueue('test.outbox.dispatch', 1, 2_000)
78+
const [billing] = await enqueue('test.outbox.billing', 1, 1_000)
79+
const delivered: string[] = []
80+
const handler: OutboxHandler = async (_payload, context) => {
81+
delivered.push(context.eventId)
82+
}
83+
84+
const result = await processOutboxEvents(
85+
{
86+
'test.outbox.cleanup': handler,
87+
'test.outbox.dispatch': handler,
88+
'test.outbox.billing': handler,
89+
},
90+
{ batchSize: 20 }
91+
)
92+
93+
expect(result.processed).toBe(20)
94+
expect(delivered.slice(0, 3)).toContain(dispatch.id)
95+
expect(delivered.slice(0, 3)).toContain(billing.id)
96+
})
97+
98+
it('keeps draining a single event type up to the batch limit in creation order', async () => {
99+
const rows = await enqueue('test.outbox.cleanup', 30)
100+
const delivered: string[] = []
101+
const handler: OutboxHandler = async (_payload, context) => {
102+
delivered.push(context.eventId)
103+
}
104+
105+
const result = await processOutboxEvents({ 'test.outbox.cleanup': handler }, { batchSize: 25 })
106+
107+
expect(result.processed).toBe(25)
108+
expect(delivered).toEqual(rows.slice(0, 25).map((row) => row.id))
109+
})
110+
111+
it('keeps future events pending while serving other ready event types', async () => {
112+
const [future] = await enqueue('test.outbox.future', 1)
113+
await db
114+
.update(outboxEvent)
115+
.set({ availableAt: new Date(Date.now() + 60_000) })
116+
.where(eq(outboxEvent.id, future.id))
117+
await enqueue('test.outbox.cleanup', 5)
118+
const delivered: string[] = []
119+
const handler: OutboxHandler = async (_payload, context) => {
120+
delivered.push(context.eventId)
121+
}
122+
123+
const result = await processOutboxEvents({
124+
'test.outbox.future': handler,
125+
'test.outbox.cleanup': handler,
126+
})
127+
128+
expect(result.processed).toBe(5)
129+
expect(delivered).not.toContain(future.id)
130+
})
131+
132+
it('does not deliver the same event twice when cron invocations overlap', async () => {
133+
await enqueue('test.outbox.cleanup', 50)
134+
await enqueue('test.outbox.dispatch', 2, 1_000)
135+
const delivered: string[] = []
136+
const handler: OutboxHandler = async (_payload, context) => {
137+
delivered.push(context.eventId)
138+
}
139+
const handlers = { 'test.outbox.cleanup': handler, 'test.outbox.dispatch': handler }
140+
141+
const results = await Promise.all([
142+
processOutboxEvents(handlers, { batchSize: 40 }),
143+
processOutboxEvents(handlers, { batchSize: 40 }),
144+
])
145+
146+
expect(results.reduce((sum, result) => sum + result.processed, 0)).toBe(52)
147+
expect(delivered).toHaveLength(52)
148+
expect(new Set(delivered).size).toBe(52)
149+
})
150+
151+
it('serves rare types using indexed heads beside 100,000 pending and 100,000 completed events', async () => {
152+
await seedBacklog('test.outbox.cleanup', 100_000, 'completed')
153+
await seedBacklog('test.outbox.cleanup', 100_000, 'pending')
154+
const [dispatch] = await enqueue('test.outbox.dispatch', 1)
155+
const [billing] = await enqueue('test.outbox.billing', 1)
156+
await db.execute(sql`ANALYZE outbox_event`)
157+
158+
const plans = await db.execute<{ 'QUERY PLAN': { Plan: QueryPlan }[] }>(sql`
159+
EXPLAIN (FORMAT JSON)
160+
SELECT * FROM outbox_event
161+
WHERE status = 'pending' AND event_type = 'test.outbox.cleanup'
162+
AND available_at <= now()
163+
ORDER BY available_at, created_at, id
164+
LIMIT 1 FOR UPDATE SKIP LOCKED
165+
`)
166+
const nodes = planNodes(plans[0]['QUERY PLAN'][0].Plan)
167+
expect(
168+
nodes.some((node) => node['Index Name'] === 'outbox_event_pending_type_available_idx')
169+
).toBe(true)
170+
expect(nodes.some((node) => node['Node Type'] === 'Sort')).toBe(false)
171+
172+
const delivered: string[] = []
173+
const handler: OutboxHandler = async (_payload, context) => {
174+
delivered.push(context.eventId)
175+
}
176+
const result = await processOutboxEvents(
177+
{
178+
'test.outbox.cleanup': handler,
179+
'test.outbox.dispatch': handler,
180+
'test.outbox.billing': handler,
181+
},
182+
{ batchSize: 20 }
183+
)
184+
185+
expect(result.processed).toBe(20)
186+
expect(delivered.slice(0, 3)).toContain(dispatch.id)
187+
expect(delivered.slice(0, 3)).toContain(billing.id)
188+
}, 60_000)
189+
190+
it('runs eligible short handlers without claiming a type that exceeds the deadline', async () => {
191+
const [long] = await enqueue('test.outbox.long', 1)
192+
const [short] = await enqueue('test.outbox.short', 1)
193+
const delivered: string[] = []
194+
const handler: OutboxHandler = async (_payload, context) => {
195+
delivered.push(context.eventId)
196+
}
197+
const result = await processOutboxEvents(
198+
{
199+
'test.outbox.long': withOutboxHandlerTimeout(async () => {
200+
throw new Error('Long handler must remain pending')
201+
}, 550_000),
202+
'test.outbox.short': handler,
203+
},
204+
{ maxRuntimeMs: 110_000 }
205+
)
206+
207+
expect(result.processed).toBe(1)
208+
expect(delivered).toEqual([short.id])
209+
const [pending] = await db.select().from(outboxEvent).where(eq(outboxEvent.id, long.id))
210+
expect(pending).toMatchObject({ status: 'pending', attempts: 0, lockedAt: null })
211+
})
212+
213+
it('bounds stale-lease recovery and leaves excess rows for the next invocation', async () => {
214+
await seedBacklog('test.outbox.stale', 1_005, 'processing')
215+
216+
const first = await processOutboxEvents({}, { batchSize: 0 })
217+
expect(first.reaped).toBe(1_000)
218+
const counts = await db
219+
.select({ status: outboxEvent.status, count: sql<number>`count(*)::int` })
220+
.from(outboxEvent)
221+
.where(eq(outboxEvent.eventType, 'test.outbox.stale'))
222+
.groupBy(outboxEvent.status)
223+
expect(counts).toEqual(
224+
expect.arrayContaining([
225+
{ status: 'pending', count: 1_000 },
226+
{ status: 'processing', count: 5 },
227+
])
228+
)
229+
230+
const second = await processOutboxEvents({}, { batchSize: 0 })
231+
expect(second.reaped).toBe(5)
232+
})
233+
})

0 commit comments

Comments
 (0)