Skip to content

Commit a77b1a8

Browse files
fix(table): preserve provenance during execution metadata updates (#7671)
1 parent 9fd3177 commit a77b1a8

2 files changed

Lines changed: 291 additions & 34 deletions

File tree

apps/sim/lib/table/rows/secret-provenance.postgres.test.ts

Lines changed: 249 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,10 @@ import {
2323
mutateTableRowsWithSecretProvenance,
2424
updateTableRowsWithDerivedSecretProvenance,
2525
} from '@/lib/table/rows/secret-provenance'
26-
import type { RowData, TableRowSecretProvenanceWrite } from '@/lib/table/types'
26+
import { getRowById, updateRow } from '@/lib/table/rows/service'
27+
import { fireTableTrigger } from '@/lib/table/trigger'
28+
import type { RowData, TableDefinition, TableRowSecretProvenanceWrite } from '@/lib/table/types'
29+
import { cancelWorkflowGroupRuns } from '@/lib/table/workflow-columns'
2730
import { executeWorkflow } from '@/lib/workflows/executor/execute-workflow'
2831
import type { ExecutionCallbacks } from '@/executor/execution/types'
2932

@@ -45,6 +48,10 @@ vi.mock('@sim/db', () => ({
4548
if (!database.current) throw new Error('PostgreSQL test database is not initialized')
4649
return Reflect.apply(database.current.select, database.current, args)
4750
},
51+
transaction: (...args: unknown[]) => {
52+
if (!database.current) throw new Error('PostgreSQL test database is not initialized')
53+
return Reflect.apply(database.current.transaction, database.current, args)
54+
},
4855
},
4956
}))
5057
vi.mock('@sim/logger', () => ({
@@ -54,6 +61,16 @@ vi.mock('@/lib/core/security/encryption', () => ({
5461
decryptSecret: vi.fn(async () => ({ decrypted: 'secret-value' })),
5562
}))
5663
vi.mock('@/lib/table/events', () => ({ appendTableEvent: vi.fn() }))
64+
vi.mock('@/lib/table/trigger', () => ({ fireTableTrigger: vi.fn() }))
65+
vi.mock('@/lib/table/service', () => ({ getTableById: vi.fn(async () => table) }))
66+
vi.mock('@/lib/core/async-jobs/config', () => ({
67+
getJobQueue: vi.fn(async () => ({ cancelByKey: vi.fn(), cancelJob: vi.fn() })),
68+
}))
69+
vi.mock('@/lib/table/dispatcher', () => ({
70+
listActiveDispatches: vi.fn(async () => []),
71+
markActiveDispatchesCancelled: vi.fn(async () => []),
72+
}))
73+
vi.mock('@/lib/core/config/env-flags', () => ({ isTriggerDevEnabled: false }))
5774
vi.mock('@/lib/logs/execution/logging-session', () => loggingSessionMock)
5875
vi.mock('@/lib/posthog/server', () => ({ captureServerEvent: vi.fn() }))
5976
vi.mock('@/lib/workflows/executor/execution-core', () => ({
@@ -74,6 +91,25 @@ if (databaseUrl && !['localhost', '127.0.0.1', '[::1]'].includes(new URL(databas
7491
const connection = databaseUrl ? postgres(databaseUrl, { max: 1 }) : undefined
7592
const updatedAt = new Date('2026-08-05T00:00:00.123Z')
7693
const secretEntry = { columnId: 'retained', encryptedValue: 'encrypted-secret', name: 'SECRET' }
94+
const scope = { userId: 'user-1', workspaceId: 'workspace-1' }
95+
const table: TableDefinition = {
96+
id: 'table-1',
97+
workspaceId: 'workspace-1',
98+
name: 'Test table',
99+
description: null,
100+
schema: {
101+
columns: ['retained', 'removed', 'derived'].map((id) => ({ id, name: id, type: 'string' })),
102+
workflowGroups: [{ id: 'group-1', workflowId: 'workflow-1', outputs: [] }],
103+
},
104+
metadata: null,
105+
rowCount: 1,
106+
maxRows: 100,
107+
createdBy: 'user-1',
108+
locks: { schemaLocked: false, insertLocked: false, updateLocked: false, deleteLocked: false },
109+
archivedAt: null,
110+
createdAt: updatedAt,
111+
updatedAt,
112+
}
77113

78114
interface Fixture {
79115
id?: string
@@ -155,7 +191,17 @@ describe.skipIf(!databaseUrl)('table provenance in PostgreSQL', () => {
155191
CREATE TEMP TABLE user_table_definitions (id text PRIMARY KEY, workspace_id text NOT NULL, rows_version integer NOT NULL);
156192
CREATE TEMP TABLE user_table_rows (
157193
id text PRIMARY KEY, table_id text NOT NULL, workspace_id text NOT NULL,
158-
data jsonb NOT NULL, updated_at timestamp NOT NULL, secret_provenance_version integer
194+
data jsonb NOT NULL, updated_at timestamp NOT NULL, secret_provenance_version integer,
195+
position integer NOT NULL DEFAULT 0, order_key text,
196+
created_at timestamp NOT NULL DEFAULT now(), created_by text
197+
);
198+
CREATE TEMP TABLE table_row_executions (
199+
table_id text NOT NULL, row_id text NOT NULL REFERENCES user_table_rows(id) ON DELETE CASCADE,
200+
group_id text NOT NULL, status text NOT NULL, execution_id text, job_id text,
201+
workflow_id text NOT NULL, error text, running_block_ids text[] NOT NULL DEFAULT '{}',
202+
block_errors jsonb NOT NULL DEFAULT '{}', cancelled_at timestamp,
203+
capability_governed_user_id text, enrichment_details jsonb,
204+
updated_at timestamp NOT NULL DEFAULT now(), PRIMARY KEY (row_id, group_id)
159205
);
160206
CREATE TEMP TABLE user_table_row_secret_provenance (
161207
row_id text PRIMARY KEY, content_updated_at timestamp NOT NULL,
@@ -180,7 +226,7 @@ describe.skipIf(!databaseUrl)('table provenance in PostgreSQL', () => {
180226
mockIsEnforced.mockReturnValue(false)
181227
if (!connection) throw new Error('PostgreSQL test database is not initialized')
182228
await connection.unsafe(
183-
'TRUNCATE user_table_rows, user_table_row_secret_provenance, user_table_definitions'
229+
'TRUNCATE table_row_executions, user_table_rows, user_table_row_secret_provenance, user_table_definitions'
184230
)
185231
await connection`INSERT INTO user_table_definitions VALUES ('table-1', 'workspace-1', 7)`
186232
})
@@ -189,6 +235,206 @@ describe.skipIf(!databaseUrl)('table provenance in PostgreSQL', () => {
189235
await connection?.end()
190236
})
191237

238+
it.each(['exact', 'unknown', 'legacy', 'stale'] as const)(
239+
'preserves %s row provenance through cancellation, restart, and a cell write',
240+
async (baseStatus) => {
241+
if (!connection) throw new Error('PostgreSQL fixture unavailable')
242+
const boundEntry = {
243+
...secretEntry,
244+
sourceUserId: scope.userId,
245+
sourceWorkspaceId: scope.workspaceId,
246+
}
247+
await insertRow({
248+
version: baseStatus === 'legacy' ? null : 1,
249+
...(baseStatus === 'legacy'
250+
? {}
251+
: { status: baseStatus === 'stale' ? 'exact' : baseStatus }),
252+
entries: baseStatus === 'unknown' ? [] : [boundEntry],
253+
stale: baseStatus === 'stale',
254+
})
255+
await connection`
256+
INSERT INTO table_row_executions (table_id, row_id, group_id, status, workflow_id)
257+
VALUES ('table-1', 'row-1', 'group-1', 'pending', 'workflow-1')
258+
`
259+
const readStoredContent = () => connection`
260+
SELECT r.data, r.updated_at::text, r.secret_provenance_version,
261+
p.content_updated_at::text, p.status, p.entries, p.updated_at::text AS provenance_updated_at
262+
FROM user_table_rows r LEFT JOIN user_table_row_secret_provenance p ON p.row_id = r.id
263+
WHERE r.id = 'row-1'
264+
`
265+
const before = await readStoredContent()
266+
expect(await cancelWorkflowGroupRuns('table-1', 'row-1')).toBe(1)
267+
expect(await readStoredContent()).toEqual(before)
268+
expect(
269+
(await getRowById('table-1', 'row-1', 'workspace-1'))?.executions['group-1']
270+
).toMatchObject({
271+
status: 'cancelled',
272+
})
273+
274+
const guard = { groupId: 'group-1', executionId: 'execution-2' }
275+
const running = {
276+
status: 'running',
277+
executionId: 'execution-2',
278+
jobId: null,
279+
workflowId: 'workflow-1',
280+
error: null,
281+
} as const
282+
const restarted = await updateRow(
283+
{
284+
tableId: 'table-1',
285+
rowId: 'row-1',
286+
workspaceId: 'workspace-1',
287+
data: {},
288+
secretProvenance: undefined,
289+
capabilityGovernedUserId: null,
290+
executionsPatch: { 'group-1': running },
291+
cancellationGuard: { ...guard, allowNewExecution: true },
292+
},
293+
table,
294+
'restart-test'
295+
)
296+
expect(restarted?.updatedAt).toEqual(updatedAt)
297+
expect(await readStoredContent()).toEqual(before)
298+
expect(fireTableTrigger).not.toHaveBeenCalled()
299+
300+
/** Usage-limit cleanup supplies no cells and must not stamp even explicit provenance. */
301+
const cleared = await updateRow(
302+
{
303+
tableId: 'table-1',
304+
rowId: 'row-1',
305+
workspaceId: 'workspace-1',
306+
data: {},
307+
secretProvenance: { complete: true, columns: {} },
308+
capabilityGovernedUserId: null,
309+
executionsPatch: { 'group-1': null },
310+
cancellationGuard: guard,
311+
},
312+
table,
313+
'cleanup-test'
314+
)
315+
expect(cleared?.executions).toEqual({})
316+
expect(await readStoredContent()).toEqual(before)
317+
expect(fireTableTrigger).not.toHaveBeenCalled()
318+
319+
const written = await updateRow(
320+
{
321+
tableId: 'table-1',
322+
rowId: 'row-1',
323+
workspaceId: 'workspace-1',
324+
data: { derived: 'public result' },
325+
secretProvenance: {
326+
complete: true,
327+
columns: { derived: { version: 1, complete: true, entries: [], scope } },
328+
},
329+
capabilityGovernedUserId: null,
330+
executionsPatch: { 'group-1': { ...running, status: 'completed' } },
331+
cancellationGuard: guard,
332+
},
333+
table,
334+
'write-test',
335+
{ computedWrite: true }
336+
)
337+
expect(written).not.toBeNull()
338+
expect(written?.updatedAt.getTime()).toBeGreaterThan(updatedAt.getTime())
339+
const [stored] = await readStoredContent()
340+
expect(stored.data).toEqual({ retained: 'value', removed: 'other', derived: 'public result' })
341+
expect(stored.content_updated_at).toBe(stored.updated_at)
342+
expect(stored.status).toBe(
343+
baseStatus === 'exact' || baseStatus === 'legacy' ? 'exact' : 'unknown'
344+
)
345+
expect(stored.entries).toEqual(baseStatus === 'exact' ? [boundEntry] : [])
346+
expect(fireTableTrigger).toHaveBeenCalledOnce()
347+
mockIsEnforced.mockReturnValue(true)
348+
const provenance = await loadTableRowSecretProvenance(
349+
[{ id: 'row-1', updatedAt: written!.updatedAt }],
350+
scope
351+
)
352+
expect(provenance.complete).toBe(baseStatus === 'exact' || baseStatus === 'legacy')
353+
expect(provenance.entries).toEqual(
354+
baseStatus === 'exact'
355+
? [{ encryptedValue: secretEntry.encryptedValue, name: secretEntry.name }]
356+
: []
357+
)
358+
}
359+
)
360+
361+
it.each(['cancelled', 'replaced'] as const)(
362+
'rolls back execution-only cleanup rejected by a %s attempt',
363+
async (attempt) => {
364+
if (!connection) throw new Error('PostgreSQL fixture unavailable')
365+
await insertRow({ status: 'exact', entries: [secretEntry] })
366+
await connection`
367+
INSERT INTO table_row_executions (table_id, row_id, group_id, status, execution_id, workflow_id)
368+
VALUES ('table-1', 'row-1', 'group-1', ${attempt === 'cancelled' ? 'cancelled' : 'running'}, ${attempt === 'cancelled' ? null : 'new-execution'}, 'workflow-1'),
369+
('table-1', 'row-1', 'other-group', 'pending', NULL, 'workflow-1')
370+
`
371+
const before = await getRowById('table-1', 'row-1', 'workspace-1')
372+
expect(
373+
await updateRow(
374+
{
375+
tableId: 'table-1',
376+
rowId: 'row-1',
377+
workspaceId: 'workspace-1',
378+
data: {},
379+
secretProvenance: undefined,
380+
capabilityGovernedUserId: null,
381+
executionsPatch: { 'other-group': null, 'group-1': null },
382+
cancellationGuard: { groupId: 'group-1', executionId: 'old-execution' },
383+
},
384+
table,
385+
'stale-cleanup-test'
386+
)
387+
).toBeNull()
388+
expect(await getRowById('table-1', 'row-1', 'workspace-1')).toEqual(before)
389+
expect(
390+
(await loadTableRowSecretProvenance([{ id: 'row-1', updatedAt }], scope)).complete
391+
).toBe(true)
392+
}
393+
)
394+
395+
it('rolls back cell data and provenance when a cancelled worker writes late', async () => {
396+
if (!connection) throw new Error('PostgreSQL fixture unavailable')
397+
await insertRow({ status: 'exact', entries: [secretEntry] })
398+
await connection`
399+
INSERT INTO table_row_executions (table_id, row_id, group_id, status, workflow_id)
400+
VALUES ('table-1', 'row-1', 'group-1', 'cancelled', 'workflow-1')
401+
`
402+
const before = await getRowById('table-1', 'row-1', 'workspace-1')
403+
expect(
404+
await updateRow(
405+
{
406+
tableId: 'table-1',
407+
rowId: 'row-1',
408+
workspaceId: 'workspace-1',
409+
data: { retained: 'late result' },
410+
secretProvenance: {
411+
complete: true,
412+
columns: { retained: { version: 1, complete: true, entries: [], scope } },
413+
},
414+
capabilityGovernedUserId: null,
415+
executionsPatch: {
416+
'group-1': {
417+
status: 'completed',
418+
executionId: 'old-execution',
419+
jobId: null,
420+
workflowId: 'workflow-1',
421+
error: null,
422+
},
423+
},
424+
cancellationGuard: { groupId: 'group-1', executionId: 'old-execution' },
425+
},
426+
table,
427+
'stale-write-test',
428+
{ computedWrite: true }
429+
)
430+
).toBeNull()
431+
expect(await getRowById('table-1', 'row-1', 'workspace-1')).toEqual(before)
432+
expect(
433+
(await loadTableRowSecretProvenance([{ id: 'row-1', updatedAt }], scope)).entries
434+
).toEqual([{ encryptedValue: secretEntry.encryptedValue }])
435+
expect(fireTableTrigger).not.toHaveBeenCalled()
436+
})
437+
192438
it.each(['exact', 'unknown', 'legacy'] as const)(
193439
'persists executor callback provenance across a retried partial write over a %s row',
194440
async (baseStatus) => {

apps/sim/lib/table/rows/service.ts

Lines changed: 42 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -1751,41 +1751,50 @@ export async function updateRow(
17511751
let persistedUpdatedAt: Date
17521752
try {
17531753
persistedUpdatedAt = await db.transaction(async (trx) => {
1754+
const mutate = async () => {
1755+
const condition = and(
1756+
eq(userTableRows.id, data.rowId),
1757+
eq(userTableRows.tableId, data.tableId),
1758+
eq(userTableRows.workspaceId, data.workspaceId)
1759+
)
1760+
const projection = { id: userTableRows.id, updatedAt: userTableRows.updatedAt }
1761+
/**
1762+
* Execution metadata has its own sidecar clock. Lock the content row for
1763+
* existence and cancellation atomicity without invalidating its provenance.
1764+
*/
1765+
const [updatedRow] =
1766+
patchedColumnIds.size > 0
1767+
? await trx
1768+
.update(userTableRows)
1769+
.set({ data: persistedData, updatedAt: now })
1770+
.where(condition)
1771+
.returning(projection)
1772+
: await trx.select(projection).from(userTableRows).where(condition).for('update')
1773+
if (!updatedRow) throw new TableRowNotFoundError()
1774+
1775+
const result = await writeExecutionsPatch(
1776+
trx,
1777+
data.tableId,
1778+
data.rowId,
1779+
effectiveExecutionsPatch,
1780+
guard
1781+
)
1782+
if (result === 'guard-rejected') {
1783+
throw new GuardRejected()
1784+
}
1785+
return {
1786+
value: updatedRow.updatedAt,
1787+
affectedRowIds: [updatedRow.id],
1788+
}
1789+
}
1790+
1791+
if (patchedColumnIds.size === 0) return (await mutate()).value
1792+
17541793
return await mutateTableRowsWithSecretProvenance(trx, {
17551794
rows: [{ rowId: data.rowId, provenance: data.secretProvenance }],
17561795
rowState: 'existing',
17571796
mode: 'merge',
1758-
mutate: async () => {
1759-
const updatedRows = await trx
1760-
.update(userTableRows)
1761-
.set({ data: persistedData, updatedAt: now })
1762-
.where(
1763-
and(
1764-
eq(userTableRows.id, data.rowId),
1765-
eq(userTableRows.tableId, data.tableId),
1766-
eq(userTableRows.workspaceId, data.workspaceId)
1767-
)
1768-
)
1769-
.returning({ id: userTableRows.id, updatedAt: userTableRows.updatedAt })
1770-
const [updatedRow] = updatedRows
1771-
if (!updatedRow) throw new TableRowNotFoundError()
1772-
1773-
const result = await writeExecutionsPatch(
1774-
trx,
1775-
data.tableId,
1776-
data.rowId,
1777-
effectiveExecutionsPatch,
1778-
guard
1779-
)
1780-
if (result === 'guard-rejected') {
1781-
// Roll back the data update too — the worker isn't authoritative.
1782-
throw new GuardRejected()
1783-
}
1784-
return {
1785-
value: updatedRow.updatedAt,
1786-
affectedRowIds: [updatedRow.id],
1787-
}
1788-
},
1797+
mutate,
17891798
})
17901799
})
17911800
} catch (err) {
@@ -1804,6 +1813,8 @@ export async function updateRow(
18041813
updatedAt: persistedUpdatedAt,
18051814
}
18061815

1816+
if (patchedColumnIds.size === 0) return updatedRow
1817+
18071818
const oldRows = new Map([[data.rowId, existingRow.data as RowData]])
18081819
void fireTableTrigger(
18091820
data.tableId,

0 commit comments

Comments
 (0)