Skip to content

Commit 4db4e43

Browse files
committed
fix(search): prune idle dispatch owners safely
1 parent d6203c3 commit 4db4e43

3 files changed

Lines changed: 101 additions & 3 deletions

File tree

apps/sim/lib/knowledge/__integration__/document-dispatch.integration.ts

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -202,6 +202,58 @@ describe('durable fair document admission', () => {
202202
}
203203
})
204204

205+
it('prunes idle owners in bounded batches while retaining delayed and outstanding work', async () => {
206+
const idleOwners = Array.from({ length: 1001 }, () => `organization:${generateId()}`)
207+
fixtureOwners.push(...idleOwners)
208+
await db
209+
.insert(knowledgeDocumentDispatchOwner)
210+
.values(idleOwners.map((ownerKey) => ({ ownerKey })))
211+
const delayed = await createSource()
212+
const active = await createSource()
213+
const [delayedPayload] = await createDocuments(delayed, 1)
214+
const [activePayload] = await createDocuments(active, 1)
215+
await enqueueDocumentProcessingDispatch([delayedPayload, activePayload])
216+
await db
217+
.update(knowledgeDocumentDispatch)
218+
.set({ availableAt: new Date(Date.now() + 60_000) })
219+
.where(eq(knowledgeDocumentDispatch.documentId, delayedPayload.documentId))
220+
221+
await claimDocumentProcessingDispatches()
222+
const retained = await db.select().from(knowledgeDocumentDispatchOwner)
223+
expect(retained.filter((owner) => idleOwners.includes(owner.ownerKey))).toHaveLength(1)
224+
expect(retained.map((owner) => owner.ownerKey)).toEqual(
225+
expect.arrayContaining([delayed.ownerKey, active.ownerKey])
226+
)
227+
await claimDocumentProcessingDispatches()
228+
expect(await db.select().from(knowledgeDocumentDispatchOwner)).toHaveLength(2)
229+
230+
await completeDocumentProcessingDispatch(activePayload)
231+
await claimDocumentProcessingDispatches()
232+
expect(
233+
(await db.select().from(knowledgeDocumentDispatchOwner)).map((owner) => owner.ownerKey)
234+
).toEqual([delayed.ownerKey])
235+
})
236+
237+
it('keeps every persisted intent discoverable when idle pruning races with enqueues', async () => {
238+
const sources = await Promise.all(Array.from({ length: 12 }, () => createSource()))
239+
await db
240+
.insert(knowledgeDocumentDispatchOwner)
241+
.values(sources.map(({ ownerKey }) => ({ ownerKey })))
242+
const payloads = await Promise.all(sources.map((source) => createDocuments(source, 1)))
243+
const results = await Promise.allSettled([
244+
...payloads.map((batch) => enqueueDocumentProcessingDispatch(batch)),
245+
...Array.from({ length: 6 }, () => claimDocumentProcessingDispatches()),
246+
])
247+
expect(results.every((result) => result.status === 'fulfilled')).toBe(true)
248+
const owners = new Set(
249+
(await db.select().from(knowledgeDocumentDispatchOwner)).map((owner) => owner.ownerKey)
250+
)
251+
const intents = await db.select().from(knowledgeDocumentDispatch)
252+
expect(intents).toHaveLength(sources.length)
253+
expect(intents.every((intent) => owners.has(intent.ownerKey))).toBe(true)
254+
expect(await claimDocumentProcessingDispatches()).toHaveLength(sources.length)
255+
})
256+
205257
it('lets one owner use available workers without queuing its entire corpus ahead of a newcomer', async () => {
206258
const large = await createSource()
207259
await enqueueDocumentProcessingDispatch(await createDocuments(large, 10_000))

apps/sim/lib/knowledge/application/operations.test.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,8 @@ describe('knowledge operation registry', () => {
5757
'knowledge.connectors.update',
5858
'knowledge.connectors.access.update',
5959
'knowledge.search.sources.list',
60+
'knowledge.search.sources.overview',
61+
'knowledge.search.sources.progress',
6062
'knowledge.search.integrations.list',
6163
'knowledge.search.integrations.approve',
6264
'knowledge.connectors.members.list',

apps/sim/lib/knowledge/documents/processing-dispatch-queue.ts

Lines changed: 47 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ const EXECUTION_CONCURRENCY = envNumber(env.KB_CONFIG_CONCURRENCY_LIMIT, 20, { m
2424
export const DOCUMENT_DISPATCH_OWNER_OUTSTANDING = EXECUTION_CONCURRENCY
2525
export const DOCUMENT_DISPATCH_MAX_OUTSTANDING = EXECUTION_CONCURRENCY * 2
2626
const ENQUEUE_BATCH_SIZE = 1000
27+
const OWNER_PRUNE_BATCH_SIZE = 1000
2728
const RECONCILE_AFTER_MS = 15 * 60 * 1000
2829
const DISPATCH_LOCK = 'knowledge-document-dispatch'
2930

@@ -119,11 +120,17 @@ async function persistDispatchIntents(
119120
})
120121
}
121122
if (values.length === 0) return []
122-
await tx.insert(knowledgeDocumentDispatch).values(values).onConflictDoNothing()
123+
/** Lock owners before inserting intents so idle-owner pruning cannot orphan accepted work. */
123124
await tx
124125
.insert(knowledgeDocumentDispatchOwner)
125-
.values([...new Set(values.map((value) => value.ownerKey))].map((ownerKey) => ({ ownerKey })))
126-
.onConflictDoNothing()
126+
.values(
127+
[...new Set(values.map((value) => value.ownerKey))].sort().map((ownerKey) => ({ ownerKey }))
128+
)
129+
.onConflictDoUpdate({
130+
target: knowledgeDocumentDispatchOwner.ownerKey,
131+
set: { ownerKey: sql`excluded.owner_key` },
132+
})
133+
await tx.insert(knowledgeDocumentDispatch).values(values).onConflictDoNothing()
127134
return tx
128135
.select()
129136
.from(knowledgeDocumentDispatch)
@@ -258,8 +265,45 @@ async function reconcileCompletedDispatches(): Promise<void> {
258265
})
259266
}
260267

268+
/** Release idle-owner locks before claims can wait on another owner's concurrent enqueue. */
269+
async function pruneIdleDispatchOwners(): Promise<void> {
270+
await db.transaction(async (tx) => {
271+
const idleOwner = sql`
272+
NOT EXISTS (SELECT 1 FROM knowledge_document_dispatch AS intent
273+
WHERE intent.owner_key = ${knowledgeDocumentDispatchOwner.ownerKey}
274+
AND intent.dispatched_at IS NULL)
275+
AND NOT EXISTS (SELECT 1 FROM knowledge_document_dispatch AS intent
276+
WHERE intent.owner_key = ${knowledgeDocumentDispatchOwner.ownerKey}
277+
AND intent.dispatched_at IS NOT NULL)
278+
`
279+
const idleOwners = await tx
280+
.select({ ownerKey: knowledgeDocumentDispatchOwner.ownerKey })
281+
.from(knowledgeDocumentDispatchOwner)
282+
.where(idleOwner)
283+
.orderBy(
284+
asc(knowledgeDocumentDispatchOwner.enqueuedAt),
285+
asc(knowledgeDocumentDispatchOwner.ownerKey)
286+
)
287+
.limit(OWNER_PRUNE_BATCH_SIZE)
288+
.for('update', { skipLocked: true })
289+
if (idleOwners.length > 0) {
290+
/** Recheck on a fresh statement snapshot after locking, in case an enqueue just committed. */
291+
await tx.delete(knowledgeDocumentDispatchOwner).where(
292+
and(
293+
inArray(
294+
knowledgeDocumentDispatchOwner.ownerKey,
295+
idleOwners.map(({ ownerKey }) => ownerKey)
296+
),
297+
idleOwner
298+
)
299+
)
300+
}
301+
})
302+
}
303+
261304
/** Reserves both budgets under one short lock; provider calls happen after the transaction. */
262305
export async function claimDocumentProcessingDispatches(): Promise<DispatchRow[]> {
306+
await pruneIdleDispatchOwners()
263307
return db.transaction(async (tx) => {
264308
const [lock] = await tx.execute<{ acquired: boolean }>(
265309
sql`SELECT pg_try_advisory_xact_lock(hashtextextended(${DISPATCH_LOCK}, 0)) AS acquired`

0 commit comments

Comments
 (0)