Skip to content

Commit f4768b3

Browse files
committed
fix(table): stop a queued cell whose dispatch was cancelled
Cancelling a dispatch row stops the next window, not the one already queued — the dispatcher blocks on a whole window at a time. Account deletion cancels the departing account's dispatches and then deletes the user row, so those in-flight cells kept invoking tools and writing results under a subject that no longer existed. The cell now reads its owning dispatch before executing and terminalizes itself as cancelled; there is no per-cell alternative, since table_row_executions carries no dispatch column.
1 parent 8a3c289 commit f4768b3

2 files changed

Lines changed: 173 additions & 0 deletions

File tree

Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,139 @@
1+
/**
2+
* @vitest-environment node
3+
*/
4+
import { resetDbChainMock } from '@sim/testing'
5+
import { beforeEach, describe, expect, it, vi } from 'vitest'
6+
7+
const mocks = vi.hoisted(() => ({
8+
readDispatch: vi.fn(),
9+
getTableById: vi.fn(),
10+
getRowById: vi.fn(),
11+
executeWorkflow: vi.fn(),
12+
loadDeployedWorkflowState: vi.fn(),
13+
writeWorkflowGroupState: vi.fn(),
14+
markWorkflowGroupPickedUp: vi.fn(),
15+
createWorkflowCellProgressWriter: vi.fn(),
16+
pickNextEligibleGroupForRow: vi.fn(),
17+
stashCellContextForResume: vi.fn(),
18+
classifyWorkflowCellTerminalResult: vi.fn(),
19+
}))
20+
21+
vi.mock('@/lib/table/dispatcher', () => ({
22+
readDispatch: mocks.readDispatch,
23+
completeDispatchIfActive: vi.fn(),
24+
}))
25+
vi.mock('@/lib/table/service', () => ({ getTableById: mocks.getTableById }))
26+
vi.mock('@/lib/table/rows/service', () => ({
27+
getRowById: mocks.getRowById,
28+
updateRow: vi.fn(),
29+
}))
30+
vi.mock('@/lib/workflows/executor/execute-workflow', () => ({
31+
executeWorkflow: mocks.executeWorkflow,
32+
}))
33+
vi.mock('@/lib/workflows/persistence/utils', () => ({
34+
loadDeployedWorkflowState: mocks.loadDeployedWorkflowState,
35+
}))
36+
vi.mock('@/lib/table/cell-write', () => ({
37+
buildCancelledExecution: (prev: { executionId: string | null; workflowId: string }) => ({
38+
status: 'cancelled',
39+
executionId: prev.executionId,
40+
jobId: null,
41+
workflowId: prev.workflowId,
42+
error: 'Cancelled',
43+
}),
44+
createWorkflowCellProgressWriter: mocks.createWorkflowCellProgressWriter,
45+
writeWorkflowGroupState: mocks.writeWorkflowGroupState,
46+
markWorkflowGroupPickedUp: mocks.markWorkflowGroupPickedUp,
47+
}))
48+
vi.mock('@/lib/table/workflow-cell-result', () => ({
49+
classifyWorkflowCellTerminalResult: mocks.classifyWorkflowCellTerminalResult,
50+
}))
51+
vi.mock('@/lib/table/workflow-columns', () => ({
52+
pickNextEligibleGroupForRow: mocks.pickNextEligibleGroupForRow,
53+
stashCellContextForResume: mocks.stashCellContextForResume,
54+
}))
55+
vi.mock('@/lib/table/events', () => ({ appendTableEvent: vi.fn() }))
56+
57+
import { runRowCascadeLoop } from '@/background/workflow-column-execution'
58+
59+
const TABLE = {
60+
id: 'table-1',
61+
name: 'Table',
62+
workspaceId: 'workspace-1',
63+
schema: {
64+
columns: [],
65+
workflowGroups: [{ id: 'group-1', workflowId: 'workflow-1', outputs: [] }],
66+
},
67+
}
68+
69+
const PAYLOAD = {
70+
tableId: 'table-1',
71+
tableName: 'Table',
72+
rowId: 'row-1',
73+
groupId: 'group-1',
74+
workflowId: 'workflow-1',
75+
workspaceId: 'workspace-1',
76+
executionId: 'execution-1',
77+
dispatchId: 'tdsp_1',
78+
executionTimeoutMs: 10_000,
79+
billingAttribution: {
80+
actorUserId: 'user-1',
81+
workspaceId: 'workspace-1',
82+
organizationId: null,
83+
billedAccountUserId: 'user-1',
84+
billingEntity: { type: 'user' as const, id: 'user-1' },
85+
billingPeriod: { start: '2026-07-01T00:00:00.000Z', end: '2026-08-01T00:00:00.000Z' },
86+
payerSubscription: null,
87+
},
88+
} as Parameters<typeof runRowCascadeLoop>[0]
89+
90+
describe('the cell guard on its owning dispatch', () => {
91+
beforeEach(() => {
92+
vi.clearAllMocks()
93+
resetDbChainMock()
94+
mocks.getTableById.mockResolvedValue(TABLE)
95+
mocks.getRowById.mockResolvedValue({ id: 'row-1', data: {}, executions: {} })
96+
mocks.pickNextEligibleGroupForRow.mockReturnValue(null)
97+
mocks.writeWorkflowGroupState.mockResolvedValue('wrote')
98+
mocks.markWorkflowGroupPickedUp.mockResolvedValue('picked-up')
99+
mocks.loadDeployedWorkflowState.mockResolvedValue(null)
100+
})
101+
102+
/**
103+
* The dispatcher blocks on a whole window, so cancelling its row — which is
104+
* all account deletion does before the user row goes away — leaves the cells
105+
* that window already queued free to invoke tools and write results.
106+
*/
107+
it('refuses to execute a cell whose dispatch was cancelled', async () => {
108+
mocks.readDispatch.mockResolvedValue({ id: 'tdsp_1', status: 'cancelled' })
109+
110+
await runRowCascadeLoop(PAYLOAD)
111+
112+
expect(mocks.readDispatch).toHaveBeenCalledWith('tdsp_1')
113+
expect(mocks.executeWorkflow).not.toHaveBeenCalled()
114+
expect(mocks.markWorkflowGroupPickedUp).not.toHaveBeenCalled()
115+
expect(mocks.writeWorkflowGroupState).toHaveBeenCalledWith(
116+
expect.anything(),
117+
expect.objectContaining({
118+
executionState: expect.objectContaining({ status: 'cancelled' }),
119+
})
120+
)
121+
})
122+
123+
/**
124+
* `complete` is the ordinary state a dispatch reaches while its final window
125+
* is still finishing — stopping on it would kill the run's last cells.
126+
*/
127+
it('lets a cell of a still-live dispatch past the guard', async () => {
128+
mocks.readDispatch.mockResolvedValue({ id: 'tdsp_1', status: 'complete' })
129+
130+
await runRowCascadeLoop(PAYLOAD)
131+
132+
// It got as far as loading the workflow, which the db mock does not have.
133+
const statuses = mocks.writeWorkflowGroupState.mock.calls.map(
134+
([, write]) => (write as { executionState: { status: string } }).executionState.status
135+
)
136+
expect(statuses).toContain('error')
137+
expect(statuses).not.toContain('cancelled')
138+
})
139+
})

apps/sim/background/workflow-column-execution.ts

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -469,6 +469,40 @@ async function runWorkflowAndWriteTerminal(
469469
secretProvenance,
470470
})
471471

472+
/**
473+
* Dispatch-level cancellation guard.
474+
*
475+
* The dispatcher blocks on a whole window at a time, so cancelling its
476+
* `table_run_dispatches` row stops the NEXT window and nothing that is
477+
* already queued: those cells still invoke tools and write their results.
478+
* That gap is what account deletion falls into — it cancels the departing
479+
* account's dispatches and then deletes the user row, while the cells the
480+
* last window queued keep running under a subject that no longer exists.
481+
*
482+
* The row cannot be reached the other way: `table_row_executions` carries
483+
* no dispatch column, so there is nothing to cancel per cell. Reading the
484+
* owning dispatch here is the dispatch-linked stop, and it costs one
485+
* indexed primary-key read against a whole workflow run.
486+
*
487+
* `cancelled`, or a row that is gone — nothing deletes a dispatch but the
488+
* table cascade, so a missing one means the table it belonged to is gone.
489+
* `complete` deliberately does not stop the cell: it is the ordinary
490+
* terminal state a dispatch reaches while its last window is finishing.
491+
*/
492+
if (dispatchId) {
493+
const { readDispatch } = await import('@/lib/table/dispatcher')
494+
const owningDispatch = await readDispatch(dispatchId)
495+
if (!owningDispatch || owningDispatch.status === 'cancelled') {
496+
logger.info(
497+
`Skipping cell — owning dispatch is cancelled (table=${tableId} row=${rowId} group=${groupId} dispatch=${dispatchId})`
498+
)
499+
await writeState(
500+
buildCancelledExecution({ executionId, workflowId, blockErrors: undefined })
501+
)
502+
return 'cancelled'
503+
}
504+
}
505+
472506
/** Pre-execution cancellation guard: a cell cancelled while it sat in the
473507
* queue (e.g. trigger.dev concurrency backlog) must not run once it
474508
* dequeues. Reads the already-loaded row's exec — no extra query. */

0 commit comments

Comments
 (0)