Skip to content

Commit 01bb233

Browse files
refactor(table): row executions sidecar + left-to-right dep retrigger + cancel counter refresh
Split per-row workflow-group execution state out of the user_table_rows.executions JSONB column into a new table_row_executions sidecar keyed by (row_id, group_id). Dispatcher filters, "X running" counter, bulk clears, and the cancellation guard all hit indexed columns instead of walking JSONB. Wire shape unchanged — server merges sidecar rows back into row.executions on the way out. Also: - deriveExecClearsForDataPatch now walks workflowGroups left-to-right with a propagating dirtied-column set so transitive dep chains (edit col A → group 1 re-runs → group 2 depends on group 1's output → group 2 re-runs) collapse to a single forward pass. - useCancelTableRuns.onSettled invalidates the activeDispatches query so the top-right counter and row gutter Stop button refetch from the server after any Stop (per-cell, row, or table-wide). countRunningCells is the source of truth; client no longer needs duplicate state. Three migrations on this branch (0209 + 0210 + new sidecar) collapsed into one since the feature is unreleased.
1 parent b2b1a83 commit 01bb233

20 files changed

Lines changed: 1221 additions & 18572 deletions

File tree

apps/sim/app/api/table/[tableId]/rows/route.ts

Lines changed: 46 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
import { db } from '@sim/db'
2-
import { userTableRows } from '@sim/db/schema'
2+
import { tableRowExecutions, userTableRows } from '@sim/db/schema'
33
import { createLogger } from '@sim/logger'
44
import { toError } from '@sim/utils/errors'
5-
import { and, eq, sql } from 'drizzle-orm'
5+
import { and, eq, inArray, sql } from 'drizzle-orm'
66
import { type NextRequest, NextResponse } from 'next/server'
77
import {
88
type BatchInsertTableRowsBodyInput,
@@ -17,7 +17,14 @@ import { isZodError, validationErrorResponse } from '@/lib/api/server/validation
1717
import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
1818
import { generateRequestId } from '@/lib/core/utils/request'
1919
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
20-
import type { Filter, RowData, Sort, TableSchema } from '@/lib/table'
20+
import type {
21+
Filter,
22+
RowData,
23+
RowExecutionMetadata,
24+
RowExecutions,
25+
Sort,
26+
TableSchema,
27+
} from '@/lib/table'
2128
import {
2229
batchInsertRows,
2330
batchUpdateRows,
@@ -277,7 +284,6 @@ export const GET = withRouteHandler(
277284
.select({
278285
id: userTableRows.id,
279286
data: userTableRows.data,
280-
executions: userTableRows.executions,
281287
position: userTableRows.position,
282288
createdAt: userTableRows.createdAt,
283289
updatedAt: userTableRows.updatedAt,
@@ -308,6 +314,41 @@ export const GET = withRouteHandler(
308314

309315
const rows = await query.limit(validated.limit).offset(validated.offset)
310316

317+
// Sidecar: fetch per-(row, group) execution state and group into a map
318+
// so the response preserves the legacy `row.executions[groupId]` wire
319+
// shape. One indexed-IN scan against table_row_executions.
320+
const executionsByRow = new Map<string, RowExecutions>()
321+
if (rows.length > 0) {
322+
const execRows = await db
323+
.select()
324+
.from(tableRowExecutions)
325+
.where(
326+
inArray(
327+
tableRowExecutions.rowId,
328+
rows.map((r) => r.id)
329+
)
330+
)
331+
for (const e of execRows) {
332+
const existing = executionsByRow.get(e.rowId) ?? {}
333+
const meta: RowExecutionMetadata = {
334+
status: e.status as RowExecutionMetadata['status'],
335+
executionId: e.executionId ?? null,
336+
jobId: e.jobId ?? null,
337+
workflowId: e.workflowId,
338+
error: e.error ?? null,
339+
...(e.runningBlockIds && e.runningBlockIds.length > 0
340+
? { runningBlockIds: e.runningBlockIds }
341+
: {}),
342+
...(e.blockErrors && Object.keys(e.blockErrors as Record<string, string>).length > 0
343+
? { blockErrors: e.blockErrors as Record<string, string> }
344+
: {}),
345+
...(e.cancelledAt ? { cancelledAt: e.cancelledAt.toISOString() } : {}),
346+
}
347+
existing[e.groupId] = meta
348+
executionsByRow.set(e.rowId, existing)
349+
}
350+
}
351+
311352
logger.info(
312353
`[${requestId}] Queried ${rows.length} rows from table ${tableId} (total: ${totalCount ?? 'n/a'})`
313354
)
@@ -318,7 +359,7 @@ export const GET = withRouteHandler(
318359
rows: rows.map((r) => ({
319360
id: r.id,
320361
data: r.data,
321-
executions: r.executions ?? {},
362+
executions: executionsByRow.get(r.id) ?? {},
322363
position: r.position,
323364
createdAt:
324365
r.createdAt instanceof Date ? r.createdAt.toISOString() : String(r.createdAt),

apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/cells/cell-render.tsx

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
'use client'
22

3-
import { useEffect, useRef, useState } from 'react'
43
import type React from 'react'
4+
import { useEffect, useRef, useState } from 'react'
55
import { parse } from 'tldts'
66
import { Badge, Checkbox, Tooltip } from '@/components/emcn'
77
import { cn } from '@/lib/core/utils/cn'
@@ -65,7 +65,9 @@ export function resolveCellRender({
6565
// (workflow yielded for human-in-the-loop). Render as Pending rather
6666
// than Queued so the user can tell it's not just waiting to start.
6767
const isPaused =
68-
exec?.status === 'pending' && typeof exec.jobId === 'string' && exec.jobId.startsWith('paused-')
68+
exec?.status === 'pending' &&
69+
typeof exec.jobId === 'string' &&
70+
exec.jobId.startsWith('paused-')
6971
if (isPaused) return { kind: 'pending-upstream' }
7072
if (exec?.status === 'queued' || exec?.status === 'pending') return { kind: 'queued' }
7173
return { kind: 'pending-upstream' }

apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/table-grid.tsx

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2793,9 +2793,7 @@ export function TableGrid({
27932793
// it has a real executionId and a viewable trace, same as
27942794
// running/completed/error.
27952795
const isPaused =
2796-
status === 'pending' &&
2797-
typeof exec?.jobId === 'string' &&
2798-
exec.jobId.startsWith('paused-')
2796+
status === 'pending' && typeof exec?.jobId === 'string' && exec.jobId.startsWith('paused-')
27992797
return {
28002798
rowId: row.id,
28012799
groupId,

apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/workflow-sidebar/workflow-sidebar.tsx

Lines changed: 2 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -290,9 +290,7 @@ function WorkflowSidebarBody({
290290
// Deps default to none selected. With auto-run on, at least one is required
291291
// (enforced via `depsValid` below); a legacy group with empty deps will
292292
// surface the error on first open until the user picks at least one column.
293-
const [deps, setDeps] = useState<string[]>(
294-
() => existingGroup?.dependencies?.columns ?? []
295-
)
293+
const [deps, setDeps] = useState<string[]>(() => existingGroup?.dependencies?.columns ?? [])
296294
// `selectedOutputs` is encoded `${blockId}::${path}`. Seeded once `blockOutputGroups`
297295
// resolves (we may not have the workflow blocks loaded at first render); see the
298296
// post-load reconciliation below.
@@ -901,9 +899,7 @@ function WorkflowSidebarBody({
901899
depOptions={depOptions}
902900
deps={deps}
903901
onChangeDeps={setDeps}
904-
error={
905-
showValidation && deps.length === 0 ? 'Select at least one column' : null
906-
}
902+
error={showValidation && deps.length === 0 ? 'Select at least one column' : null}
907903
/>
908904
</>
909905
)}

apps/sim/app/workspace/[workspaceId]/tables/[tableId]/hooks/use-table-event-stream.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import { useQueryClient } from '@tanstack/react-query'
66
import type { ActiveDispatch } from '@/lib/api/contracts/tables'
77
import type { RowData, RowExecutionMetadata, RowExecutions } from '@/lib/table'
88
import type { TableEvent, TableEventEntry } from '@/lib/table/events'
9-
import { snapshotAndMutateRows, tableKeys, type TableRunState } from '@/hooks/queries/tables'
9+
import { snapshotAndMutateRows, type TableRunState, tableKeys } from '@/hooks/queries/tables'
1010

1111
const logger = createLogger('useTableEventStream')
1212

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

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,12 @@ import { generateId } from '@sim/utils/id'
66
import { task } from '@trigger.dev/sdk'
77
import { eq } from 'drizzle-orm'
88
import { withCascadeLock } from '@/lib/table/cascade-lock'
9-
import type { RowData, RowExecutionMetadata, TableDefinition, WorkflowGroup } from '@/lib/table/types'
9+
import type {
10+
RowData,
11+
RowExecutionMetadata,
12+
TableDefinition,
13+
WorkflowGroup,
14+
} from '@/lib/table/types'
1015
import type { WorkflowGroupCellPayload } from '@/lib/table/workflow-columns'
1116

1217
export type { WorkflowGroupCellPayload }

apps/sim/hooks/queries/tables.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -944,6 +944,11 @@ export function useCancelTableRuns({ workspaceId, tableId }: RowMutationContext)
944944
},
945945
onSettled: () => {
946946
queryClient.invalidateQueries({ queryKey: tableKeys.rowsRoot(tableId) })
947+
// Refetch the run-state snapshot — server re-derives runningCellCount +
948+
// runningByRowId from the freshly-updated sidecar via countRunningCells.
949+
// Without this, the counter and row gutter button stay stale until the
950+
// user refetches manually.
951+
queryClient.invalidateQueries({ queryKey: tableKeys.activeDispatches(tableId) })
947952
},
948953
})
949954
}

apps/sim/lib/table/dispatcher.ts

Lines changed: 93 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,19 @@
11
import { db } from '@sim/db'
2-
import { tableRunDispatches, userTableRows } from '@sim/db/schema'
2+
import { tableRowExecutions, tableRunDispatches, userTableRows } from '@sim/db/schema'
33
import { createLogger } from '@sim/logger'
44
import { toError } from '@sim/utils/errors'
55
import { generateId } from '@sim/utils/id'
66
import { and, asc, eq, gt, inArray, type SQL, sql } from 'drizzle-orm'
77
import { getJobQueue } from '@/lib/core/async-jobs/config'
88
import { writeWorkflowGroupState } from '@/lib/table/cell-write'
99
import { appendTableEvent } from '@/lib/table/events'
10-
import type { TableRow } from '@/lib/table/types'
10+
import type { RowExecutionMetadata, RowExecutions, TableRow } from '@/lib/table/types'
1111
import {
1212
buildEnqueueItems,
1313
buildPendingRuns,
14-
type WorkflowGroupCellPayload,
1514
TABLE_CONCURRENCY_LIMIT,
1615
toTableRow,
16+
type WorkflowGroupCellPayload,
1717
} from './workflow-columns'
1818

1919
const logger = createLogger('TableRunDispatcher')
@@ -68,11 +68,10 @@ export async function bulkClearWorkflowGroupCells(input: {
6868
const outputCols = Array.from(new Set(groups.flatMap((g) => g.outputs.map((o) => o.columnName))))
6969
const groupIds = groups.map((g) => g.id)
7070

71-
// Build `data - 'col1' - 'col2' - ...` and `executions - 'gid1' - 'gid2' - ...`.
71+
// Step 1: clear the targeted output columns from `data` on every row in
72+
// scope. Identical chain to the previous JSONB-only path.
7273
let dataExpr: SQL = sql`coalesce(${userTableRows.data}, '{}'::jsonb)`
7374
for (const col of outputCols) dataExpr = sql`(${dataExpr}) - ${col}::text`
74-
let execExpr: SQL = sql`coalesce(${userTableRows.executions}, '{}'::jsonb)`
75-
for (const gid of groupIds) execExpr = sql`(${execExpr}) - ${gid}::text`
7675

7776
const filters: SQL[] = [eq(userTableRows.tableId, tableId)]
7877
if (rowIds && rowIds.length > 0) {
@@ -87,22 +86,47 @@ export async function bulkClearWorkflowGroupCells(input: {
8786
)
8887
const allFilled = filledChecks.reduce((acc, expr) => sql`${acc} AND ${expr}`)
8988
filters.push(sql`NOT (${allFilled})`)
90-
// Also skip rows where ANY targeted group has an in-flight exec from
91-
// another dispatch — clobbering its `executions[gid]` would race with
92-
// the in-flight worker. An `incomplete` run by definition shouldn't
93-
// touch rows another dispatch is actively working on.
94-
const inFlightChecks = groupIds.map(
95-
(gid) =>
96-
sql`${userTableRows.executions} -> ${gid}::text ->> 'status' IN ('queued', 'running', 'pending')`
89+
// Also skip rows where ANY targeted group has an in-flight exec — those
90+
// belong to another dispatch and clobbering them would race. Encoded as
91+
// a NOT EXISTS subquery against the sidecar's `(table_id, status)`
92+
// partial index.
93+
filters.push(
94+
sql`NOT EXISTS (
95+
SELECT 1 FROM ${tableRowExecutions} re
96+
WHERE re.row_id = ${userTableRows.id}
97+
AND re.group_id = ANY(ARRAY[${sql.join(
98+
groupIds.map((gid) => sql`${gid}`),
99+
sql`, `
100+
)}]::text[])
101+
AND re.status IN ('queued', 'running', 'pending')
102+
)`
97103
)
98-
const anyInFlight = inFlightChecks.reduce((acc, expr) => sql`${acc} OR ${expr}`)
99-
filters.push(sql`NOT (${anyInFlight})`)
100104
}
101105

102-
await db
103-
.update(userTableRows)
104-
.set({ data: dataExpr, executions: execExpr, updatedAt: new Date() })
105-
.where(and(...filters))
106+
await db.transaction(async (trx) => {
107+
await trx
108+
.update(userTableRows)
109+
.set({ data: dataExpr, updatedAt: new Date() })
110+
.where(and(...filters))
111+
112+
// Step 2: delete the targeted groups' executions for the rows in scope.
113+
// Reuse the same row-scope filter via a subquery.
114+
const execFilters: SQL[] = [
115+
eq(tableRowExecutions.tableId, tableId),
116+
inArray(tableRowExecutions.groupId, groupIds),
117+
]
118+
if (rowIds && rowIds.length > 0) {
119+
execFilters.push(inArray(tableRowExecutions.rowId, rowIds))
120+
}
121+
if (mode === 'incomplete') {
122+
// For `incomplete`, only delete entries that aren't already in-flight
123+
// — terminal states (completed/error/cancelled) get wiped so the
124+
// dispatcher re-enqueues; in-flight entries stay so we don't race
125+
// with their worker.
126+
execFilters.push(sql`${tableRowExecutions.status} NOT IN ('queued', 'running', 'pending')`)
127+
}
128+
await trx.delete(tableRowExecutions).where(and(...execFilters))
129+
})
106130
}
107131

108132
export async function insertDispatch(input: {
@@ -142,27 +166,20 @@ export async function insertDispatch(input: {
142166
export async function countRunningCells(
143167
tableId: string
144168
): Promise<{ total: number; byRowId: Record<string, number> }> {
169+
// Hits the `(table_id, status)` partial index on table_row_executions.
145170
const rows = await db
146171
.select({
147-
id: userTableRows.id,
148-
runningCount: sql<number>`(
149-
SELECT count(*)::int FROM jsonb_each(${userTableRows.executions}) e
150-
WHERE e.value->>'status' = 'running'
151-
)`,
172+
rowId: tableRowExecutions.rowId,
173+
runningCount: sql<number>`count(*)::int`,
152174
})
153-
.from(userTableRows)
154-
.where(
155-
and(
156-
eq(userTableRows.tableId, tableId),
157-
sql`${userTableRows.executions} IS NOT NULL`,
158-
sql`${userTableRows.executions} != '{}'::jsonb`
159-
)
160-
)
175+
.from(tableRowExecutions)
176+
.where(and(eq(tableRowExecutions.tableId, tableId), eq(tableRowExecutions.status, 'running')))
177+
.groupBy(tableRowExecutions.rowId)
161178
let total = 0
162179
const byRowId: Record<string, number> = {}
163180
for (const r of rows) {
164181
if (r.runningCount > 0) {
165-
byRowId[r.id] = r.runningCount
182+
byRowId[r.rowId] = r.runningCount
166183
total += r.runningCount
167184
}
168185
}
@@ -265,19 +282,22 @@ export async function dispatcherStep(dispatchId: string): Promise<DispatcherStep
265282
filters.push(inArray(userTableRows.id, dispatch.scope.rowIds))
266283
}
267284
// `'new'` mode targets only rows whose targeted groups haven't been
268-
// attempted. Exclude a row only when EVERY targeted group already has an
269-
// `executions[gid]` entry — if any one is missing, the row still has work
270-
// to do and per-group JS filtering in `classifyEligibility` handles the
271-
// rest. `jsonb_exists_all` is the function form of `?&` — safer than the
272-
// operator, which collides with prepared-statement placeholder parsing in
273-
// some drivers. Drizzle interpolates a JS array as a tuple of placeholders,
274-
// not a Postgres array — emit `ARRAY[...]` literally.
285+
// attempted. Exclude a row only when EVERY targeted group already has a
286+
// sidecar entry — if any one is missing, the row still has work to do
287+
// and per-group JS filtering in `classifyEligibility` handles the rest.
275288
if (dispatch.mode === 'new' && dispatch.scope.groupIds.length > 0) {
289+
const gids = dispatch.scope.groupIds
276290
filters.push(
277-
sql`NOT jsonb_exists_all(coalesce(${userTableRows.executions}, '{}'::jsonb), ARRAY[${sql.join(
278-
dispatch.scope.groupIds.map((gid) => sql`${gid}`),
279-
sql`, `
280-
)}]::text[])`
291+
sql`NOT EXISTS (
292+
SELECT 1 FROM ${tableRowExecutions} re
293+
WHERE re.row_id = ${userTableRows.id}
294+
AND re.group_id = ANY(ARRAY[${sql.join(
295+
gids.map((gid) => sql`${gid}`),
296+
sql`, `
297+
)}]::text[])
298+
GROUP BY re.row_id
299+
HAVING count(DISTINCT re.group_id) = ${gids.length}
300+
)`
281301
)
282302
}
283303

@@ -303,12 +323,40 @@ export async function dispatcherStep(dispatchId: string): Promise<DispatcherStep
303323
return 'done'
304324
}
305325

326+
// Pre-fetch executions for the chunk so per-row eligibility doesn't fan
327+
// out into one query per row. Returns `Map<rowId, RowExecutions>`.
328+
const chunkRowIds = chunk.map((r) => r.id)
329+
const execRows = await db
330+
.select()
331+
.from(tableRowExecutions)
332+
.where(inArray(tableRowExecutions.rowId, chunkRowIds))
333+
const executionsByRow = new Map<string, RowExecutions>()
334+
for (const r of execRows) {
335+
const existing = executionsByRow.get(r.rowId) ?? {}
336+
const meta: RowExecutionMetadata = {
337+
status: r.status as RowExecutionMetadata['status'],
338+
executionId: r.executionId ?? null,
339+
jobId: r.jobId ?? null,
340+
workflowId: r.workflowId,
341+
error: r.error ?? null,
342+
...(r.runningBlockIds && r.runningBlockIds.length > 0
343+
? { runningBlockIds: r.runningBlockIds }
344+
: {}),
345+
...(r.blockErrors && Object.keys(r.blockErrors as Record<string, string>).length > 0
346+
? { blockErrors: r.blockErrors as Record<string, string> }
347+
: {}),
348+
...(r.cancelledAt ? { cancelledAt: r.cancelledAt.toISOString() } : {}),
349+
}
350+
existing[r.groupId] = meta
351+
executionsByRow.set(r.rowId, existing)
352+
}
353+
306354
// Strip rows the user cancelled mid-cascade (post-dispatch tombstones)
307355
// before running the shared eligibility filter — `buildPendingRuns`
308356
// doesn't know about the per-dispatch cancel tombstone.
309357
const tombstoneFiltered: TableRow[] = []
310358
for (const r of chunk) {
311-
const tableRow = toTableRow(r)
359+
const tableRow = toTableRow(r, executionsByRow.get(r.id) ?? {})
312360
const tombstoned = dispatch.scope.groupIds.some((gid) => {
313361
const exec = tableRow.executions?.[gid]
314362
if (!exec?.cancelledAt) return false

0 commit comments

Comments
 (0)