Skip to content

Commit af8025c

Browse files
fix(table): dispatcher cold-start, live run counter, smooth typewriter (#4675)
* fix(table): cut dispatcher cold-start by lazy-loading heavy import chains The trigger.dev table-run-dispatcher spent ~6s in module init before its first batchTriggerAndWait — it imports lib/table/service for getTableById, which eagerly imported lib/table/trigger → @/lib/webhooks/processor → webhook-execution + executor, dragging the entire workflow-execution stack into the dispatcher container even though it never fires a trigger. - trigger.ts lazy-imports the webhook processor + polling utils inside fireTableTrigger (the only consumer), so importing service no longer pulls the executor. - buildEnqueueItems only imports the cell job (for the inline `runner`) on the database backend; the trigger.dev backend triggers by task id and ignores runner. * fix(table): run counter + gutter Stop update instantly on Run The "X running" badge, per-row gutter Stop button, and runningByRowId map stayed at zero after clicking Run until a manual refetch. useRunColumn optimistically stamped cells pending in the rows cache but never bumped the activeDispatches counter — so when the dispatcher's real pending SSE arrived, applyCell saw the cell was already in-flight (wasInFlight === isInFlight) and skipped the counter delta. The optimistic stamp ate the transition. - onMutate now bumps runningCellCount / runningByRowId by the cells it stamps, snapshotting prior run-state for rollback on error. - onSuccess seeds the dispatch into the overlay list from the response instead of invalidating activeDispatches (a refetch would reset the optimistic counter to the server's still-zero count before the dispatcher stamps). * fix(table): drive cell typewriter with rAF so concurrent reveals stay smooth The character-by-character reveal used a per-cell setInterval. When many cells reveal at once (a Run-all completing in waves), the independent interval callbacks fire at uncoordinated times and each forces its own render + layout/paint — O(cells) reflows over an un-virtualized grid, so it degrades as more cells fill. Switch to requestAnimationFrame: all cells' callbacks run before one paint, so React batches them into a single render + paint per frame regardless of cell count. Reveal length is derived from elapsed time, so a dropped frame catches up instead of slowing the animation. * fix(table): roll back optimistic run counter when no dispatch is created useRunColumn.onSuccess returned early on a null dispatchId (no matching groups / eligible rows) without undoing the onMutate counter bump — and no SSE would arrive to correct it, leaving the counter permanently inflated. Restore the pre-mutation run-state on that path, mirroring onError. * chore(table): tighten inline comments on dispatcher cold-start fixes
1 parent 46db406 commit af8025c

4 files changed

Lines changed: 92 additions & 20 deletions

File tree

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

Lines changed: 14 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -296,6 +296,10 @@ const TYPEWRITER_MS_PER_CHAR = 15
296296
* value statically — animation fires only for subsequent updates, which in
297297
* practice means SSE-driven workflow completions arriving via
298298
* `useTableEventStream → applyCell()`.
299+
*
300+
* rAF-driven (not `setInterval`) so concurrent reveals batch into one
301+
* render/paint per frame instead of O(cells) uncoordinated reflows; reveal
302+
* length is elapsed-time based so dropped frames catch up rather than slow.
299303
*/
300304
function useTypewriter(text: string | null): string | null {
301305
const [revealed, setRevealed] = useState<string | null>(text)
@@ -317,14 +321,17 @@ function useTypewriter(text: string | null): string | null {
317321
return
318322
}
319323

324+
const full = text
325+
const start = performance.now()
326+
let raf = 0
327+
const tick = (now: number) => {
328+
const chars = Math.min(full.length, Math.floor((now - start) / TYPEWRITER_MS_PER_CHAR))
329+
setRevealed(full.slice(0, chars))
330+
if (chars < full.length) raf = requestAnimationFrame(tick)
331+
}
320332
setRevealed('')
321-
let i = 0
322-
const id = window.setInterval(() => {
323-
i++
324-
setRevealed(text.slice(0, i))
325-
if (i >= text.length) window.clearInterval(id)
326-
}, TYPEWRITER_MS_PER_CHAR)
327-
return () => window.clearInterval(id)
333+
raf = requestAnimationFrame(tick)
334+
return () => cancelAnimationFrame(raf)
328335
}, [text])
329336

330337
return revealed

apps/sim/hooks/queries/tables.ts

Lines changed: 62 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1336,10 +1336,12 @@ export function useRunColumn({ workspaceId, tableId }: RowMutationContext) {
13361336
queryClient.getQueryData<TableDefinition>(tableKeys.detail(tableId))?.schema
13371337
.workflowGroups ?? []
13381338
const groupsById = new Map(groups.map((g) => [g.id, g]))
1339+
// Tally cells stamped per row to bump the run-state counter in lockstep.
1340+
const stampedByRow: Record<string, number> = {}
13391341
const snapshots = await snapshotAndMutateRows(queryClient, tableId, (r) => {
13401342
if (targetRowIds && !targetRowIds.has(r.id)) return null
13411343
const executions = r.executions ?? {}
1342-
let changed = false
1344+
let stamped = 0
13431345
const next: RowExecutions = { ...executions }
13441346
const nextData = { ...r.data }
13451347
for (const groupId of targetGroupIds) {
@@ -1367,20 +1369,72 @@ export function useRunColumn({ workspaceId, tableId }: RowMutationContext) {
13671369
if (o.columnName in nextData) nextData[o.columnName] = null
13681370
}
13691371
}
1370-
changed = true
1372+
stamped++
13711373
}
1372-
if (!changed) return null
1374+
if (stamped === 0) return null
1375+
stampedByRow[r.id] = stamped
13731376
return { ...r, data: nextData, executions: next }
13741377
})
1375-
return { snapshots }
1378+
1379+
// Bump the counter to match the stamped cells. Without it the "X running"
1380+
// badge + gutter Stop stay at zero until a refetch: the optimistic stamp
1381+
// already marks the cell in-flight, so the dispatcher's `pending` SSE
1382+
// sees no `wasInFlight` transition and never bumps the counter.
1383+
const runStateSnapshot = queryClient.getQueryData<TableRunState>(
1384+
tableKeys.activeDispatches(tableId)
1385+
)
1386+
const totalStamped = Object.values(stampedByRow).reduce((s, n) => s + n, 0)
1387+
if (totalStamped > 0) {
1388+
queryClient.setQueryData<TableRunState>(tableKeys.activeDispatches(tableId), (prev) => {
1389+
const base = prev ?? { dispatches: [], runningCellCount: 0, runningByRowId: {} }
1390+
const nextByRow = { ...base.runningByRowId }
1391+
for (const [rid, n] of Object.entries(stampedByRow)) {
1392+
nextByRow[rid] = (nextByRow[rid] ?? 0) + n
1393+
}
1394+
return {
1395+
...base,
1396+
runningCellCount: base.runningCellCount + totalStamped,
1397+
runningByRowId: nextByRow,
1398+
}
1399+
})
1400+
}
1401+
return { snapshots, runStateSnapshot, didBumpRunState: totalStamped > 0 }
13761402
},
13771403
onError: (_err, _variables, context) => {
13781404
if (context?.snapshots) restoreCachedWorkflowCells(queryClient, context.snapshots)
1405+
// Roll back the optimistic counter bump (snapshot may be undefined).
1406+
if (context?.didBumpRunState) {
1407+
queryClient.setQueryData(tableKeys.activeDispatches(tableId), context.runStateSnapshot)
1408+
}
13791409
},
1380-
onSuccess: () => {
1381-
// Seed the active-dispatch overlay immediately (insertDispatch ran
1382-
// server-side before responding); rows cache stays owned by SSE.
1383-
void queryClient.invalidateQueries({ queryKey: tableKeys.activeDispatches(tableId) })
1410+
onSuccess: (data, { groupIds, runMode = 'all', rowIds }, context) => {
1411+
// Seed the dispatch into the overlay (drives resolveCellExec for
1412+
// ahead-of-cursor rows) from the response — refetching would reset the
1413+
// optimistic counter to the server's still-zero count.
1414+
const dispatchId = data?.data?.dispatchId
1415+
if (!dispatchId) {
1416+
// No dispatch created → no SSE to reconcile the bump; roll it back.
1417+
if (context?.didBumpRunState) {
1418+
queryClient.setQueryData(tableKeys.activeDispatches(tableId), context.runStateSnapshot)
1419+
}
1420+
return
1421+
}
1422+
queryClient.setQueryData<TableRunState>(tableKeys.activeDispatches(tableId), (prev) => {
1423+
const base = prev ?? { dispatches: [], runningCellCount: 0, runningByRowId: {} }
1424+
if (base.dispatches.some((d) => d.id === dispatchId)) return base
1425+
const dispatch: ActiveDispatch = {
1426+
id: dispatchId,
1427+
status: 'pending',
1428+
mode: runMode,
1429+
isManualRun: true,
1430+
cursor: -1,
1431+
scope: {
1432+
groupIds,
1433+
...(rowIds && rowIds.length > 0 ? { rowIds } : {}),
1434+
},
1435+
}
1436+
return { ...base, dispatches: [...base.dispatches, dispatch] }
1437+
})
13841438
},
13851439
})
13861440
}

apps/sim/lib/table/trigger.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,6 @@
99
import { createLogger } from '@sim/logger'
1010
import { generateShortId } from '@sim/utils/id'
1111
import type { RowData, TableRow, TableSchema } from '@/lib/table/types'
12-
import { fetchActiveWebhooks } from '@/lib/webhooks/polling/utils'
13-
import { processPolledWebhookEvent } from '@/lib/webhooks/processor'
1412

1513
const logger = createLogger('TableTrigger')
1614

@@ -57,6 +55,10 @@ export async function fireTableTrigger(
5755
requestId: string
5856
): Promise<void> {
5957
try {
58+
// Lazy: the webhook utils/processor pull in the executor + blocks stack.
59+
// Eager imports would force every `lib/table/service` consumer (e.g. the
60+
// dispatcher) to pay that cold-start even when no trigger fires.
61+
const { fetchActiveWebhooks } = await import('@/lib/webhooks/polling/utils')
6062
const webhooks = await fetchActiveWebhooks('table')
6163
if (webhooks.length === 0) return
6264

@@ -74,6 +76,8 @@ export async function fireTableTrigger(
7476

7577
if (matching.length === 0) return
7678

79+
const { processPolledWebhookEvent } = await import('@/lib/webhooks/processor')
80+
7781
logger.info(
7882
`[${requestId}] Firing ${matching.length} trigger(s) for ${rows.length} ${eventType} event(s) in table ${tableId}`
7983
)

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

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -203,11 +203,18 @@ export function buildPendingRuns(
203203
/** Build the per-cell `{payload, options}` items for `queue.batchEnqueue` /
204204
* `queue.batchEnqueueAndWait`. Hydrates trigger.dev tags, concurrency keys,
205205
* the inline runner, and the cancel key the inline backend uses to map a
206-
* Stop click to the in-flight cell's AbortController. */
206+
* Stop click to the in-flight cell's AbortController.
207+
*
208+
* `runner` is only used by the database backend; trigger.dev triggers by task
209+
* id. The cell-job import pulls in the executor + blocks stack, so skip it on
210+
* trigger.dev to avoid a multi-second dispatcher cold-start. */
207211
export async function buildEnqueueItems(
208212
pendingRuns: WorkflowGroupCellPayload[]
209213
): Promise<Array<{ payload: WorkflowGroupCellPayload; options: EnqueueOptions }>> {
210-
const { executeWorkflowGroupCellJob } = await import('@/background/workflow-column-execution')
214+
const runner = isTriggerDevEnabled
215+
? undefined
216+
: ((await import('@/background/workflow-column-execution'))
217+
.executeWorkflowGroupCellJob as EnqueueOptions['runner'])
211218
return pendingRuns.map((runOpts) => ({
212219
payload: runOpts,
213220
options: {
@@ -225,7 +232,7 @@ export async function buildEnqueueItems(
225232
concurrencyKey: runOpts.tableId,
226233
concurrencyLimit: TABLE_CONCURRENCY_LIMIT,
227234
tags: cellTagsFor(runOpts),
228-
runner: executeWorkflowGroupCellJob as EnqueueOptions['runner'],
235+
...(runner ? { runner } : {}),
229236
cancelKey: cellCancelKey(runOpts.tableId, runOpts.rowId, runOpts.groupId),
230237
},
231238
}))

0 commit comments

Comments
 (0)