d25d482dc2
Publish CLI Package / publish-npm (push) Waiting to run
Publish Python SDK / publish-pypi (push) Waiting to run
Publish TypeScript SDK / publish-npm (push) Waiting to run
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled
803 lines
31 KiB
TypeScript
803 lines
31 KiB
TypeScript
import { db } from '@sim/db'
|
|
import { tableRowExecutions, tableRunDispatches, userTableRows } from '@sim/db/schema'
|
|
import { createLogger } from '@sim/logger'
|
|
import { toError } from '@sim/utils/errors'
|
|
import { generateId } from '@sim/utils/id'
|
|
import {
|
|
and,
|
|
asc,
|
|
eq,
|
|
gt,
|
|
inArray,
|
|
isNotNull,
|
|
ne,
|
|
notInArray,
|
|
or,
|
|
type SQL,
|
|
sql,
|
|
} from 'drizzle-orm'
|
|
import { getJobQueue } from '@/lib/core/async-jobs/config'
|
|
import { writeWorkflowGroupState } from '@/lib/table/cell-write'
|
|
import { USER_TABLE_ROWS_SQL_NAME } from '@/lib/table/constants'
|
|
import { isExecCancelledAfter } from '@/lib/table/deps'
|
|
import { appendTableEvent } from '@/lib/table/events'
|
|
import { type DbExecutor, withSeqscanOff } from '@/lib/table/planner'
|
|
import { buildFilterClause } from '@/lib/table/sql'
|
|
import type { Filter, RowExecutionMetadata, RowExecutions, TableRow } from '@/lib/table/types'
|
|
import {
|
|
buildEnqueueItems,
|
|
buildPendingRuns,
|
|
TABLE_CONCURRENCY_LIMIT,
|
|
toTableRow,
|
|
type WorkflowGroupCellPayload,
|
|
} from '@/lib/table/workflow-columns'
|
|
|
|
const logger = createLogger('TableRunDispatcher')
|
|
|
|
/** Window size matches the cell-execution concurrency cap so one window
|
|
* saturates the pool before the next is loaded — yields a row-major
|
|
* scan-line crawl (rows 1-20 finish before 21-40 start). */
|
|
const WINDOW_SIZE = TABLE_CONCURRENCY_LIMIT
|
|
|
|
const ACTIVE_DISPATCH_STATUSES = ['pending', 'dispatching'] as const
|
|
|
|
export type DispatchStatus = 'pending' | 'dispatching' | 'complete' | 'cancelled'
|
|
export type DispatchMode = 'all' | 'incomplete' | 'new'
|
|
|
|
export interface DispatchScope {
|
|
groupIds: string[]
|
|
rowIds?: string[]
|
|
/** "Select all matching a filter" — run every row matching this filter (mutually exclusive with
|
|
* `rowIds`). Lets the action-bar Play/Refresh target a filtered view without materializing ids. */
|
|
filter?: Filter
|
|
/** Select-all scope only: deselected rows the walk skips (mirrors the delete job's exclusion set). */
|
|
excludeRowIds?: string[]
|
|
}
|
|
|
|
/**
|
|
* Optional cap on how much work a dispatch does before it completes. The
|
|
* discriminated `type` keeps it extensible: only `'rows'` exists today, but a
|
|
* future `'cells'` / `'cost'` / `'duration'` cap can be added by extending the
|
|
* union and teaching `dispatcherStep` how to count that unit — no schema or
|
|
* plumbing change. `max` is the hard ceiling in units of `type`.
|
|
*/
|
|
export interface DispatchLimit {
|
|
type: 'rows'
|
|
max: number
|
|
}
|
|
|
|
export interface DispatchRow {
|
|
id: string
|
|
tableId: string
|
|
workspaceId: string
|
|
requestId: string
|
|
mode: DispatchMode
|
|
scope: DispatchScope
|
|
status: DispatchStatus
|
|
cursor: number
|
|
/** Cap on work before completion; null = unbounded. */
|
|
limit: DispatchLimit | null
|
|
/** Units of `limit.type` already consumed (eligible rows dispatched). */
|
|
processedCount: number
|
|
isManualRun: boolean
|
|
/** User who triggered the run (for usage attribution); null for auto-fire. */
|
|
triggeredByUserId: string | null
|
|
requestedAt: Date
|
|
}
|
|
|
|
export type DispatcherStepResult = 'continue' | 'done'
|
|
|
|
/** Eager bulk clear at click time so the user sees every targeted cell go
|
|
* blank/Pending instantly — without it, only the rows the dispatcher has
|
|
* reached visibly change, and the rest sit on stale data until the cursor
|
|
* walks to them. For `mode: 'incomplete'` we skip rows whose outputs are
|
|
* already filled, mirroring the eligibility predicate. */
|
|
export async function bulkClearWorkflowGroupCells(input: {
|
|
tableId: string
|
|
groups: Array<{ id: string; outputs: Array<{ columnName: string }> }>
|
|
rowIds?: string[]
|
|
/** Select-all scope: deselected rows whose outputs must NOT be wiped. */
|
|
excludeRowIds?: string[]
|
|
mode: DispatchMode
|
|
}): Promise<void> {
|
|
const { tableId, groups, rowIds, excludeRowIds, mode } = input
|
|
if (groups.length === 0) return
|
|
// `'new'` mode targets only rows with no prior attempt — nothing to clear.
|
|
// Pre-existing outputs on any other row must not be wiped by an auto-fire.
|
|
if (mode === 'new') return
|
|
|
|
const groupIds = groups.map((g) => g.id)
|
|
const rowScope = rowIds && rowIds.length > 0 ? rowIds : null
|
|
const excluded = !rowScope && excludeRowIds && excludeRowIds.length > 0 ? excludeRowIds : null
|
|
|
|
if (mode === 'all') {
|
|
// Run-all re-runs every targeted group: wipe all their output columns +
|
|
// executions for the rows in scope. (Prior in-flight runs were already
|
|
// cancelled by the caller.)
|
|
const outputCols = Array.from(
|
|
new Set(groups.flatMap((g) => g.outputs.map((o) => o.columnName)))
|
|
)
|
|
let dataExpr: SQL = sql`coalesce(${userTableRows.data}, '{}'::jsonb)`
|
|
for (const col of outputCols) dataExpr = sql`(${dataExpr}) - ${col}::text`
|
|
const filters: SQL[] = [eq(userTableRows.tableId, tableId)]
|
|
if (rowScope) filters.push(inArray(userTableRows.id, rowScope))
|
|
if (excluded) filters.push(notInArray(userTableRows.id, excluded))
|
|
|
|
await db.transaction(async (trx) => {
|
|
await trx
|
|
.update(userTableRows)
|
|
.set({ data: dataExpr, updatedAt: new Date() })
|
|
.where(and(...filters))
|
|
const execFilters: SQL[] = [
|
|
eq(tableRowExecutions.tableId, tableId),
|
|
inArray(tableRowExecutions.groupId, groupIds),
|
|
]
|
|
if (rowScope) execFilters.push(inArray(tableRowExecutions.rowId, rowScope))
|
|
if (excluded) execFilters.push(notInArray(tableRowExecutions.rowId, excluded))
|
|
await trx.delete(tableRowExecutions).where(and(...execFilters))
|
|
})
|
|
return
|
|
}
|
|
|
|
// `incomplete`: clear per-group, not per-row. Only groups that are
|
|
// re-runnable (`error` / `cancelled`) get their output columns + exec wiped;
|
|
// `completed` and in-flight groups are left fully intact. A row-level "all
|
|
// filled" check would otherwise wipe a completed group's data + exec just
|
|
// because a *sibling* group on the same row is incomplete, re-running the
|
|
// completed one. (`never-run` groups have no exec/output to clear — the
|
|
// dispatcher runs them via eligibility.)
|
|
await db.transaction(async (trx) => {
|
|
for (const group of groups) {
|
|
const reRunnable = sql`EXISTS (
|
|
SELECT 1 FROM ${tableRowExecutions} re
|
|
WHERE re.row_id = ${userTableRows.id}
|
|
AND re.group_id = ${group.id}
|
|
AND re.status IN ('error', 'cancelled')
|
|
)`
|
|
const filters: SQL[] = [eq(userTableRows.tableId, tableId), reRunnable]
|
|
if (rowScope) filters.push(inArray(userTableRows.id, rowScope))
|
|
if (excluded) filters.push(notInArray(userTableRows.id, excluded))
|
|
|
|
let dataExpr: SQL = sql`coalesce(${userTableRows.data}, '{}'::jsonb)`
|
|
for (const out of group.outputs) dataExpr = sql`(${dataExpr}) - ${out.columnName}::text`
|
|
await trx
|
|
.update(userTableRows)
|
|
.set({ data: dataExpr, updatedAt: new Date() })
|
|
.where(and(...filters))
|
|
|
|
const execFilters: SQL[] = [
|
|
eq(tableRowExecutions.tableId, tableId),
|
|
eq(tableRowExecutions.groupId, group.id),
|
|
sql`${tableRowExecutions.status} IN ('error', 'cancelled')`,
|
|
]
|
|
if (rowScope) execFilters.push(inArray(tableRowExecutions.rowId, rowScope))
|
|
if (excluded) execFilters.push(notInArray(tableRowExecutions.rowId, excluded))
|
|
await trx.delete(tableRowExecutions).where(and(...execFilters))
|
|
}
|
|
})
|
|
}
|
|
|
|
export async function insertDispatch(input: {
|
|
tableId: string
|
|
workspaceId: string
|
|
requestId: string
|
|
mode: DispatchMode
|
|
scope: DispatchScope
|
|
limit?: DispatchLimit | null
|
|
isManualRun: boolean
|
|
triggeredByUserId?: string | null
|
|
}): Promise<string> {
|
|
const id = `tdsp_${generateId().replace(/-/g, '')}`
|
|
await db.insert(tableRunDispatches).values({
|
|
id,
|
|
tableId: input.tableId,
|
|
workspaceId: input.workspaceId,
|
|
requestId: input.requestId,
|
|
mode: input.mode,
|
|
scope: input.scope,
|
|
limit: input.limit ?? null,
|
|
status: 'pending',
|
|
// -1 = "haven't started." First window's filter `position > -1` matches
|
|
// position 0; subsequent iterations advance to `lastPosition` which then
|
|
// correctly excludes already-processed rows.
|
|
cursor: -1,
|
|
isManualRun: input.isManualRun,
|
|
triggeredByUserId: input.triggeredByUserId ?? null,
|
|
})
|
|
return id
|
|
}
|
|
|
|
/** Counts in-flight cells (queued / running / pending) per row across the
|
|
* entire table — the authoritative source for the "X running" badge and the
|
|
* per-row gutter Run/Stop button. All three statuses are user-cancellable, so
|
|
* the gutter must surface Stop whenever any of them are present (else clicking
|
|
* Play during the queued window would re-run an already-queued cell).
|
|
*
|
|
* Excludes orphan pre-stamps — `pending` rows with no `executionId` — which
|
|
* are dead placeholders left when a dispatcher loop wrote the stamp but no
|
|
* cell-task ever picked it up (lock contention, queue failure, crash). The
|
|
* cell already shows its prior value and `classifyEligibility` treats these as
|
|
* claimable, so counting them stuck the "X running" badge above zero forever
|
|
* even though nothing was running. Same `executionId == null` test used by
|
|
* {@link classifyEligibility} / {@link pickNextEligibleGroupForRow}.
|
|
*
|
|
* Hits the `(table_id, status)` partial index on table_row_executions. */
|
|
export async function countRunningCells(
|
|
tableId: string,
|
|
opts?: { includeUnclaimedPreStamps?: boolean }
|
|
): Promise<{ byRowId: Record<string, number>; hasRunning: boolean }> {
|
|
// `pending` + null-executionId rows are unclaimed pre-stamps. With an active
|
|
// dispatch they're real queued work (include); with none they're abandoned
|
|
// orphans that would pin the badge above zero forever (exclude).
|
|
const excludeOrphanPreStamps = !opts?.includeUnclaimedPreStamps
|
|
const rows = await db
|
|
.select({
|
|
rowId: tableRowExecutions.rowId,
|
|
runningCount: sql<number>`count(*)::int`,
|
|
// Cells actually claimed by a worker — drives the header's
|
|
// "Queueing" vs "N running" label table-wide (the client can only see
|
|
// claims on loaded rows; a long run's active window scrolls past them).
|
|
claimedCount: sql<number>`count(*) FILTER (WHERE ${tableRowExecutions.status} = 'running')::int`,
|
|
})
|
|
.from(tableRowExecutions)
|
|
.where(
|
|
and(
|
|
eq(tableRowExecutions.tableId, tableId),
|
|
inArray(tableRowExecutions.status, ['queued', 'running', 'pending']),
|
|
excludeOrphanPreStamps
|
|
? or(ne(tableRowExecutions.status, 'pending'), isNotNull(tableRowExecutions.executionId))
|
|
: undefined
|
|
)
|
|
)
|
|
.groupBy(tableRowExecutions.rowId)
|
|
const byRowId: Record<string, number> = {}
|
|
let hasRunning = false
|
|
for (const r of rows) {
|
|
if (r.runningCount > 0) byRowId[r.rowId] = r.runningCount
|
|
if (r.claimedCount > 0) hasRunning = true
|
|
}
|
|
return { byRowId, hasRunning }
|
|
}
|
|
|
|
/** Read every dispatch on a table whose status is still `pending` or
|
|
* `dispatching`. Drives the client-side "about to run" overlay: rows in an
|
|
* active dispatch's scope ahead of its cursor are rendered as queued even
|
|
* before the dispatcher has reached them, so refresh during a long Run-all
|
|
* doesn't lose the queued indicators. */
|
|
export async function listActiveDispatches(tableId: string): Promise<DispatchRow[]> {
|
|
const rows = await db
|
|
.select()
|
|
.from(tableRunDispatches)
|
|
.where(
|
|
and(
|
|
eq(tableRunDispatches.tableId, tableId),
|
|
inArray(tableRunDispatches.status, [...ACTIVE_DISPATCH_STATUSES])
|
|
)
|
|
)
|
|
return rows.map((row) => ({
|
|
id: row.id,
|
|
tableId: row.tableId,
|
|
workspaceId: row.workspaceId,
|
|
requestId: row.requestId,
|
|
mode: row.mode as DispatchMode,
|
|
scope: row.scope as DispatchScope,
|
|
status: row.status as DispatchStatus,
|
|
cursor: row.cursor,
|
|
limit: (row.limit as DispatchLimit | null) ?? null,
|
|
processedCount: row.processedCount,
|
|
isManualRun: row.isManualRun,
|
|
triggeredByUserId: row.triggeredByUserId,
|
|
requestedAt: row.requestedAt,
|
|
}))
|
|
}
|
|
|
|
export async function readDispatch(dispatchId: string): Promise<DispatchRow | null> {
|
|
const [row] = await db
|
|
.select()
|
|
.from(tableRunDispatches)
|
|
.where(eq(tableRunDispatches.id, dispatchId))
|
|
.limit(1)
|
|
if (!row) return null
|
|
return {
|
|
id: row.id,
|
|
tableId: row.tableId,
|
|
workspaceId: row.workspaceId,
|
|
requestId: row.requestId,
|
|
mode: row.mode as DispatchMode,
|
|
scope: row.scope as DispatchScope,
|
|
status: row.status as DispatchStatus,
|
|
cursor: row.cursor,
|
|
limit: (row.limit as DispatchLimit | null) ?? null,
|
|
processedCount: row.processedCount,
|
|
isManualRun: row.isManualRun,
|
|
triggeredByUserId: row.triggeredByUserId,
|
|
requestedAt: row.requestedAt,
|
|
}
|
|
}
|
|
|
|
/** Drive `dispatcherStep` to completion. Shared between the trigger.dev task
|
|
* wrapper (`tableRunDispatcherTask`) and the in-process inline path so both
|
|
* runtimes use identical loop semantics + error logging. */
|
|
export async function runDispatcherToCompletion(dispatchId: string): Promise<void> {
|
|
while ((await dispatcherStep(dispatchId)) === 'continue') {}
|
|
}
|
|
|
|
/** Run one window of the dispatcher state machine. Caller re-invokes (via the
|
|
* trigger.dev task wrapper) until the returned status is `'done'`. */
|
|
export async function dispatcherStep(dispatchId: string): Promise<DispatcherStepResult> {
|
|
const dispatch = await readDispatch(dispatchId)
|
|
if (!dispatch) {
|
|
logger.warn(`[${dispatchId}] dispatch row missing — aborting`)
|
|
return 'done'
|
|
}
|
|
if (dispatch.status === 'cancelled' || dispatch.status === 'complete') return 'done'
|
|
|
|
const { getTableById } = await import('@/lib/table/service')
|
|
const table = await getTableById(dispatch.tableId)
|
|
if (!table) {
|
|
logger.warn(`[${dispatchId}] table ${dispatch.tableId} missing — completing dispatch`)
|
|
await markDispatchComplete(dispatchId)
|
|
return 'done'
|
|
}
|
|
|
|
const allGroups = table.schema.workflowGroups ?? []
|
|
const targetGroups = allGroups.filter((g) => dispatch.scope.groupIds.includes(g.id))
|
|
if (targetGroups.length === 0) {
|
|
await markDispatchComplete(dispatchId)
|
|
return 'done'
|
|
}
|
|
|
|
// First iteration: just transition pending → dispatching. The bulk clear
|
|
// ran synchronously in `runWorkflowColumn` before this task fired, so the
|
|
// user already saw the column flip to empty/Pending before any cell
|
|
// started enqueueing.
|
|
if (dispatch.status === 'pending') {
|
|
await db
|
|
.update(tableRunDispatches)
|
|
.set({ status: 'dispatching' })
|
|
.where(eq(tableRunDispatches.id, dispatchId))
|
|
// Announce the dispatch the moment it starts — before the first window's
|
|
// cells finish. Without this, auto-fired and capped dispatches (no client-
|
|
// side optimistic seed) emit their first `dispatch` event only after window
|
|
// 1 completes, so the "X running" / Stop-all control stays hidden while a
|
|
// long first window runs. The client refetches the run-state count on this.
|
|
await appendTableEvent({
|
|
kind: 'dispatch',
|
|
tableId: dispatch.tableId,
|
|
dispatchId,
|
|
status: 'dispatching',
|
|
scope: dispatch.scope,
|
|
cursor: dispatch.cursor,
|
|
mode: dispatch.mode,
|
|
isManualRun: dispatch.isManualRun,
|
|
...(dispatch.limit ? { limit: dispatch.limit } : {}),
|
|
})
|
|
}
|
|
|
|
const filters = [
|
|
eq(userTableRows.tableId, dispatch.tableId),
|
|
gt(userTableRows.position, dispatch.cursor),
|
|
]
|
|
let hasJsonbFilter = false
|
|
if (dispatch.scope.rowIds && dispatch.scope.rowIds.length > 0) {
|
|
filters.push(inArray(userTableRows.id, dispatch.scope.rowIds))
|
|
} else if (dispatch.scope.filter) {
|
|
// "Select all under a filter": walk only the matching rows. Same cursor/window mechanism —
|
|
// non-matching rows are simply never selected, like mode eligibility.
|
|
const filterClause = buildFilterClause(
|
|
dispatch.scope.filter,
|
|
USER_TABLE_ROWS_SQL_NAME,
|
|
table.schema.columns
|
|
)
|
|
if (filterClause) {
|
|
filters.push(filterClause)
|
|
hasJsonbFilter = true
|
|
}
|
|
}
|
|
if (!dispatch.scope.rowIds?.length && dispatch.scope.excludeRowIds?.length) {
|
|
filters.push(notInArray(userTableRows.id, dispatch.scope.excludeRowIds))
|
|
}
|
|
// `'new'` mode targets only rows whose targeted groups haven't been
|
|
// attempted. Exclude a row only when EVERY targeted group already has a
|
|
// sidecar entry — if any one is missing, the row still has work to do
|
|
// and per-group JS filtering in `classifyEligibility` handles the rest.
|
|
if (dispatch.mode === 'new' && dispatch.scope.groupIds.length > 0) {
|
|
const gids = dispatch.scope.groupIds
|
|
filters.push(
|
|
sql`NOT EXISTS (
|
|
SELECT 1 FROM ${tableRowExecutions} re
|
|
WHERE re.row_id = ${userTableRows.id}
|
|
AND re.group_id = ANY(ARRAY[${sql.join(
|
|
gids.map((gid) => sql`${gid}`),
|
|
sql`, `
|
|
)}]::text[])
|
|
GROUP BY re.row_id
|
|
HAVING count(DISTINCT re.group_id) = ${gids.length}
|
|
)`
|
|
)
|
|
}
|
|
|
|
const windowQuery = (executor: DbExecutor) =>
|
|
executor
|
|
.select()
|
|
.from(userTableRows)
|
|
.where(and(...filters))
|
|
.orderBy(asc(userTableRows.position))
|
|
.limit(WINDOW_SIZE)
|
|
// Filtered scopes carry a jsonb predicate the planner can't estimate — left alone it
|
|
// seq-scans the whole shared relation per window; keep it on the tenant's position index.
|
|
const chunk = hasJsonbFilter
|
|
? await withSeqscanOff(async (trx) => windowQuery(trx))
|
|
: await windowQuery(db)
|
|
|
|
if (chunk.length === 0) {
|
|
await markDispatchComplete(dispatchId)
|
|
await appendTableEvent({
|
|
kind: 'dispatch',
|
|
tableId: dispatch.tableId,
|
|
dispatchId,
|
|
status: 'complete',
|
|
scope: dispatch.scope,
|
|
cursor: dispatch.cursor,
|
|
mode: dispatch.mode,
|
|
isManualRun: dispatch.isManualRun,
|
|
})
|
|
return 'done'
|
|
}
|
|
|
|
// Pre-fetch executions for the chunk so per-row eligibility doesn't fan
|
|
// out into one query per row. Returns `Map<rowId, RowExecutions>`.
|
|
const chunkRowIds = chunk.map((r) => r.id)
|
|
const execRows = await db
|
|
.select()
|
|
.from(tableRowExecutions)
|
|
.where(inArray(tableRowExecutions.rowId, chunkRowIds))
|
|
const executionsByRow = new Map<string, RowExecutions>()
|
|
for (const r of execRows) {
|
|
const existing = executionsByRow.get(r.rowId) ?? {}
|
|
const meta: RowExecutionMetadata = {
|
|
status: r.status as RowExecutionMetadata['status'],
|
|
executionId: r.executionId ?? null,
|
|
jobId: r.jobId ?? null,
|
|
workflowId: r.workflowId,
|
|
error: r.error ?? null,
|
|
...(r.runningBlockIds && r.runningBlockIds.length > 0
|
|
? { runningBlockIds: r.runningBlockIds }
|
|
: {}),
|
|
...(r.blockErrors && Object.keys(r.blockErrors as Record<string, string>).length > 0
|
|
? { blockErrors: r.blockErrors as Record<string, string> }
|
|
: {}),
|
|
...(r.cancelledAt ? { cancelledAt: r.cancelledAt.toISOString() } : {}),
|
|
}
|
|
existing[r.groupId] = meta
|
|
executionsByRow.set(r.rowId, existing)
|
|
}
|
|
|
|
// Strip rows the user cancelled mid-cascade (post-dispatch tombstones)
|
|
// before running the shared eligibility filter — `buildPendingRuns`
|
|
// doesn't know about the per-dispatch cancel tombstone.
|
|
const tombstoneFiltered: TableRow[] = []
|
|
for (const r of chunk) {
|
|
const tableRow = toTableRow(r, executionsByRow.get(r.id) ?? {})
|
|
const tombstoned = dispatch.scope.groupIds.some((gid) =>
|
|
isExecCancelledAfter(tableRow.executions?.[gid], dispatch.requestedAt)
|
|
)
|
|
if (!tombstoned) tombstoneFiltered.push(tableRow)
|
|
}
|
|
|
|
const pendingRuns = buildPendingRuns(table, tombstoneFiltered, {
|
|
isManualRun: dispatch.isManualRun,
|
|
groupIds: dispatch.scope.groupIds,
|
|
mode: dispatch.mode,
|
|
}).map((p) => ({ ...p, dispatchId, triggeredByUserId: dispatch.triggeredByUserId ?? undefined }))
|
|
|
|
// Cursor advances to the last position in this chunk regardless of
|
|
// eligibility — otherwise a window full of skipped cells loops forever.
|
|
const lastPosition = chunk[chunk.length - 1].position
|
|
|
|
// Apply the dispatch's row cap. With a `rows` limit, only the first
|
|
// `remaining` distinct eligible rows in this window are dispatched and the
|
|
// dispatch completes once the budget is spent. buildPendingRuns emits each
|
|
// row's groups consecutively in ascending position, so collecting distinct
|
|
// rowIds until the budget fills picks the lowest-position rows.
|
|
let windowRuns = pendingRuns
|
|
let dispatchedRows = 0
|
|
let budgetExhausted = false
|
|
if (dispatch.limit?.type === 'rows') {
|
|
const remaining = dispatch.limit.max - dispatch.processedCount
|
|
if (remaining <= 0) {
|
|
await completeDispatch(dispatch, lastPosition)
|
|
return 'done'
|
|
}
|
|
const allowedRowIds = new Set<string>()
|
|
for (const p of pendingRuns) {
|
|
if (allowedRowIds.has(p.rowId)) continue
|
|
if (allowedRowIds.size >= remaining) break
|
|
allowedRowIds.add(p.rowId)
|
|
}
|
|
windowRuns = pendingRuns.filter((p) => allowedRowIds.has(p.rowId))
|
|
dispatchedRows = allowedRowIds.size
|
|
budgetExhausted = dispatch.processedCount + dispatchedRows >= dispatch.limit.max
|
|
}
|
|
|
|
if (windowRuns.length > 0) {
|
|
await stampQueuedForBatch(windowRuns)
|
|
|
|
// Backend-agnostic batch dispatch: trigger.dev wraps `batchTriggerAndWait`
|
|
// (CRIU-checkpointed wait); database backend calls the cell-task runner
|
|
// directly via Promise.all (skips async_jobs since we're awaiting in-
|
|
// process anyway). Either way the parent dispatcher blocks until every
|
|
// cell in the window terminates — bounds queue depth at WINDOW_SIZE.
|
|
const items = await buildEnqueueItems(windowRuns)
|
|
const queue = await getJobQueue()
|
|
try {
|
|
await queue.batchEnqueueAndWait('workflow-group-cell', items)
|
|
} catch (err) {
|
|
logger.error(`[${dispatchId}] batch dispatch failed`, {
|
|
error: toError(err).message,
|
|
})
|
|
// These rows never actually ran, so they must not consume the row cap —
|
|
// otherwise a transient failure on the only window of a `max: N` run would
|
|
// exhaust the budget and complete the dispatch with zero rows started.
|
|
// The cursor still advances past the window (cells are flipped to a
|
|
// re-runnable `error` below), so later windows fulfill the remaining cap.
|
|
dispatchedRows = 0
|
|
budgetExhausted = false
|
|
// Cursor advances past this window, so flip the un-claimed pre-stamps to
|
|
// terminal `error` (+ SSE) — visible, not stuck pending, re-runnable.
|
|
const failedAt = new Date()
|
|
await Promise.allSettled(
|
|
windowRuns.map(async (p) => {
|
|
const updated = await db
|
|
.update(tableRowExecutions)
|
|
.set({ status: 'error', error: 'Failed to enqueue run', updatedAt: failedAt })
|
|
.where(
|
|
and(
|
|
eq(tableRowExecutions.rowId, p.rowId),
|
|
eq(tableRowExecutions.groupId, p.groupId),
|
|
eq(tableRowExecutions.status, 'pending'),
|
|
sql`${tableRowExecutions.executionId} IS NULL`
|
|
)
|
|
)
|
|
.returning({ rowId: tableRowExecutions.rowId })
|
|
if (updated.length === 0) return
|
|
await appendTableEvent({
|
|
kind: 'cell',
|
|
tableId: dispatch.tableId,
|
|
rowId: p.rowId,
|
|
groupId: p.groupId,
|
|
status: 'error',
|
|
executionId: null,
|
|
jobId: null,
|
|
error: 'Failed to enqueue run',
|
|
})
|
|
})
|
|
)
|
|
}
|
|
}
|
|
|
|
if (dispatchedRows > 0) await incrementProcessedCount(dispatchId, dispatchedRows)
|
|
|
|
// Budget spent → complete now rather than crawling the rest of the table.
|
|
if (budgetExhausted) {
|
|
await completeDispatch(dispatch, lastPosition)
|
|
return 'done'
|
|
}
|
|
|
|
// A cell may have halted the dispatch mid-window (e.g. usage limit calls
|
|
// completeDispatchIfActive). Re-read before emitting the per-window
|
|
// `dispatching` event — otherwise that stale event arrives after the client
|
|
// already dropped the dispatch and re-adds it, flickering "X running" back.
|
|
const current = await readDispatch(dispatchId)
|
|
if (!current || current.status === 'cancelled' || current.status === 'complete') return 'done'
|
|
|
|
await Promise.all([
|
|
advanceCursor(dispatchId, lastPosition),
|
|
appendTableEvent({
|
|
kind: 'dispatch',
|
|
tableId: dispatch.tableId,
|
|
dispatchId,
|
|
status: 'dispatching',
|
|
scope: dispatch.scope,
|
|
cursor: lastPosition,
|
|
mode: dispatch.mode,
|
|
isManualRun: dispatch.isManualRun,
|
|
...(dispatch.limit ? { limit: dispatch.limit } : {}),
|
|
}),
|
|
])
|
|
|
|
return 'continue'
|
|
}
|
|
|
|
/** Bump the processed-row counter so a row cap survives across the
|
|
* checkpointed waits between windows. */
|
|
async function incrementProcessedCount(dispatchId: string, delta: number): Promise<void> {
|
|
await db
|
|
.update(tableRunDispatches)
|
|
.set({ processedCount: sql`${tableRunDispatches.processedCount} + ${delta}` })
|
|
.where(eq(tableRunDispatches.id, dispatchId))
|
|
}
|
|
|
|
/** Mark a dispatch complete and emit the terminal SSE so the client overlay
|
|
* clears. Shared by the row-cap exhaustion path. */
|
|
async function completeDispatch(dispatch: DispatchRow, cursor: number): Promise<void> {
|
|
await markDispatchComplete(dispatch.id)
|
|
await appendTableEvent({
|
|
kind: 'dispatch',
|
|
tableId: dispatch.tableId,
|
|
dispatchId: dispatch.id,
|
|
status: 'complete',
|
|
scope: dispatch.scope,
|
|
cursor,
|
|
mode: dispatch.mode,
|
|
isManualRun: dispatch.isManualRun,
|
|
...(dispatch.limit ? { limit: dispatch.limit } : {}),
|
|
})
|
|
}
|
|
|
|
/** Pre-batch stamp: write each targeted cell as `pending` (no executionId)
|
|
* before firing the batch so the renderer shows the cell as in-flight
|
|
* immediately. The cell-task overwrites with `running` (and its own
|
|
* executionId) once it acquires the row's cascade lock — if another
|
|
* cell-task already holds the lock, this task bails and the pending stamp
|
|
* is later reconciled by whoever owns the cascade. */
|
|
async function stampQueuedForBatch(pendingRuns: WorkflowGroupCellPayload[]): Promise<void> {
|
|
await Promise.allSettled(
|
|
pendingRuns.map((runOpts) =>
|
|
writeWorkflowGroupState(runOpts, {
|
|
executionState: {
|
|
status: 'pending',
|
|
executionId: null,
|
|
jobId: null,
|
|
workflowId: runOpts.workflowId,
|
|
error: null,
|
|
},
|
|
})
|
|
)
|
|
)
|
|
}
|
|
|
|
async function advanceCursor(dispatchId: string, newCursor: number): Promise<void> {
|
|
await db
|
|
.update(tableRunDispatches)
|
|
.set({ cursor: newCursor })
|
|
.where(eq(tableRunDispatches.id, dispatchId))
|
|
}
|
|
|
|
export async function markDispatchComplete(dispatchId: string): Promise<void> {
|
|
await db
|
|
.update(tableRunDispatches)
|
|
.set({ status: 'complete', completedAt: new Date() })
|
|
.where(eq(tableRunDispatches.id, dispatchId))
|
|
}
|
|
|
|
/** Cancel one dispatch by id (if still active) and emit the terminal SSE so
|
|
* the client overlay clears. Used when `runWorkflowColumn` fails between
|
|
* inserting its dispatch row and firing the dispatcher — without this the
|
|
* orphaned `pending` row would pin the "about to run" overlay forever. */
|
|
export async function cancelDispatchById(dispatchId: string): Promise<void> {
|
|
const [row] = await db
|
|
.update(tableRunDispatches)
|
|
.set({ status: 'cancelled', cancelledAt: new Date() })
|
|
.where(
|
|
and(
|
|
eq(tableRunDispatches.id, dispatchId),
|
|
inArray(tableRunDispatches.status, [...ACTIVE_DISPATCH_STATUSES])
|
|
)
|
|
)
|
|
.returning()
|
|
if (!row) return
|
|
await appendTableEvent({
|
|
kind: 'dispatch',
|
|
tableId: row.tableId,
|
|
dispatchId: row.id,
|
|
status: 'cancelled',
|
|
scope: row.scope as DispatchScope,
|
|
cursor: row.cursor,
|
|
mode: row.mode as DispatchMode,
|
|
isManualRun: row.isManualRun,
|
|
})
|
|
}
|
|
|
|
/** Complete a dispatch only if it's still active, returning whether THIS call
|
|
* performed the transition. Lets concurrent cells that all hit a hard stop
|
|
* (e.g. usage limit) elect a single owner — only the winner emits the
|
|
* user-facing event, instead of one toast per in-flight cell. */
|
|
export async function completeDispatchIfActive(dispatchId: string): Promise<boolean> {
|
|
const transitioned = await db
|
|
.update(tableRunDispatches)
|
|
.set({ status: 'complete', completedAt: new Date() })
|
|
.where(
|
|
and(
|
|
eq(tableRunDispatches.id, dispatchId),
|
|
inArray(tableRunDispatches.status, [...ACTIVE_DISPATCH_STATUSES])
|
|
)
|
|
)
|
|
.returning({ id: tableRunDispatches.id })
|
|
return transitioned.length > 0
|
|
}
|
|
|
|
export async function markDispatchCancelled(dispatchId: string): Promise<void> {
|
|
await db
|
|
.update(tableRunDispatches)
|
|
.set({ status: 'cancelled', cancelledAt: new Date() })
|
|
.where(
|
|
and(
|
|
eq(tableRunDispatches.id, dispatchId),
|
|
inArray(tableRunDispatches.status, [...ACTIVE_DISPATCH_STATUSES])
|
|
)
|
|
)
|
|
}
|
|
|
|
/** Mark every active dispatch on this table as cancelled. Single atomic
|
|
* UPDATE so the dispatcher's next iteration observes the cancel. Returns the
|
|
* dispatches that were cancelled so the caller can emit per-dispatch SSE
|
|
* events — without those the client's overlay would hang on "queued" until
|
|
* the next refresh. Pass `scopeFilter` to cancel only dispatches whose scope
|
|
* is that exact filter (a filtered "select all" Stop must not halt
|
|
* whole-table or differently-filtered runs). Pass `spareExcludedRowIds`
|
|
* (select-all-minus-deselections Stop) to spare row-scoped dispatches whose
|
|
* rows are ALL deselected — that work wasn't in the stopped selection. Pass
|
|
* `spareDispatchId` when the caller is a manual run cancelling *prior* work:
|
|
* its own dispatch row is already inserted (so a concurrent Stop-all has
|
|
* something to cancel) and must not cancel itself. */
|
|
export async function markActiveDispatchesCancelled(
|
|
tableId: string,
|
|
opts?: { scopeFilter?: Filter; spareExcludedRowIds?: string[]; spareDispatchId?: string }
|
|
): Promise<DispatchRow[]> {
|
|
const { scopeFilter, spareExcludedRowIds, spareDispatchId } = opts ?? {}
|
|
const cancelled = await db
|
|
.update(tableRunDispatches)
|
|
.set({ status: 'cancelled', cancelledAt: new Date() })
|
|
.where(
|
|
and(
|
|
eq(tableRunDispatches.tableId, tableId),
|
|
inArray(tableRunDispatches.status, [...ACTIVE_DISPATCH_STATUSES]),
|
|
spareDispatchId ? ne(tableRunDispatches.id, spareDispatchId) : undefined,
|
|
scopeFilter
|
|
? sql`${tableRunDispatches.scope}->'filter' = ${JSON.stringify(scopeFilter)}::jsonb`
|
|
: undefined,
|
|
// coalesce(false): table-wide dispatches have no scope.rowIds (NULL <@ x
|
|
// is NULL) and must still cancel.
|
|
spareExcludedRowIds && spareExcludedRowIds.length > 0
|
|
? sql`NOT coalesce(
|
|
${tableRunDispatches.scope}->'rowIds' <@ ${JSON.stringify(spareExcludedRowIds)}::jsonb
|
|
AND jsonb_array_length(${tableRunDispatches.scope}->'rowIds') > 0,
|
|
false
|
|
)`
|
|
: undefined
|
|
)
|
|
)
|
|
.returning()
|
|
const dispatches = cancelled.map((row) => ({
|
|
id: row.id,
|
|
tableId: row.tableId,
|
|
workspaceId: row.workspaceId,
|
|
requestId: row.requestId,
|
|
mode: row.mode as DispatchMode,
|
|
scope: row.scope as DispatchScope,
|
|
status: 'cancelled' as DispatchStatus,
|
|
cursor: row.cursor,
|
|
limit: (row.limit as DispatchLimit | null) ?? null,
|
|
processedCount: row.processedCount,
|
|
isManualRun: row.isManualRun,
|
|
triggeredByUserId: row.triggeredByUserId,
|
|
requestedAt: row.requestedAt,
|
|
}))
|
|
await Promise.all(
|
|
dispatches.map((d) =>
|
|
appendTableEvent({
|
|
kind: 'dispatch',
|
|
tableId: d.tableId,
|
|
dispatchId: d.id,
|
|
status: 'cancelled',
|
|
scope: d.scope,
|
|
cursor: d.cursor,
|
|
mode: d.mode,
|
|
isManualRun: d.isManualRun,
|
|
})
|
|
)
|
|
)
|
|
return dispatches
|
|
}
|