Skip to content

Commit 01deeab

Browse files
refactor(table): consolidate exec-status helpers + fix N-running counter
Cleanup pass on the recent table changes — pulls duplicated predicates and SQL snippets into shared helpers and fixes one drift bug along the way. - isExecInFlight: now single export from lib/table/deps.ts. Removed the duplicate in components/table-grid/utils.ts. Used by isGroupEligible (server eligibility) and runningByRowId (client counter). - isOptimisticInFlight: kept local to hooks/queries/tables.ts — renamed from isInFlight to disambiguate from the stricter isExecInFlight. The two predicates differ on `pending` without a jobId: optimistic patches and poll-trigger want the broader version, eligibility wants the strict one. - areOutputsFilled: single export from lib/table/deps.ts, dropped duplicate from workflow-columns.ts. - classifyExecStatusMix: shared row × group walker in table-grid/utils.ts. Replaces two copies of the same loop in table-grid.tsx (selectionStats + contextMenuStats). Both surfaces now have the same short-circuit semantics, including the seen-all-selected-rows early break that contextMenuStats was missing. - stripGroupExecutions: SQL helper in service.ts. Replaces three copies of the `UPDATE user_table_rows SET executions = executions - $gid::text` pattern across deleteColumn / deleteColumns / deleteWorkflowGroup. Drift bug: - runningByRowId / totalRunning counted only `running` and `queued`. Every other in-flight check in the codebase treats post-stamp `pending` as in-flight too, so the page-header "N running" badge briefly dropped to 0 between scheduler stamp and worker pickup. Now uses isExecInFlight.
1 parent 036db43 commit 01deeab

6 files changed

Lines changed: 125 additions & 123 deletions

File tree

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

Lines changed: 17 additions & 61 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ import type { RunMode } from '@/lib/api/contracts/tables'
1010
import { cn } from '@/lib/core/utils/cn'
1111
import { captureEvent } from '@/lib/posthog/client'
1212
import type { ColumnDefinition, TableRow as TableRowType, WorkflowGroup } from '@/lib/table'
13-
import { getUnmetGroupDeps } from '@/lib/table/deps'
13+
import { getUnmetGroupDeps, isExecInFlight } from '@/lib/table/deps'
1414
import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
1515
import {
1616
useAddTableColumn,
@@ -46,8 +46,10 @@ import type { DisplayColumn } from './types'
4646
import {
4747
buildHeaderGroups,
4848
type CellCoord,
49+
classifyExecStatusMix,
4950
collectRowSnapshots,
5051
computeNormalizedSelection,
52+
type ExecStatusMix,
5153
expandToDisplayColumns,
5254
moveCell,
5355
type NormalizedSelection,
@@ -116,11 +118,7 @@ export interface SelectionSnapshot {
116118
selectedRunScope: { groupIds: string[]; rowIds: string[] } | null
117119
/** Drives Play (`hasIncompleteOrFailed`) / Refresh (`hasCompleted`) /
118120
* Stop (`hasInFlight`) visibility on the action bar. */
119-
selectionStats: {
120-
hasIncompleteOrFailed: boolean
121-
hasCompleted: boolean
122-
hasInFlight: boolean
123-
}
121+
selectionStats: ExecStatusMix
124122
/**
125123
* When the highlight resolves to exactly one workflow-group execution —
126124
* same row, every highlighted column in the same workflow group — describe
@@ -2569,8 +2567,7 @@ export function TableGrid({
25692567
let count = 0
25702568
const executions = row.executions ?? {}
25712569
for (const gid in executions) {
2572-
const status = executions[gid]?.status
2573-
if (status === 'running' || status === 'queued') count++
2570+
if (isExecInFlight(executions[gid])) count++
25742571
}
25752572
if (count > 0) {
25762573
byRow.set(row.id, count)
@@ -2671,30 +2668,12 @@ export function TableGrid({
26712668
[tableWorkflowGroups]
26722669
)
26732670

2674-
// Status mix across the (row, group) cells the context menu is acting on.
2675-
// Drives Run vs Refresh visibility in the dropdown — same shape the action
2676-
// bar uses for its selectionStats so both surfaces stay in sync.
2677-
const contextMenuStats = useMemo(() => {
2678-
let hasIncompleteOrFailed = false
2679-
let hasCompleted = false
2680-
if (contextMenuRowIds.length === 0 || tableWorkflowGroupIds.length === 0) {
2681-
return { hasIncompleteOrFailed, hasCompleted }
2682-
}
2683-
const rowIdSet = new Set(contextMenuRowIds)
2684-
for (const row of rows) {
2685-
if (!rowIdSet.has(row.id)) continue
2686-
for (const groupId of tableWorkflowGroupIds) {
2687-
const status = readExecution(row, groupId)?.status
2688-
if (status === 'queued' || status === 'running' || status === 'pending') continue
2689-
if (status === 'completed') hasCompleted = true
2690-
else hasIncompleteOrFailed = true
2691-
if (hasIncompleteOrFailed && hasCompleted) {
2692-
return { hasIncompleteOrFailed, hasCompleted }
2693-
}
2694-
}
2695-
}
2696-
return { hasIncompleteOrFailed, hasCompleted }
2697-
}, [contextMenuRowIds, rows, tableWorkflowGroupIds])
2671+
// Drives Run vs Refresh visibility on the context menu — same classifier
2672+
// the action bar uses, so both surfaces stay in sync.
2673+
const contextMenuStats = useMemo(
2674+
() => classifyExecStatusMix(rows, new Set(contextMenuRowIds), tableWorkflowGroupIds),
2675+
[contextMenuRowIds, rows, tableWorkflowGroupIds]
2676+
)
26982677

26992678
// Run scope is derived from one of two selection sources:
27002679
// - checkedRows (whole-row selection) → those rows × every workflow group
@@ -2730,36 +2709,13 @@ export function TableGrid({
27302709
}, [checkedRows, normalizedSelection, rows, displayColumns, tableWorkflowGroupIds])
27312710

27322711
const selectionStats = useMemo<SelectionSnapshot['selectionStats']>(() => {
2733-
let hasIncompleteOrFailed = false
2734-
let hasCompleted = false
2735-
let hasInFlight = false
2736-
if (!selectedRunScope) return { hasIncompleteOrFailed, hasCompleted, hasInFlight }
2737-
// Walk only the selected rows (not every row in the table). When the
2738-
// selection comes from `checkedRows` we have a Set already; for the
2739-
// rectangle path we build one over the small rowIds list.
2740-
const rowIdSet = checkedRows.size > 0 ? checkedRows : new Set(selectedRunScope.rowIds)
2741-
const target = selectedRunScope.rowIds.length
2742-
let seen = 0
2743-
const groupIds = selectedRunScope.groupIds
2744-
for (const row of rows) {
2745-
if (!rowIdSet.has(row.id)) continue
2746-
seen++
2747-
for (const groupId of groupIds) {
2748-
const status = readExecution(row, groupId)?.status
2749-
if (status === 'queued' || status === 'running' || status === 'pending') {
2750-
hasInFlight = true
2751-
} else if (status === 'completed') {
2752-
hasCompleted = true
2753-
} else {
2754-
hasIncompleteOrFailed = true
2755-
}
2756-
if (hasInFlight && hasCompleted && hasIncompleteOrFailed) {
2757-
return { hasIncompleteOrFailed, hasCompleted, hasInFlight }
2758-
}
2759-
}
2760-
if (seen === target) break
2712+
if (!selectedRunScope) {
2713+
return { hasIncompleteOrFailed: false, hasCompleted: false, hasInFlight: false }
27612714
}
2762-
return { hasIncompleteOrFailed, hasCompleted, hasInFlight }
2715+
// Reuse `checkedRows` as the rowIdSet when the selection comes from
2716+
// checkboxes — saves an O(n) Set construction for "select all 10k rows."
2717+
const rowIdSet = checkedRows.size > 0 ? checkedRows : new Set(selectedRunScope.rowIds)
2718+
return classifyExecStatusMix(rows, rowIdSet, selectedRunScope.groupIds)
27632719
}, [selectedRunScope, rows, checkedRows])
27642720

27652721
// Emit selection snapshots so the wrapper can render <TableActionBar>.

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

Lines changed: 45 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -123,15 +123,51 @@ export function readExecution(
123123
return row?.executions?.[groupId]
124124
}
125125

126-
/** True when the cell has a worker actively reserved (queued / running, or
127-
* pending after the scheduler stamped a jobId). Mirrors the eligibility
128-
* predicate's in-flight check on the server. */
129-
export function isExecInFlight(exec: RowExecutionMetadata | undefined): boolean {
130-
if (!exec) return false
131-
const s = exec.status
132-
if (s === 'queued' || s === 'running') return true
133-
if (s === 'pending' && exec.jobId) return true
134-
return false
126+
export interface ExecStatusMix {
127+
hasIncompleteOrFailed: boolean
128+
hasCompleted: boolean
129+
hasInFlight: boolean
130+
}
131+
132+
/**
133+
* Walks `(rowIdSet × groupIds)` exec statuses on `rows` and reports which
134+
* status buckets are present. Short-circuits once all three buckets are
135+
* observed and once every selected row has been visited. Drives Play /
136+
* Refresh / Stop visibility on the action bar and the context menu — both
137+
* surfaces use the same shape so they stay in sync.
138+
*/
139+
export function classifyExecStatusMix(
140+
rows: TableRowType[],
141+
rowIdSet: ReadonlySet<string>,
142+
groupIds: readonly string[]
143+
): ExecStatusMix {
144+
const result: ExecStatusMix = {
145+
hasIncompleteOrFailed: false,
146+
hasCompleted: false,
147+
hasInFlight: false,
148+
}
149+
if (rowIdSet.size === 0 || groupIds.length === 0) return result
150+
const target = rowIdSet.size
151+
let seen = 0
152+
for (const row of rows) {
153+
if (!rowIdSet.has(row.id)) continue
154+
seen++
155+
for (const groupId of groupIds) {
156+
const status = readExecution(row, groupId)?.status
157+
if (status === 'queued' || status === 'running' || status === 'pending') {
158+
result.hasInFlight = true
159+
} else if (status === 'completed') {
160+
result.hasCompleted = true
161+
} else {
162+
result.hasIncompleteOrFailed = true
163+
}
164+
if (result.hasInFlight && result.hasCompleted && result.hasIncompleteOrFailed) {
165+
return result
166+
}
167+
}
168+
if (seen === target) break
169+
}
170+
return result
135171
}
136172

137173
export function moveCell(

apps/sim/hooks/queries/tables.ts

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -78,9 +78,7 @@ function hasRunningGroupExecution(rows: TableRow[] | undefined): boolean {
7878
for (const row of rows) {
7979
const executions = row.executions ?? {}
8080
for (const key in executions) {
81-
const exec = executions[key]
82-
if (exec?.status === 'running' || exec?.status === 'queued' || exec?.status === 'pending')
83-
return true
81+
if (isOptimisticInFlight(executions[key])) return true
8482
}
8583
}
8684
return false
@@ -825,8 +823,7 @@ export function useCancelTableRuns({ workspaceId, tableId }: RowMutationContext)
825823
const nextExecutions: RowExecutions = { ...executions }
826824
for (const gid in executions) {
827825
const exec = executions[gid]
828-
if (exec.status !== 'running' && exec.status !== 'queued' && exec.status !== 'pending')
829-
continue
826+
if (!isOptimisticInFlight(exec)) continue
830827
// Preserve blockErrors so cells that already errored keep their
831828
// Error rendering after the stop — only cells without a value or
832829
// error should flip to "Cancelled".
@@ -1138,7 +1135,11 @@ function buildPendingExec(
11381135
}
11391136
}
11401137

1141-
function isInFlight(exec: RowExecutionMetadata | undefined): boolean {
1138+
/** Broader sibling of `isExecInFlight` from `lib/table/deps`: treats any
1139+
* `pending` (with or without a jobId) as in-flight. The optimistic-patch
1140+
* context uses this to avoid re-marking a cell we just flipped optimistically.
1141+
* The eligibility predicate uses the stricter version. */
1142+
function isOptimisticInFlight(exec: RowExecutionMetadata | undefined): boolean {
11421143
return exec?.status === 'running' || exec?.status === 'queued' || exec?.status === 'pending'
11431144
}
11441145

@@ -1173,7 +1174,7 @@ export function useRunColumn({ workspaceId, tableId }: RowMutationContext) {
11731174
const next: RowExecutions = { ...executions }
11741175
for (const groupId of targetGroupIds) {
11751176
const exec = executions[groupId] as RowExecutionMetadata | undefined
1176-
if (isInFlight(exec)) continue
1177+
if (isOptimisticInFlight(exec)) continue
11771178
if (runMode === 'incomplete' && exec?.status === 'completed') continue
11781179
next[groupId] = buildPendingExec(exec)
11791180
changed = true

apps/sim/lib/table/deps.ts

Lines changed: 30 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,36 @@
66

77
import type { RowData, RowExecutionMetadata, RowExecutions, TableRow, WorkflowGroup } from './types'
88

9+
/**
10+
* True when the cell has a worker actively reserved — `queued` / `running`,
11+
* or `pending` after the scheduler stamped a jobId. Single source of truth
12+
* for the "is this exec in flight" classification across the eligibility
13+
* predicate, optimistic patches, status counters, and renderer. `pending`
14+
* without a jobId is the optimistic-flag-only state, not in-flight.
15+
*/
16+
export function isExecInFlight(exec: RowExecutionMetadata | undefined): boolean {
17+
if (!exec) return false
18+
const s = exec.status
19+
if (s === 'queued' || s === 'running') return true
20+
if (s === 'pending' && exec.jobId) return true
21+
return false
22+
}
23+
24+
/**
25+
* True when every output column the group writes still has a non-empty value
26+
* on this row. The "completed" exec status is metadata, but the cells are the
27+
* source of truth — if the user cleared an output cell, the row is effectively
28+
* incomplete and should be re-run on dep-fill / manual incomplete-mode runs.
29+
*/
30+
export function areOutputsFilled(group: WorkflowGroup, row: TableRow): boolean {
31+
if (group.outputs.length === 0) return true
32+
for (const o of group.outputs) {
33+
const v = row.data[o.columnName]
34+
if (v === null || v === undefined || v === '') return false
35+
}
36+
return true
37+
}
38+
939
/**
1040
* Returns true when every column this group depends on is non-empty on this
1141
* row. Workflow output columns count the same as plain columns — the model
@@ -97,12 +127,3 @@ export function optimisticallyScheduleNewlyEligibleGroups(
97127
}
98128
return next
99129
}
100-
101-
function areOutputsFilled(group: WorkflowGroup, row: TableRow): boolean {
102-
if (group.outputs.length === 0) return true
103-
for (const o of group.outputs) {
104-
const v = row.data[o.columnName]
105-
if (v === null || v === undefined || v === '') return false
106-
}
107-
return true
108-
}

apps/sim/lib/table/service.ts

Lines changed: 21 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1598,6 +1598,24 @@ function buildExecutionsSqlPatch(
15981598
return expr
15991599
}
16001600

1601+
/**
1602+
* Strips the given workflow group ids from every row's `executions` jsonb on
1603+
* a table — used by the column / group delete paths so stale running/queued
1604+
* exec records don't linger and inflate counters after the group is gone.
1605+
* The caller wraps in their own transaction.
1606+
*/
1607+
async function stripGroupExecutions(
1608+
trx: Parameters<Parameters<typeof db.transaction>[0]>[0],
1609+
tableId: string,
1610+
groupIds: Iterable<string>
1611+
): Promise<void> {
1612+
for (const gid of groupIds) {
1613+
await trx.execute(
1614+
sql`UPDATE user_table_rows SET executions = executions - ${gid}::text WHERE table_id = ${tableId} AND executions ? ${gid}::text`
1615+
)
1616+
}
1617+
}
1618+
16011619
/**
16021620
* Updates a single row.
16031621
*
@@ -2425,15 +2443,7 @@ export async function deleteColumn(
24252443
await trx.execute(
24262444
sql`UPDATE user_table_rows SET data = data - ${actualName}::text WHERE table_id = ${data.tableId} AND data ? ${actualName}::text`
24272445
)
2428-
// If deleting this column orphaned its parent group (last output gone),
2429-
// strip the group's exec entries from every row. Otherwise stale
2430-
// running/queued exec records linger forever and inflate the
2431-
// "N running" counter.
2432-
if (groupRemovedId) {
2433-
await trx.execute(
2434-
sql`UPDATE user_table_rows SET executions = executions - ${groupRemovedId}::text WHERE table_id = ${data.tableId} AND executions ? ${groupRemovedId}::text`
2435-
)
2436-
}
2446+
if (groupRemovedId) await stripGroupExecutions(trx, data.tableId, [groupRemovedId])
24372447
})
24382448

24392449
logger.info(`[${requestId}] Deleted column "${actualName}" from table ${data.tableId}`)
@@ -2536,13 +2546,7 @@ export async function deleteColumns(
25362546
sql`UPDATE user_table_rows SET data = data - ${name}::text WHERE table_id = ${data.tableId} AND data ? ${name}::text`
25372547
)
25382548
}
2539-
// Strip exec entries for any group orphaned by these column deletes —
2540-
// see `deleteColumn` for rationale.
2541-
for (const gid of removedGroupIds) {
2542-
await trx.execute(
2543-
sql`UPDATE user_table_rows SET executions = executions - ${gid}::text WHERE table_id = ${data.tableId} AND executions ? ${gid}::text`
2544-
)
2545-
}
2549+
await stripGroupExecutions(trx, data.tableId, removedGroupIds)
25462550
})
25472551

25482552
logger.info(
@@ -3443,9 +3447,7 @@ export async function deleteWorkflowGroup(
34433447
sql`UPDATE user_table_rows SET data = data - ${name}::text WHERE table_id = ${data.tableId} AND data ? ${name}::text`
34443448
)
34453449
}
3446-
await trx.execute(
3447-
sql`UPDATE user_table_rows SET executions = executions - ${data.groupId}::text WHERE table_id = ${data.tableId} AND executions ? ${data.groupId}::text`
3448-
)
3450+
await stripGroupExecutions(trx, data.tableId, [data.groupId])
34493451
})
34503452

34513453
logger.info(`[${requestId}] Deleted workflow group "${data.groupId}" from table ${data.tableId}`)

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

Lines changed: 4 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -26,30 +26,17 @@ import type {
2626

2727
const logger = createLogger('WorkflowGroupScheduler')
2828

29-
import { areGroupDepsSatisfied } from './deps'
29+
import { areGroupDepsSatisfied, areOutputsFilled, isExecInFlight } from './deps'
3030

3131
export {
3232
areGroupDepsSatisfied,
33+
areOutputsFilled,
3334
getUnmetGroupDeps,
35+
isExecInFlight,
3436
optimisticallyScheduleNewlyEligibleGroups,
3537
type UnmetDeps,
3638
} from './deps'
3739

38-
/**
39-
* True when every output column the group writes still has a non-empty value
40-
* on this row. The "completed" exec status is metadata, but the cells are the
41-
* source of truth — if the user cleared an output cell, the row is effectively
42-
* incomplete and should be re-run on dep-fill / manual incomplete-mode runs.
43-
*/
44-
function areOutputsFilled(group: WorkflowGroup, row: TableRow): boolean {
45-
if (group.outputs.length === 0) return true
46-
for (const o of group.outputs) {
47-
const v = row.data[o.columnName]
48-
if (v === null || v === undefined || v === '') return false
49-
}
50-
return true
51-
}
52-
5340
/**
5441
* Per-(row, group) eligibility for both the auto-fire reactor and manual
5542
* runs. Manual runs bypass the `autoRun === false` skip, and additionally
@@ -70,9 +57,8 @@ export function isGroupEligible(
7057
if (group.autoRun === false && !isManualRun) return false
7158

7259
const exec = row.executions?.[group.id]
60+
if (isExecInFlight(exec)) return false
7361
const status = exec?.status
74-
if (status === 'queued' || status === 'running') return false
75-
if (status === 'pending' && exec?.jobId) return false
7662

7763
const completedAndFilled = status === 'completed' && areOutputsFilled(group, row)
7864
if (!isManualRun && completedAndFilled) return false

0 commit comments

Comments
 (0)