@@ -24,6 +24,7 @@ const EXECUTION_CONCURRENCY = envNumber(env.KB_CONFIG_CONCURRENCY_LIMIT, 20, { m
2424export const DOCUMENT_DISPATCH_OWNER_OUTSTANDING = EXECUTION_CONCURRENCY
2525export const DOCUMENT_DISPATCH_MAX_OUTSTANDING = EXECUTION_CONCURRENCY * 2
2626const ENQUEUE_BATCH_SIZE = 1000
27+ const OWNER_PRUNE_BATCH_SIZE = 1000
2728const RECONCILE_AFTER_MS = 15 * 60 * 1000
2829const 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. */
262305export 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