Skip to content

Commit 8219876

Browse files
fix(outbox): prevent bulk cleanup from starving delivery (#7654)
* fix(outbox): prevent bulk cleanup from starving delivery * test(outbox): isolate scheduler PostgreSQL fixtures
1 parent e83deee commit 8219876

14 files changed

Lines changed: 26375 additions & 41 deletions

File tree

.github/workflows/test-build.yml

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -179,14 +179,16 @@ jobs:
179179
lib/table/rows/secret-provenance.postgres.test.ts
180180
lib/memory/message-provenance.postgres.test.ts
181181
182-
- name: Verify Search progress and pagination in PostgreSQL
182+
- name: Verify Search progress, pagination, and outbox scheduling in PostgreSQL
183183
working-directory: apps/sim
184184
env:
185185
KNOWLEDGE_ACL_TEST_DATABASE_URL: postgresql://postgres:postgres@127.0.0.1:5432/sim_auth_scim
186186
run: >-
187187
bunx vitest run --mode integration
188188
lib/knowledge/__integration__/search-source-progress.integration.ts
189189
lib/knowledge/__integration__/search-source-pagination.integration.ts
190+
lib/core/outbox/service.integration.ts
191+
lib/knowledge/__integration__/connector-upload.integration.ts
190192
191193
test-build:
192194
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: 277 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,277 @@
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 { withUtcTimestamps } from '@sim/db/timestamps'
5+
import { generateId } from '@sim/utils/id'
6+
import { eq, inArray, sql } from 'drizzle-orm'
7+
import { drizzle, type PostgresJsDatabase } from 'drizzle-orm/postgres-js'
8+
import postgres from 'postgres'
9+
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest'
10+
11+
const database = vi.hoisted(() => ({ current: undefined as PostgresJsDatabase | undefined }))
12+
13+
vi.mock('@sim/db', () => ({
14+
get db() {
15+
if (!database.current) throw new Error('Outbox PostgreSQL test database is not initialized')
16+
return database.current
17+
},
18+
}))
19+
20+
import {
21+
type OutboxHandler,
22+
processOutboxEvents,
23+
withOutboxHandlerTimeout,
24+
} from '@/lib/core/outbox/service'
25+
26+
interface QueryPlan {
27+
'Node Type': string
28+
'Index Name'?: string
29+
Plans?: QueryPlan[]
30+
}
31+
32+
function planNodes(plan: QueryPlan): QueryPlan[] {
33+
return [plan, ...(plan.Plans ?? []).flatMap(planNodes)]
34+
}
35+
36+
describe('outbox scheduling in PostgreSQL', () => {
37+
const eventTypes = new Set<string>()
38+
const schemaName = `outbox_test_${generateId().replaceAll('-', '')}`
39+
const databaseUrl = process.env.KNOWLEDGE_ACL_TEST_DATABASE_URL
40+
if (!databaseUrl) throw new Error('Outbox tests require a disposable local database')
41+
const connection = postgres(
42+
databaseUrl,
43+
withUtcTimestamps({
44+
max: 4,
45+
prepare: false,
46+
fetch_types: false,
47+
connection: { search_path: schemaName },
48+
onnotice: () => {},
49+
})
50+
)
51+
52+
beforeAll(async () => {
53+
await connection`CREATE SCHEMA ${connection(schemaName)}`
54+
/** Copy the provisioned table and indexes without consuming another suite's pending events. */
55+
await connection`CREATE TABLE outbox_event (LIKE public.outbox_event INCLUDING ALL)`
56+
database.current = drizzle(connection)
57+
})
58+
59+
afterEach(async () => {
60+
if (eventTypes.size) {
61+
await db.delete(outboxEvent).where(inArray(outboxEvent.eventType, [...eventTypes]))
62+
}
63+
eventTypes.clear()
64+
})
65+
66+
afterAll(async () => {
67+
try {
68+
await connection`DROP SCHEMA ${connection(schemaName)} CASCADE`
69+
} finally {
70+
await connection.end()
71+
database.current = undefined
72+
}
73+
})
74+
75+
async function enqueue(eventType: string, count: number, ageMs = 10_000) {
76+
if (count > 1_000) throw new Error('Use bounded SQL batches for large fixtures')
77+
const now = Date.now()
78+
const rows = Array.from({ length: count }, (_, index) => ({
79+
id: generateId(),
80+
eventType,
81+
payload: {},
82+
createdAt: new Date(now - ageMs + index),
83+
availableAt: new Date(now - 1),
84+
}))
85+
eventTypes.add(eventType)
86+
await db.insert(outboxEvent).values(rows)
87+
return rows
88+
}
89+
90+
async function seedBacklog(
91+
eventType: string,
92+
count: number,
93+
status: 'pending' | 'completed' | 'processing'
94+
) {
95+
const prefix = generateId()
96+
const availableAt = new Date(Date.now() - 60_000)
97+
const createdAt = new Date(Date.now() - 24 * 60 * 60_000)
98+
const lockedAt = status === 'processing' ? new Date(Date.now() - 11 * 60_000) : null
99+
eventTypes.add(eventType)
100+
for (let offset = 0; offset < count; offset += 1_000) {
101+
await db.execute(sql`
102+
INSERT INTO outbox_event
103+
(id, event_type, payload, status, available_at, created_at, locked_at)
104+
SELECT ${prefix} || ':' || n, ${eventType}, '{}'::json, ${status},
105+
${availableAt.toISOString()}::timestamp,
106+
${createdAt.toISOString()}::timestamp + n * interval '1 millisecond',
107+
${lockedAt?.toISOString() ?? null}::timestamp
108+
FROM generate_series(${offset}::integer, ${Math.min(offset + 999, count - 1)}::integer) AS n
109+
`)
110+
}
111+
}
112+
113+
it('serves newer event types before exhausting an older cleanup backlog', async () => {
114+
await enqueue('test.outbox.cleanup', 1_000)
115+
const [dispatch] = await enqueue('test.outbox.dispatch', 1, 2_000)
116+
const [billing] = await enqueue('test.outbox.billing', 1, 1_000)
117+
const delivered: string[] = []
118+
const handler: OutboxHandler = async (_payload, context) => {
119+
delivered.push(context.eventId)
120+
}
121+
122+
const result = await processOutboxEvents(
123+
{
124+
'test.outbox.cleanup': handler,
125+
'test.outbox.dispatch': handler,
126+
'test.outbox.billing': handler,
127+
},
128+
{ batchSize: 20 }
129+
)
130+
131+
expect(result.processed).toBe(20)
132+
expect(delivered.slice(0, 3)).toContain(dispatch.id)
133+
expect(delivered.slice(0, 3)).toContain(billing.id)
134+
})
135+
136+
it('keeps draining a single event type up to the batch limit in creation order', async () => {
137+
const rows = await enqueue('test.outbox.cleanup', 30)
138+
const delivered: string[] = []
139+
const handler: OutboxHandler = async (_payload, context) => {
140+
delivered.push(context.eventId)
141+
}
142+
143+
const result = await processOutboxEvents({ 'test.outbox.cleanup': handler }, { batchSize: 25 })
144+
145+
expect(result.processed).toBe(25)
146+
expect(delivered).toEqual(rows.slice(0, 25).map((row) => row.id))
147+
})
148+
149+
it('keeps future events pending while serving other ready event types', async () => {
150+
const [future] = await enqueue('test.outbox.future', 1)
151+
await db
152+
.update(outboxEvent)
153+
.set({ availableAt: new Date(Date.now() + 60_000) })
154+
.where(eq(outboxEvent.id, future.id))
155+
await enqueue('test.outbox.cleanup', 5)
156+
const delivered: string[] = []
157+
const handler: OutboxHandler = async (_payload, context) => {
158+
delivered.push(context.eventId)
159+
}
160+
161+
const result = await processOutboxEvents({
162+
'test.outbox.future': handler,
163+
'test.outbox.cleanup': handler,
164+
})
165+
166+
expect(result.processed).toBe(5)
167+
expect(delivered).not.toContain(future.id)
168+
})
169+
170+
it('does not deliver the same event twice when cron invocations overlap', async () => {
171+
await enqueue('test.outbox.cleanup', 50)
172+
await enqueue('test.outbox.dispatch', 2, 1_000)
173+
const delivered: string[] = []
174+
const handler: OutboxHandler = async (_payload, context) => {
175+
delivered.push(context.eventId)
176+
}
177+
const handlers = { 'test.outbox.cleanup': handler, 'test.outbox.dispatch': handler }
178+
179+
const results = await Promise.all([
180+
processOutboxEvents(handlers, { batchSize: 40 }),
181+
processOutboxEvents(handlers, { batchSize: 40 }),
182+
])
183+
184+
expect(results.reduce((sum, result) => sum + result.processed, 0)).toBe(52)
185+
expect(delivered).toHaveLength(52)
186+
expect(new Set(delivered).size).toBe(52)
187+
})
188+
189+
it('serves rare types using indexed heads beside 100,000 pending and 100,000 completed events', async () => {
190+
await seedBacklog('test.outbox.cleanup', 100_000, 'completed')
191+
await seedBacklog('test.outbox.cleanup', 100_000, 'pending')
192+
const [dispatch] = await enqueue('test.outbox.dispatch', 1)
193+
const [billing] = await enqueue('test.outbox.billing', 1)
194+
await db.execute(sql`ANALYZE outbox_event`)
195+
196+
const plans = await db.execute<{ 'QUERY PLAN': { Plan: QueryPlan }[] }>(sql`
197+
EXPLAIN (FORMAT JSON)
198+
SELECT * FROM outbox_event
199+
WHERE status = 'pending' AND event_type = 'test.outbox.cleanup'
200+
AND available_at <= now()
201+
ORDER BY available_at, created_at, id
202+
LIMIT 1 FOR UPDATE SKIP LOCKED
203+
`)
204+
const nodes = planNodes(plans[0]['QUERY PLAN'][0].Plan)
205+
const indexScan = nodes.find((node) => node['Node Type'] === 'Index Scan')
206+
expect(indexScan).toBeDefined()
207+
const [index] = await connection<{ indexdef: string }[]>`
208+
SELECT indexdef FROM pg_indexes
209+
WHERE schemaname = ${schemaName} AND indexname = ${indexScan?.['Index Name'] ?? ''}
210+
LIMIT 1
211+
`
212+
expect(index.indexdef).toContain('(event_type, available_at, created_at, id)')
213+
expect(index.indexdef).toContain("WHERE (status = 'pending'::text)")
214+
expect(nodes.some((node) => node['Node Type'] === 'Sort')).toBe(false)
215+
216+
const delivered: string[] = []
217+
const handler: OutboxHandler = async (_payload, context) => {
218+
delivered.push(context.eventId)
219+
}
220+
const result = await processOutboxEvents(
221+
{
222+
'test.outbox.cleanup': handler,
223+
'test.outbox.dispatch': handler,
224+
'test.outbox.billing': handler,
225+
},
226+
{ batchSize: 20 }
227+
)
228+
229+
expect(result.processed).toBe(20)
230+
expect(delivered.slice(0, 3)).toContain(dispatch.id)
231+
expect(delivered.slice(0, 3)).toContain(billing.id)
232+
}, 60_000)
233+
234+
it('runs eligible short handlers without claiming a type that exceeds the deadline', async () => {
235+
const [long] = await enqueue('test.outbox.long', 1)
236+
const [short] = await enqueue('test.outbox.short', 1)
237+
const delivered: string[] = []
238+
const handler: OutboxHandler = async (_payload, context) => {
239+
delivered.push(context.eventId)
240+
}
241+
const result = await processOutboxEvents(
242+
{
243+
'test.outbox.long': withOutboxHandlerTimeout(async () => {
244+
throw new Error('Long handler must remain pending')
245+
}, 550_000),
246+
'test.outbox.short': handler,
247+
},
248+
{ maxRuntimeMs: 110_000 }
249+
)
250+
251+
expect(result.processed).toBe(1)
252+
expect(delivered).toEqual([short.id])
253+
const [pending] = await db.select().from(outboxEvent).where(eq(outboxEvent.id, long.id))
254+
expect(pending).toMatchObject({ status: 'pending', attempts: 0, lockedAt: null })
255+
})
256+
257+
it('bounds stale-lease recovery and leaves excess rows for the next invocation', async () => {
258+
await seedBacklog('test.outbox.stale', 1_005, 'processing')
259+
260+
const first = await processOutboxEvents({}, { batchSize: 0 })
261+
expect(first.reaped).toBe(1_000)
262+
const counts = await db
263+
.select({ status: outboxEvent.status, count: sql<number>`count(*)::int` })
264+
.from(outboxEvent)
265+
.where(eq(outboxEvent.eventType, 'test.outbox.stale'))
266+
.groupBy(outboxEvent.status)
267+
expect(counts).toEqual(
268+
expect.arrayContaining([
269+
{ status: 'pending', count: 1_000 },
270+
{ status: 'processing', count: 5 },
271+
])
272+
)
273+
274+
const second = await processOutboxEvents({}, { batchSize: 0 })
275+
expect(second.reaped).toBe(5)
276+
})
277+
})

0 commit comments

Comments
 (0)