@@ -40,11 +40,6 @@ import {
4040
4141const logger = createLogger ( 'TableRunDispatcher' )
4242
43- /** Window size matches the cell-execution concurrency cap so one window
44- * saturates the pool before the next is loaded — yields a row-major
45- * scan-line crawl (rows 1-20 finish before 21-40 start). */
46- const WINDOW_SIZE = TABLE_CONCURRENCY_LIMIT
47-
4843const ACTIVE_DISPATCH_STATUSES = [ 'pending' , 'dispatching' ] as const
4944
5045export type DispatchStatus = 'pending' | 'dispatching' | 'complete' | 'cancelled'
@@ -85,6 +80,9 @@ export interface DispatchRow {
8580 limit : DispatchLimit | null
8681 /** Units of `limit.type` already consumed (eligible rows dispatched). */
8782 processedCount : number
83+ /** Rows executed in parallel per window, resolved from the payer's plan at
84+ * creation. Null on pre-column rows → legacy cap of 20. */
85+ concurrency : number | null
8886 isManualRun : boolean
8987 /** User who triggered the run (for usage attribution); null for auto-fire. */
9088 triggeredByUserId : string | null
@@ -190,6 +188,8 @@ export async function insertDispatch(input: {
190188 mode : DispatchMode
191189 scope : DispatchScope
192190 limit ?: DispatchLimit | null
191+ /** Per-window parallelism from the payer's plan (see `resolveTableDispatchConcurrency`). */
192+ concurrency : number
193193 isManualRun : boolean
194194 triggeredByUserId ?: string | null
195195} ) : Promise < string > {
@@ -202,6 +202,7 @@ export async function insertDispatch(input: {
202202 mode : input . mode ,
203203 scope : input . scope ,
204204 limit : input . limit ?? null ,
205+ concurrency : input . concurrency ,
205206 status : 'pending' ,
206207 // -1 = "haven't started." First window's filter `position > -1` matches
207208 // position 0; subsequent iterations advance to `lastPosition` which then
@@ -291,6 +292,7 @@ export async function listActiveDispatches(tableId: string): Promise<DispatchRow
291292 cursor : row . cursor ,
292293 limit : ( row . limit as DispatchLimit | null ) ?? null ,
293294 processedCount : row . processedCount ,
295+ concurrency : row . concurrency ,
294296 isManualRun : row . isManualRun ,
295297 triggeredByUserId : row . triggeredByUserId ,
296298 requestedAt : row . requestedAt ,
@@ -315,6 +317,7 @@ export async function readDispatch(dispatchId: string): Promise<DispatchRow | nu
315317 cursor : row . cursor ,
316318 limit : ( row . limit as DispatchLimit | null ) ?? null ,
317319 processedCount : row . processedCount ,
320+ concurrency : row . concurrency ,
318321 isManualRun : row . isManualRun ,
319322 triggeredByUserId : row . triggeredByUserId ,
320323 requestedAt : row . requestedAt ,
@@ -380,6 +383,11 @@ export async function dispatcherStep(dispatchId: string): Promise<DispatcherStep
380383 } )
381384 }
382385
386+ // Window size = the dispatch's plan-resolved parallelism, so one window
387+ // saturates the cell pool before the next is loaded — yields a row-major
388+ // scan-line crawl. Pre-column dispatches fall back to the legacy cap.
389+ const windowSize = dispatch . concurrency ?? TABLE_CONCURRENCY_LIMIT
390+
383391 const filters = [
384392 eq ( userTableRows . tableId , dispatch . tableId ) ,
385393 gt ( userTableRows . position , dispatch . cursor ) ,
@@ -429,7 +437,7 @@ export async function dispatcherStep(dispatchId: string): Promise<DispatcherStep
429437 . from ( userTableRows )
430438 . where ( and ( ...filters ) )
431439 . orderBy ( asc ( userTableRows . position ) )
432- . limit ( WINDOW_SIZE )
440+ . limit ( windowSize )
433441 // Filtered scopes carry a jsonb predicate the planner can't estimate — left alone it
434442 // seq-scans the whole shared relation per window; keep it on the tenant's position index.
435443 const chunk = hasJsonbFilter
@@ -533,8 +541,8 @@ export async function dispatcherStep(dispatchId: string): Promise<DispatcherStep
533541 // (CRIU-checkpointed wait); database backend calls the cell-task runner
534542 // directly via Promise.all (skips async_jobs since we're awaiting in-
535543 // process anyway). Either way the parent dispatcher blocks until every
536- // cell in the window terminates — bounds queue depth at WINDOW_SIZE .
537- const items = await buildEnqueueItems ( windowRuns )
544+ // cell in the window terminates — bounds queue depth at the window size .
545+ const items = await buildEnqueueItems ( windowRuns , windowSize )
538546 const queue = await getJobQueue ( )
539547 try {
540548 await queue . batchEnqueueAndWait ( 'workflow-group-cell' , items )
@@ -792,6 +800,7 @@ export async function markActiveDispatchesCancelled(
792800 cursor : row . cursor ,
793801 limit : ( row . limit as DispatchLimit | null ) ?? null ,
794802 processedCount : row . processedCount ,
803+ concurrency : row . concurrency ,
795804 isManualRun : row . isManualRun ,
796805 triggeredByUserId : row . triggeredByUserId ,
797806 requestedAt : row . requestedAt ,
0 commit comments