Skip to content

Commit 1e24c0a

Browse files
fix(tables): tolerate row deletion during run cancellation
1 parent 933eea5 commit 1e24c0a

2 files changed

Lines changed: 165 additions & 35 deletions

File tree

apps/sim/lib/table/workflow-columns.test.ts

Lines changed: 114 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,16 @@
11
/**
22
* @vitest-environment node
33
*/
4-
import { resetEnvFlagsMock, setEnvFlags } from '@sim/testing'
4+
import {
5+
dbChainMockFns,
6+
queueTableRows,
7+
resetDbChainMock,
8+
resetEnvFlagsMock,
9+
schemaMock,
10+
setEnvFlags,
11+
} from '@sim/testing'
512
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
13+
import { TableRowNotFoundError } from '@/lib/table/rows/errors'
614
import type {
715
RowExecutionMetadata,
816
TableDefinition,
@@ -15,11 +23,25 @@ const {
1523
mockResolveSystemBillingAttribution,
1624
mockRunsCancel,
1725
mockRunsList,
26+
mockGetJobQueue,
27+
mockGetTableById,
28+
mockListActiveDispatches,
29+
mockMarkActiveDispatchesCancelled,
30+
mockQueueCancelByKey,
31+
mockQueueCancelJob,
32+
mockUpdateRow,
1833
} = vi.hoisted(() => ({
1934
mockResolveBillingAttribution: vi.fn(),
2035
mockResolveSystemBillingAttribution: vi.fn(),
2136
mockRunsCancel: vi.fn(),
2237
mockRunsList: vi.fn(),
38+
mockGetJobQueue: vi.fn(),
39+
mockGetTableById: vi.fn(),
40+
mockListActiveDispatches: vi.fn(),
41+
mockMarkActiveDispatchesCancelled: vi.fn(),
42+
mockQueueCancelByKey: vi.fn(),
43+
mockQueueCancelJob: vi.fn(),
44+
mockUpdateRow: vi.fn(),
2345
}))
2446

2547
const SYSTEM_BILLING_ATTRIBUTION = {
@@ -48,15 +70,40 @@ vi.mock('@trigger.dev/sdk', () => ({
4870
},
4971
}))
5072

73+
vi.mock('@/lib/core/async-jobs/config', () => ({
74+
getJobQueue: mockGetJobQueue,
75+
}))
76+
77+
vi.mock('@/lib/table/dispatcher', () => ({
78+
listActiveDispatches: mockListActiveDispatches,
79+
markActiveDispatchesCancelled: mockMarkActiveDispatchesCancelled,
80+
}))
81+
82+
vi.mock('@/lib/table/rows/service', () => ({
83+
updateRow: mockUpdateRow,
84+
}))
85+
86+
vi.mock('@/lib/table/service', () => ({
87+
getTableById: mockGetTableById,
88+
}))
89+
5190
import {
5291
buildEnqueueItems,
5392
cancelCellRunsByTags,
93+
cancelWorkflowGroupRuns,
5494
pickNextEligibleGroupForRow,
5595
type WorkflowGroupCellPayload,
5696
} from '@/lib/table/workflow-columns'
5797

5898
beforeEach(() => {
5999
vi.clearAllMocks()
100+
resetDbChainMock()
101+
mockGetJobQueue.mockResolvedValue({
102+
cancelByKey: mockQueueCancelByKey,
103+
cancelJob: mockQueueCancelJob,
104+
})
105+
mockListActiveDispatches.mockResolvedValue([])
106+
mockMarkActiveDispatchesCancelled.mockResolvedValue([])
60107
mockResolveBillingAttribution.mockImplementation(
61108
({ actorUserId, workspaceId }: { actorUserId: string; workspaceId: string }) =>
62109
Promise.resolve({
@@ -271,3 +318,69 @@ describe('cancelCellRunsByTags', () => {
271318
)
272319
})
273320
})
321+
322+
describe('cancelWorkflowGroupRuns deletion races', () => {
323+
const group = makeGroup({ id: 'g1' })
324+
const table = makeTable([group])
325+
const inFlightExecution = {
326+
tableId: table.id,
327+
rowId: 'row1',
328+
groupId: group.id,
329+
status: 'running',
330+
executionId: 'execution-1',
331+
jobId: null,
332+
workflowId: group.workflowId,
333+
error: null,
334+
runningBlockIds: [],
335+
blockErrors: {},
336+
cancelledAt: null,
337+
}
338+
339+
beforeEach(() => {
340+
setEnvFlags({ isTriggerDevEnabled: false, isBillingEnabled: true })
341+
mockGetTableById.mockResolvedValue(table)
342+
})
343+
344+
it('ignores a row deleted after its in-flight execution was selected', async () => {
345+
queueTableRows(schemaMock.tableRowExecutions, [inFlightExecution])
346+
mockUpdateRow.mockRejectedValueOnce(new TableRowNotFoundError())
347+
348+
await expect(cancelWorkflowGroupRuns(table.id)).resolves.toBe(1)
349+
expect(mockUpdateRow).toHaveBeenCalledOnce()
350+
})
351+
352+
it('rethrows unrelated cancellation write failures', async () => {
353+
const error = new Error('database unavailable')
354+
queueTableRows(schemaMock.tableRowExecutions, [inFlightExecution])
355+
mockUpdateRow.mockRejectedValueOnce(error)
356+
357+
await expect(cancelWorkflowGroupRuns(table.id)).rejects.toBe(error)
358+
})
359+
360+
it('ignores a tombstone foreign-key failure caused by a deleted row', async () => {
361+
mockListActiveDispatches.mockResolvedValueOnce([
362+
{ id: 'dispatch-1', scope: { groupIds: [group.id], rowIds: ['row1'] } },
363+
])
364+
const cause = Object.assign(new Error('foreign key violation'), {
365+
code: '23503',
366+
constraint_name: 'table_row_executions_row_id_user_table_rows_id_fk',
367+
})
368+
dbChainMockFns.onConflictDoNothing.mockRejectedValueOnce(new Error('Failed query', { cause }))
369+
370+
await expect(cancelWorkflowGroupRuns(table.id, 'row1')).resolves.toBe(0)
371+
})
372+
373+
it('rethrows tombstone failures from any other constraint', async () => {
374+
mockListActiveDispatches.mockResolvedValueOnce([
375+
{ id: 'dispatch-1', scope: { groupIds: [group.id], rowIds: ['row1'] } },
376+
])
377+
const cause = Object.assign(new Error('foreign key violation'), {
378+
code: '23503',
379+
constraint_name: 'table_row_executions_table_id_user_table_definitions_id_fk',
380+
})
381+
const error = new Error('Failed query', { cause })
382+
dbChainMockFns.onConflictDoNothing.mockRejectedValueOnce(error)
383+
384+
await expect(cancelWorkflowGroupRuns(table.id, 'row1')).rejects.toBe(error)
385+
})
386+
})

apps/sim/lib/table/workflow-columns.ts

Lines changed: 51 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ import {
1212
userTableRows as userTableRowsTable,
1313
} from '@sim/db/schema'
1414
import { createLogger } from '@sim/logger'
15-
import { toError } from '@sim/utils/errors'
15+
import { getPostgresConstraintName, getPostgresErrorCode, toError } from '@sim/utils/errors'
1616
import { generateId } from '@sim/utils/id'
1717
import { and, asc, eq, gt, inArray, notInArray, or, sql } from 'drizzle-orm'
1818
import type { BillingAttributionSnapshot } from '@/lib/billing/core/billing-attribution'
@@ -25,6 +25,7 @@ import {
2525
import { OrchestrationError } from '@/lib/core/orchestration/types'
2626
import { mapWithConcurrency } from '@/lib/core/utils/concurrency'
2727
import { buildCancelledExecution } from '@/lib/table/cell-write'
28+
import { TableRowNotFoundError } from '@/lib/table/rows/errors'
2829
import type {
2930
Filter,
3031
RowData,
@@ -43,6 +44,7 @@ const TABLE_CANCELLATION_MAX_ROWS = 5_000
4344
const TABLE_CANCELLATION_CONCURRENCY = 10
4445
const TABLE_TRIGGER_CANCELLATION_MAX_RUNS = 5_000
4546
const TABLE_TRIGGER_CANCELLATION_RETENTION_MS = 14 * 24 * 60 * 60_000
47+
const TABLE_ROW_EXECUTIONS_ROW_FK = 'table_row_executions_row_id_user_table_rows_id_fk'
4648

4749
import { getColumnId } from '@/lib/table/column-keys'
4850
import { USER_TABLE_ROWS_SQL_NAME } from '@/lib/table/constants'
@@ -715,20 +717,25 @@ export async function cancelWorkflowGroupRuns(
715717
)
716718

717719
await mapWithConcurrency(mutations, TABLE_CANCELLATION_CONCURRENCY, async (mutation) => {
718-
const updated = await updateRow(
719-
{
720-
tableId,
721-
rowId: mutation.rowId,
722-
data: {},
723-
/** No cell values are written, so there is nothing to stamp. */
724-
secretProvenance: undefined,
725-
workspaceId: table.workspaceId,
726-
executionsPatch: mutation.executionsPatch,
727-
},
728-
table,
729-
`wfgrp-cancel-${mutation.rowId}`
730-
)
731-
if (!updated) throw new Error('Authoritative cancellation write was rejected')
720+
try {
721+
const updated = await updateRow(
722+
{
723+
tableId,
724+
rowId: mutation.rowId,
725+
data: {},
726+
/** No cell values are written, so there is nothing to stamp. */
727+
secretProvenance: undefined,
728+
workspaceId: table.workspaceId,
729+
executionsPatch: mutation.executionsPatch,
730+
},
731+
table,
732+
`wfgrp-cancel-${mutation.rowId}`
733+
)
734+
if (!updated) throw new Error('Authoritative cancellation write was rejected')
735+
} catch (error) {
736+
if (error instanceof TableRowNotFoundError) return
737+
throw error
738+
}
732739
})
733740
cancelledCount += mutations.reduce((total, mutation) => total + mutation.cancelledCount, 0)
734741

@@ -783,25 +790,35 @@ export async function cancelWorkflowGroupRuns(
783790
needsTombstone,
784791
TABLE_CANCELLATION_CONCURRENCY,
785792
async (tombstone) => {
786-
await db
787-
.insert(tableRowExecutions)
788-
.values({
789-
tableId,
790-
rowId,
791-
groupId: tombstone.groupId,
792-
status: 'cancelled',
793-
executionId: null,
794-
jobId: null,
795-
workflowId: tombstone.workflowId,
796-
error: 'Cancelled',
797-
runningBlockIds: [],
798-
blockErrors: {},
799-
cancelledAt: now,
800-
updatedAt: now,
801-
})
802-
.onConflictDoNothing({
803-
target: [tableRowExecutions.rowId, tableRowExecutions.groupId],
804-
})
793+
try {
794+
await db
795+
.insert(tableRowExecutions)
796+
.values({
797+
tableId,
798+
rowId,
799+
groupId: tombstone.groupId,
800+
status: 'cancelled',
801+
executionId: null,
802+
jobId: null,
803+
workflowId: tombstone.workflowId,
804+
error: 'Cancelled',
805+
runningBlockIds: [],
806+
blockErrors: {},
807+
cancelledAt: now,
808+
updatedAt: now,
809+
})
810+
.onConflictDoNothing({
811+
target: [tableRowExecutions.rowId, tableRowExecutions.groupId],
812+
})
813+
} catch (error) {
814+
if (
815+
getPostgresErrorCode(error) === '23503' &&
816+
getPostgresConstraintName(error) === TABLE_ROW_EXECUTIONS_ROW_FK
817+
) {
818+
return
819+
}
820+
throw error
821+
}
805822
}
806823
)
807824
}

0 commit comments

Comments
 (0)