Skip to content

Commit 0877ecb

Browse files
fix(tables): tolerate row deletion during run cancellation (#6600)
* fix(tables): tolerate row deletion during run cancellation * fix(tables): unwrap row deletion errors
1 parent 22b3569 commit 0877ecb

2 files changed

Lines changed: 183 additions & 35 deletions

File tree

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

Lines changed: 123 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,78 @@ 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('ignores a transaction-wrapped row deletion', async () => {
353+
queueTableRows(schemaMock.tableRowExecutions, [inFlightExecution])
354+
mockUpdateRow.mockRejectedValueOnce(
355+
new Error('Failed query', { cause: new TableRowNotFoundError() })
356+
)
357+
358+
await expect(cancelWorkflowGroupRuns(table.id)).resolves.toBe(1)
359+
})
360+
361+
it('rethrows unrelated cancellation write failures', async () => {
362+
const error = new Error('database unavailable')
363+
queueTableRows(schemaMock.tableRowExecutions, [inFlightExecution])
364+
mockUpdateRow.mockRejectedValueOnce(error)
365+
366+
await expect(cancelWorkflowGroupRuns(table.id)).rejects.toBe(error)
367+
})
368+
369+
it('ignores a tombstone foreign-key failure caused by a deleted row', async () => {
370+
mockListActiveDispatches.mockResolvedValueOnce([
371+
{ id: 'dispatch-1', scope: { groupIds: [group.id], rowIds: ['row1'] } },
372+
])
373+
const cause = Object.assign(new Error('foreign key violation'), {
374+
code: '23503',
375+
constraint_name: 'table_row_executions_row_id_user_table_rows_id_fk',
376+
})
377+
dbChainMockFns.onConflictDoNothing.mockRejectedValueOnce(new Error('Failed query', { cause }))
378+
379+
await expect(cancelWorkflowGroupRuns(table.id, 'row1')).resolves.toBe(0)
380+
})
381+
382+
it('rethrows tombstone failures from any other constraint', async () => {
383+
mockListActiveDispatches.mockResolvedValueOnce([
384+
{ id: 'dispatch-1', scope: { groupIds: [group.id], rowIds: ['row1'] } },
385+
])
386+
const cause = Object.assign(new Error('foreign key violation'), {
387+
code: '23503',
388+
constraint_name: 'table_row_executions_table_id_user_table_definitions_id_fk',
389+
})
390+
const error = new Error('Failed query', { cause })
391+
dbChainMockFns.onConflictDoNothing.mockRejectedValueOnce(error)
392+
393+
await expect(cancelWorkflowGroupRuns(table.id, 'row1')).rejects.toBe(error)
394+
})
395+
})

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

Lines changed: 60 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,12 @@ 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 {
16+
findCause,
17+
getPostgresConstraintName,
18+
getPostgresErrorCode,
19+
toError,
20+
} from '@sim/utils/errors'
1621
import { generateId } from '@sim/utils/id'
1722
import { and, asc, eq, gt, inArray, notInArray, or, sql } from 'drizzle-orm'
1823
import type { BillingAttributionSnapshot } from '@/lib/billing/core/billing-attribution'
@@ -25,6 +30,7 @@ import {
2530
import { OrchestrationError } from '@/lib/core/orchestration/types'
2631
import { mapWithConcurrency } from '@/lib/core/utils/concurrency'
2732
import { buildCancelledExecution } from '@/lib/table/cell-write'
33+
import { TableRowNotFoundError } from '@/lib/table/rows/errors'
2834
import type {
2935
Filter,
3036
RowData,
@@ -43,6 +49,7 @@ const TABLE_CANCELLATION_MAX_ROWS = 5_000
4349
const TABLE_CANCELLATION_CONCURRENCY = 10
4450
const TABLE_TRIGGER_CANCELLATION_MAX_RUNS = 5_000
4551
const TABLE_TRIGGER_CANCELLATION_RETENTION_MS = 14 * 24 * 60 * 60_000
52+
const TABLE_ROW_EXECUTIONS_ROW_FK = 'table_row_executions_row_id_user_table_rows_id_fk'
4653

4754
import { getColumnId } from '@/lib/table/column-keys'
4855
import { USER_TABLE_ROWS_SQL_NAME } from '@/lib/table/constants'
@@ -715,20 +722,29 @@ export async function cancelWorkflowGroupRuns(
715722
)
716723

717724
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')
725+
try {
726+
const updated = await updateRow(
727+
{
728+
tableId,
729+
rowId: mutation.rowId,
730+
data: {},
731+
/** No cell values are written, so there is nothing to stamp. */
732+
secretProvenance: undefined,
733+
workspaceId: table.workspaceId,
734+
executionsPatch: mutation.executionsPatch,
735+
},
736+
table,
737+
`wfgrp-cancel-${mutation.rowId}`
738+
)
739+
if (!updated) throw new Error('Authoritative cancellation write was rejected')
740+
} catch (error) {
741+
const rowNotFound = findCause(
742+
error,
743+
(cause): cause is TableRowNotFoundError => cause instanceof TableRowNotFoundError
744+
)
745+
if (rowNotFound) return
746+
throw error
747+
}
732748
})
733749
cancelledCount += mutations.reduce((total, mutation) => total + mutation.cancelledCount, 0)
734750

@@ -783,25 +799,35 @@ export async function cancelWorkflowGroupRuns(
783799
needsTombstone,
784800
TABLE_CANCELLATION_CONCURRENCY,
785801
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-
})
802+
try {
803+
await db
804+
.insert(tableRowExecutions)
805+
.values({
806+
tableId,
807+
rowId,
808+
groupId: tombstone.groupId,
809+
status: 'cancelled',
810+
executionId: null,
811+
jobId: null,
812+
workflowId: tombstone.workflowId,
813+
error: 'Cancelled',
814+
runningBlockIds: [],
815+
blockErrors: {},
816+
cancelledAt: now,
817+
updatedAt: now,
818+
})
819+
.onConflictDoNothing({
820+
target: [tableRowExecutions.rowId, tableRowExecutions.groupId],
821+
})
822+
} catch (error) {
823+
if (
824+
getPostgresErrorCode(error) === '23503' &&
825+
getPostgresConstraintName(error) === TABLE_ROW_EXECUTIONS_ROW_FK
826+
) {
827+
return
828+
}
829+
throw error
830+
}
805831
}
806832
)
807833
}

0 commit comments

Comments
 (0)