@@ -17,6 +17,7 @@ import {
1717 sql ,
1818} from 'drizzle-orm'
1919import { getJobQueue } from '@/lib/core/async-jobs/config'
20+ import { mapWithConcurrency } from '@/lib/core/utils/concurrency'
2021import { writeWorkflowGroupState } from '@/lib/table/cell-write'
2122import { USER_TABLE_ROWS_SQL_NAME } from '@/lib/table/constants'
2223import { isExecCancelledAfter } from '@/lib/table/deps'
@@ -43,6 +44,9 @@ const logger = createLogger('TableRunDispatcher')
4344
4445const ACTIVE_DISPATCH_STATUSES = [ 'pending' , 'dispatching' ] as const
4546
47+ /** Concurrent terminal-event writes when the stale sweep reclaims a batch. */
48+ const STALE_DISPATCH_EVENT_CONCURRENCY = 10
49+
4650export type DispatchStatus = 'pending' | 'dispatching' | 'complete' | 'cancelled'
4751export type DispatchMode = 'all' | 'incomplete' | 'new'
4852
@@ -812,6 +816,44 @@ export async function completeDispatchIfActive(dispatchId: string): Promise<bool
812816 return transitioned . length > 0
813817}
814818
819+ /**
820+ * Whether any cell inside a dispatch's own scope has reported since `since`.
821+ *
822+ * Scoped to the dispatch's groups, and to its rows when it names any, because
823+ * `table_row_executions` carries no dispatch column. Left table-wide, a live
824+ * dispatch's cells read as evidence that an abandoned dispatch beside it was
825+ * still working — and auto-fired and row-scoped runs do NOT cancel overlapping
826+ * dispatches (`cancelPriorRuns` requires `isManualRun`, and the per-row path is
827+ * a no-op for dispatch cancellation), so sharing a group is ordinary rather than
828+ * exceptional.
829+ *
830+ * Two table-wide dispatches over the same groups can still vouch for each other,
831+ * since nothing in the row execution says whose work it is. Closing that needs a
832+ * `dispatch_id` on `table_row_executions`, threaded through every cell-write
833+ * site. Until then the residue is a delay, not a permanent mask: the live
834+ * dispatch's cells stop reporting when it finishes.
835+ *
836+ * Rides the partial `(table_id, status)` index, which covers exactly these three
837+ * statuses.
838+ */
839+ function hasRecentCellActivity ( since : Date ) : SQL {
840+ return sql `EXISTS (
841+ SELECT 1 FROM ${ tableRowExecutions }
842+ WHERE ${ tableRowExecutions . tableId } = ${ tableRunDispatches . tableId }
843+ AND ${ tableRowExecutions . groupId } IN (
844+ SELECT jsonb_array_elements_text(${ tableRunDispatches . scope } -> 'groupIds')
845+ )
846+ AND (
847+ jsonb_typeof(${ tableRunDispatches . scope } -> 'rowIds') <> 'array'
848+ OR ${ tableRowExecutions . rowId } IN (
849+ SELECT jsonb_array_elements_text(${ tableRunDispatches . scope } -> 'rowIds')
850+ )
851+ )
852+ AND ${ tableRowExecutions . status } IN ('queued', 'running', 'pending')
853+ AND ${ tableRowExecutions . updatedAt } >= ${ sql . param ( since , tableRowExecutions . updatedAt ) }
854+ )`
855+ }
856+
815857/**
816858 * Cancels dispatches whose holder died without reaching a terminal state.
817859 *
@@ -881,21 +923,7 @@ export async function cancelStaleDispatches(
881923 and (
882924 inArray ( tableRunDispatches . status , [ ...ACTIVE_DISPATCH_STATUSES ] ) ,
883925 sql `COALESCE(${ tableRunDispatches . heartbeatAt } , ${ tableRunDispatches . requestedAt } ) < ${ sql . param ( staleBefore , tableRunDispatches . heartbeatAt ) } ` ,
884- sql `NOT EXISTS (
885- SELECT 1 FROM ${ tableRowExecutions }
886- WHERE ${ tableRowExecutions . tableId } = ${ tableRunDispatches . tableId }
887- AND ${ tableRowExecutions . groupId } IN (
888- SELECT jsonb_array_elements_text(${ tableRunDispatches . scope } -> 'groupIds')
889- )
890- AND (
891- jsonb_typeof(${ tableRunDispatches . scope } -> 'rowIds') <> 'array'
892- OR ${ tableRowExecutions . rowId } IN (
893- SELECT jsonb_array_elements_text(${ tableRunDispatches . scope } -> 'rowIds')
894- )
895- )
896- AND ${ tableRowExecutions . status } IN ('queued', 'running', 'pending')
897- AND ${ tableRowExecutions . updatedAt } >= ${ sql . param ( staleBefore , tableRowExecutions . updatedAt ) }
898- )`
926+ sql `NOT ${ hasRecentCellActivity ( staleBefore ) } `
899927 )
900928
901929 // Claimed as explicit ids first, then updated by id, so the bound is evaluated
@@ -939,22 +967,26 @@ export async function cancelStaleDispatches(
939967 requestedAt : row . requestedAt ,
940968 } ) )
941969
942- // Same terminal event every other cancel path emits — without it the row goes
943- // terminal in the database while the client overlay stays stuck, which is the
944- // symptom this function exists to clear.
945- await Promise . all (
946- dispatches . map ( ( d ) =>
947- appendTableEvent ( {
948- kind : 'dispatch' ,
949- tableId : d . tableId ,
950- dispatchId : d . id ,
951- status : 'cancelled' ,
952- scope : d . scope ,
953- cursor : d . cursor ,
954- mode : d . mode ,
955- isManualRun : d . isManualRun ,
956- } )
957- )
970+ /**
971+ * Same terminal event every other cancel path emits — without it the row goes
972+ * terminal in the database while the client overlay stays stuck, which is the
973+ * symptom this function exists to clear.
974+ *
975+ * Bounded rather than a bare `Promise.all`: the sibling cancel paths fan out
976+ * over one table's dispatches, while this sweep can carry a whole tick's worth
977+ * across many tables, and each event is its own write.
978+ */
979+ await mapWithConcurrency ( dispatches , STALE_DISPATCH_EVENT_CONCURRENCY , ( d ) =>
980+ appendTableEvent ( {
981+ kind : 'dispatch' ,
982+ tableId : d . tableId ,
983+ dispatchId : d . id ,
984+ status : 'cancelled' ,
985+ scope : d . scope ,
986+ cursor : d . cursor ,
987+ mode : d . mode ,
988+ isManualRun : d . isManualRun ,
989+ } )
958990 )
959991
960992 return dispatches
0 commit comments