Skip to content

Commit c4e6abb

Browse files
committed
fix(execution): report a workflow-group cancellation as the write it performed
Cancelling a workflow-group run whose log had already been cancelled, but whose cell sidecar still needed reconciliation, durably cancelled that sidecar and then reported `already_cancelled` with `durablyRecorded: false` — because the terminal-status shortcut answered from the entry snapshot alone and never asked what this request had written. The analytics event, which now gates on that field, stopped firing for a cancellation that really happened. The outcome a cancel reports is the same question whichever path answers it, so there is now one vocabulary for it rather than one the direct claim tracked and one the group transition did not. Every group result maps to that outcome through a total map, so a new group result cannot compile without deciding what it wrote, and the reclassification leads with whether this request wrote at all. A group transition that reports itself already cancelled is deliberately mapped as unknown rather than as a no-op: it leaves the sidecar alone but still terminalizes a log that was active, and the result does not say which happened. That costs nothing today, because the only snapshot that would reclassify proves the log was already terminal.
1 parent eb50a58 commit c4e6abb

2 files changed

Lines changed: 155 additions & 24 deletions

File tree

apps/sim/lib/execution/cancel-workflow-execution.test.ts

Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -319,6 +319,109 @@ describe('cancelWorkflowExecution', () => {
319319
expect(mockReleaseExecutionSlot).toHaveBeenCalledWith('execution-1')
320320
})
321321

322+
/**
323+
* A workflow-group log that is already `cancelled` can still own a cell
324+
* sidecar left in `error`, and reconciling it to `cancelled` is a durable
325+
* write this request performed. The terminal entry snapshot cannot see that
326+
* work, so it must not reinterpret the outcome as a no-op — the API would
327+
* otherwise tell the caller nothing changed and drop the cancellation event.
328+
*/
329+
it('reports a durable write when a cancelled group run still had its sidecar reconciled', async () => {
330+
mockResolveWorkflowExecutionOwnership.mockResolvedValue({
331+
belongsToWorkflow: true,
332+
workflowGroupWorkspaceId: 'workspace-1',
333+
priorStatus: 'cancelled',
334+
})
335+
const cancelled = {
336+
kind: 'cancelled' as const,
337+
tableId: 'table-1',
338+
rowId: 'row-1',
339+
groupId: 'group-1',
340+
}
341+
mockCancelWorkflowGroupExecution.mockResolvedValue(cancelled)
342+
343+
const result = await cancelWorkflowExecution(INPUT)
344+
345+
expect(result).toMatchObject({ success: true, durablyRecorded: true, reason: 'recorded' })
346+
expect(mockPublishWorkflowGroupCancellationEvent).toHaveBeenCalledWith(cancelled, 'execution-1')
347+
})
348+
349+
/**
350+
* The group path terminalizes the workflow log itself when the cell sidecar
351+
* is already gone, so that outcome is a durable write too.
352+
*/
353+
it('reports a durable write when the group path cancels a run whose sidecar is gone', async () => {
354+
mockResolveWorkflowExecutionOwnership.mockResolvedValue({
355+
belongsToWorkflow: true,
356+
workflowGroupWorkspaceId: 'workspace-1',
357+
priorStatus: 'running',
358+
})
359+
mockCancelWorkflowGroupExecution.mockResolvedValue({ kind: 'cancelled_without_sidecar' })
360+
361+
const result = await cancelWorkflowExecution(INPUT)
362+
363+
expect(result).toMatchObject({ success: true, durablyRecorded: true, reason: 'recorded' })
364+
expect(mockUpdateSet).not.toHaveBeenCalled()
365+
})
366+
367+
/**
368+
* The mirror case: a group run that was already terminal and whose sidecar was
369+
* already `cancelled` leaves both records untouched, so it still reports the
370+
* state it observed rather than a durable write.
371+
*/
372+
it('reports a group run that changed nothing as a no-op', async () => {
373+
mockResolveWorkflowExecutionOwnership.mockResolvedValue({
374+
belongsToWorkflow: true,
375+
workflowGroupWorkspaceId: 'workspace-1',
376+
priorStatus: 'cancelled',
377+
})
378+
mockCancelWorkflowGroupExecution.mockResolvedValue({
379+
kind: 'already_cancelled',
380+
tableId: 'table-1',
381+
rowId: 'row-1',
382+
groupId: 'group-1',
383+
})
384+
385+
const result = await cancelWorkflowExecution(INPUT)
386+
387+
expect(result).toMatchObject({
388+
success: true,
389+
durablyRecorded: false,
390+
reason: 'already_cancelled',
391+
})
392+
})
393+
394+
/**
395+
* The lost-race re-read applies to the group path as well: an entry snapshot
396+
* can still read `running` when the sidecar-less transition finds the log
397+
* already `cancelled` and writes nothing.
398+
*/
399+
it('reports a group run that lost the race to another cancel as a no-op', async () => {
400+
mockResolveWorkflowExecutionOwnership
401+
.mockResolvedValueOnce({
402+
belongsToWorkflow: true,
403+
workflowGroupWorkspaceId: 'workspace-1',
404+
priorStatus: 'running',
405+
})
406+
.mockResolvedValueOnce({
407+
belongsToWorkflow: true,
408+
workflowGroupWorkspaceId: 'workspace-1',
409+
priorStatus: 'cancelled',
410+
})
411+
mockCancelWorkflowGroupExecution.mockResolvedValue({
412+
kind: 'already_cancelled_without_sidecar',
413+
})
414+
415+
const result = await cancelWorkflowExecution(INPUT)
416+
417+
expect(result).toMatchObject({
418+
success: true,
419+
durablyRecorded: false,
420+
reason: 'already_cancelled',
421+
})
422+
expect(mockResolveWorkflowExecutionOwnership).toHaveBeenCalledTimes(2)
423+
})
424+
322425
it.each([
323426
[{ kind: 'conflict' as const, status: 'completed' }, 'cannot be cancelled while completed'],
324427
[{ kind: 'not_workflow_group' as const }, 'no longer the active table execution'],

apps/sim/lib/execution/cancel-workflow-execution.ts

Lines changed: 52 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import {
1818
cancelWorkflowGroupExecution,
1919
type PublishableWorkflowGroupCancellation,
2020
publishWorkflowGroupCancellationEvent,
21+
type WorkflowGroupExecutionCancellationResult,
2122
} from '@/lib/table/workflow-group-cancellation'
2223
import { WORKFLOW_EXECUTION_JOB_ID_PREFIX } from '@/lib/workflows/executor/execution-job-ids'
2324
import { resolveWorkflowExecutionOwnership } from '@/lib/workflows/executor/execution-queries'
@@ -79,24 +80,53 @@ function toTerminalExecutionStatus(
7980
}
8081

8182
/**
82-
* What the direct terminal log claim did: moved the run to `cancelled` here and
83-
* now, matched no row, or never ran — the workflow-group and paused paths write
84-
* their terminal row elsewhere, and a failed statement knows nothing either way.
83+
* What this request's own terminal claim did: moved the run to `cancelled` here
84+
* and now, provably matched no row, or ran on a path that cannot tell. Every
85+
* path that can terminalize the run — the direct log claim and the
86+
* workflow-group transition — answers in this one vocabulary, so the report can
87+
* ask a single question: did this request durably write?
8588
*/
86-
type DirectTerminalWriteOutcome = 'applied' | 'no_row' | 'unknown'
89+
type TerminalWriteOutcome = 'applied' | 'no_row' | 'unknown'
90+
91+
/**
92+
* What a returned workflow-group transition durably wrote. `cancelled` claims
93+
* the cell sidecar and `cancelled_without_sidecar` terminalizes the workflow log
94+
* itself, so both are writes this request performed — including the reconciling
95+
* cancel of a sidecar left in `error` behind an already-`cancelled` log.
96+
* `already_cancelled_without_sidecar` found the log already `cancelled` and
97+
* touched nothing. `already_cancelled` left the sidecar alone but may still have
98+
* terminalized an active workflow log, which the result does not distinguish, so
99+
* it cannot claim either way. `conflict` and `not_workflow_group` never reach
100+
* the report — both throw above — and are mapped only to keep this map total.
101+
*/
102+
const WORKFLOW_GROUP_TERMINAL_WRITES = {
103+
cancelled: 'applied',
104+
cancelled_without_sidecar: 'applied',
105+
already_cancelled: 'unknown',
106+
already_cancelled_without_sidecar: 'no_row',
107+
conflict: 'unknown',
108+
not_workflow_group: 'unknown',
109+
} as const satisfies Record<WorkflowGroupExecutionCancellationResult['kind'], TerminalWriteOutcome>
87110

88111
/**
89112
* Names the terminal state the cancel could not move, or `null` when it did
90113
* real work or when this path cannot tell — in which case the caller keeps the
91114
* undifferentiated report rather than guessing.
92115
*
93-
* The status read at entry is not enough on its own: a run that finishes
94-
* between that read and the claim leaves a stale non-terminal snapshot behind a
95-
* cancel that wrote nothing. The claim's own row count settles that, and a
96-
* plain post-read cannot: after a successful cancel the row reads `cancelled`
97-
* too, so the state has to be attributed to whoever wrote it. A claim that
98-
* moved no row against a non-terminal snapshot re-reads the row it lost the
99-
* race to, through the same ownership query the entry read came from.
116+
* A request that durably wrote is never a no-op, whatever the entry snapshot
117+
* said. A run can be terminal at entry and still owe this request a real write:
118+
* a workflow-group run whose log is already `cancelled` can carry a sidecar left
119+
* in `error`, and reconciling it is a durable cancellation that the entry
120+
* snapshot cannot see.
121+
*
122+
* The status read at entry is not enough on its own in the other direction
123+
* either: a run that finishes between that read and the claim leaves a stale
124+
* non-terminal snapshot behind a cancel that wrote nothing. The claim's own row
125+
* count settles that, and a plain post-read cannot: after a successful cancel
126+
* the row reads `cancelled` too, so the state has to be attributed to whoever
127+
* wrote it. A claim that moved no row against a non-terminal snapshot re-reads
128+
* the row it lost the race to, through the same ownership query the entry read
129+
* came from.
100130
*
101131
* Purely observational — it gates no effect, and a read failure falls back to
102132
* the undifferentiated report rather than failing the cancel.
@@ -105,10 +135,11 @@ async function resolveTerminalNoOpReason(
105135
executionId: string,
106136
workflowId: string,
107137
priorTerminalStatus: TerminalExecutionStatus | null,
108-
directTerminalWrite: DirectTerminalWriteOutcome
138+
terminalWrite: TerminalWriteOutcome
109139
): Promise<CancelWorkflowExecutionReason | null> {
140+
if (terminalWrite === 'applied') return null
110141
if (priorTerminalStatus !== null) return TERMINAL_NO_OP_REASONS[priorTerminalStatus]
111-
if (directTerminalWrite !== 'no_row') return null
142+
if (terminalWrite !== 'no_row') return null
112143
try {
113144
const { priorStatus } = await resolveWorkflowExecutionOwnership(executionId, workflowId)
114145
const terminalStatus = toTerminalExecutionStatus(priorStatus)
@@ -441,9 +472,10 @@ export async function cancelWorkflowExecution(
441472
* The claim's row count is read back only to report it — `returning` changes
442473
* what the statement returns, never the row it writes or the rows it matches.
443474
*/
444-
let directTerminalWrite: DirectTerminalWriteOutcome = 'unknown'
445-
if (
446-
groupCancellation === null &&
475+
let terminalWrite: TerminalWriteOutcome = 'unknown'
476+
if (groupCancellation !== null) {
477+
terminalWrite = WORKFLOW_GROUP_TERMINAL_WRITES[groupCancellation.kind]
478+
} else if (
447479
(cancellation.durablyRecorded || queuedJobCancelled || locallyAborted) &&
448480
!pausedCancelled
449481
) {
@@ -459,7 +491,7 @@ export async function cancelWorkflowExecution(
459491
)
460492
)
461493
.returning({ id: workflowExecutionLogs.id })
462-
directTerminalWrite = claimedRows.length > 0 ? 'applied' : 'no_row'
494+
terminalWrite = claimedRows.length > 0 ? 'applied' : 'no_row'
463495
} catch (dbError) {
464496
logger.warn('Failed to update execution log status directly', {
465497
executionId,
@@ -510,16 +542,12 @@ export async function cancelWorkflowExecution(
510542
* run — cancelled, or force-failed with paused state left behind — can still
511543
* carry real paused-HITL reconciliation work, and a genuine failure there owes
512544
* the caller the step that failed, not a no-op. So only an otherwise-clean
513-
* `recorded` is a candidate, whatever the prior status was.
545+
* `recorded` is a candidate, whatever the prior status was — and only when
546+
* this request wrote nothing durable on any path.
514547
*/
515548
const terminalNoOpReason =
516549
reason === 'recorded' && !pausedCancelled
517-
? await resolveTerminalNoOpReason(
518-
executionId,
519-
workflowId,
520-
priorTerminalStatus,
521-
directTerminalWrite
522-
)
550+
? await resolveTerminalNoOpReason(executionId, workflowId, priorTerminalStatus, terminalWrite)
523551
: null
524552

525553
return {

0 commit comments

Comments
 (0)