Files
simstudioai--sim/apps/sim/background/workflow-column-execution.ts
WeHub Mirror 6bf8bebf51
CI / Test and Build (push) Failing after 1s
CI / Migrate Dev DB (push) Has been skipped
CI / Migrate DB (push) Has been skipped
CodeQL / Analyze actions (push) Has been cancelled
CodeQL / Analyze javascript-typescript (push) Has been cancelled
CI / Detect Version (push) Has been cancelled
CI / Detect Desktop Changes (push) Has been cancelled
CI / Build AMD64 (blacksmith-2vcpu-ubuntu-2404, ./docker/cron.Dockerfile, ubuntu-latest, ghcr.io/simstudioai/cron) (push) Has been cancelled
CI / Build AMD64 (blacksmith-2vcpu-ubuntu-2404, ./docker/db.Dockerfile, ECR_MIGRATIONS, ubuntu-latest, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (blacksmith-4vcpu-ubuntu-2404, ./docker/pii.Dockerfile, ECR_PII, ubuntu-latest, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (blacksmith-4vcpu-ubuntu-2404, ./docker/realtime.Dockerfile, ECR_REALTIME, ubuntu-latest, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build AMD64 (blacksmith-8vcpu-ubuntu-2404, ./docker/app.Dockerfile, ECR_APP, linux-x64-8-core, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (blacksmith-4vcpu-ubuntu-2404-arm, ./docker/cron.Dockerfile, ubuntu-24.04-arm, ghcr.io/simstudioai/cron) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (blacksmith-4vcpu-ubuntu-2404-arm, ./docker/db.Dockerfile, ubuntu-24.04-arm, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (blacksmith-4vcpu-ubuntu-2404-arm, ./docker/pii.Dockerfile, ubuntu-24.04-arm, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (blacksmith-4vcpu-ubuntu-2404-arm, ./docker/realtime.Dockerfile, ubuntu-24.04-arm, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (blacksmith-8vcpu-ubuntu-2404-arm, ./docker/app.Dockerfile, linux-arm64-8-core, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
Publish CLI Package / publish-npm (push) Has been cancelled
Publish Python SDK / publish-pypi (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
Helm Chart / Lint, test, and validate chart (push) Has been cancelled
Helm Chart / Chart version bumped (push) Has been cancelled
Publish TypeScript SDK / publish-npm (push) Has been cancelled
CI / Build Dev ECR (blacksmith-8vcpu-ubuntu-2404, ./docker/app.Dockerfile, ECR_APP, linux-x64-8-core) (push) Has been cancelled
CI / Promote Images (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/cron) (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 / Build Dev ECR (blacksmith-2vcpu-ubuntu-2404, ./docker/db.Dockerfile, ECR_MIGRATIONS, ubuntu-latest) (push) Has been cancelled
CI / Build Dev ECR (blacksmith-4vcpu-ubuntu-2404, ./docker/pii.Dockerfile, ECR_PII, ubuntu-latest) (push) Has been cancelled
CI / Build Dev ECR (blacksmith-4vcpu-ubuntu-2404, ./docker/realtime.Dockerfile, ECR_REALTIME, ubuntu-latest) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Check Desktop Signing Secrets (push) Has been cancelled
CI / Desktop Release (push) Has been cancelled
CI / Create Desktop Prerelease (push) Has been cancelled
CI / Desktop Prerelease Build (push) Has been cancelled
CI / Publish Desktop Prerelease (push) Has been cancelled
CI / Prune Desktop Prereleases (push) Has been cancelled
Helm Chart / Install on kind and run helm test (push) Has been cancelled
WeHub snapshot of cb28d14c6f2c081de7a0d8729a8c816c9adef67a
2026-08-10 11:17:50 +08:00

1140 lines
44 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { db } from '@sim/db'
import { tableRowExecutions, workflow as workflowTable } from '@sim/db/schema'
import { createLogger, runWithRequestContext } from '@sim/logger'
import { describeError, toError } from '@sim/utils/errors'
import { sleep } from '@sim/utils/helpers'
import { generateId } from '@sim/utils/id'
import { backoffWithJitter } from '@sim/utils/retry'
import { task, timeout } from '@trigger.dev/sdk'
import { and, eq, isNull, or } from 'drizzle-orm'
import {
assertBillingAttributionSnapshot,
type BillingAttributionSnapshot,
checkAttributedUsageLimits,
toBillingContext,
} from '@/lib/billing/core/billing-attribution'
import { checkAndBillPayerOverageThreshold } from '@/lib/billing/threshold-billing'
import { isRetryableInfrastructureError } from '@/lib/core/errors/retryable-infrastructure'
import {
capExecutionTimeoutMs,
createTimeoutAbortController,
getAsyncExecutionTimeoutForBillingAttribution,
getTimeoutErrorMessage,
isTimeoutAbortReason,
type TimeoutAbortController,
} from '@/lib/core/execution-limits'
import { RateLimiter } from '@/lib/core/rate-limiter/rate-limiter'
import {
registerManualExecutionAborter,
unregisterManualExecutionAborter,
} from '@/lib/execution/manual-cancellation'
import { preprocessExecution } from '@/lib/execution/preprocessing'
import { retryTableAdmission } from '@/lib/table/admission-retry'
import { withCascadeLock } from '@/lib/table/cascade-lock'
import { fillMissingColumns, mapInputValues, namedRowMapper } from '@/lib/table/cell-format'
import { getColumnId } from '@/lib/table/column-keys'
import { isEmptyCellValue, isExecCancelled } from '@/lib/table/deps'
import { getMaxTableDispatchConcurrency } from '@/lib/table/dispatch-concurrency'
import { appendTableEvent } from '@/lib/table/events'
import {
createExactEmptyTableRowSecretProvenance,
createTableRowSecretProvenanceFromRegistry,
loadTableRowSecretProvenance,
} from '@/lib/table/rows/secret-provenance'
import type {
RowData,
RowExecutionMetadata,
TableDefinition,
TableRowSecretProvenanceWrite,
UpdateRowData,
WorkflowGroup,
} from '@/lib/table/types'
import {
buildWorkflowGroupExecutionCorrelation,
type QueuedWorkflowGroupCellPayload,
type WorkflowGroupCellPayload,
} from '@/lib/table/workflow-columns'
import { ResolvedSecretTraceRegistry } from '@/executor/utils/resolved-secret-trace-registry'
export type { WorkflowGroupCellPayload }
const logger = createLogger('TriggerWorkflowGroupCell')
function requirePayloadBillingAttribution(
payload: WorkflowGroupCellPayload
): BillingAttributionSnapshot {
if (!payload.billingAttribution) {
throw new Error('Billing attribution is required for queued table execution')
}
const attribution = assertBillingAttributionSnapshot(payload.billingAttribution)
if (attribution.workspaceId !== payload.workspaceId) {
throw new Error(
`Billing attribution workspace mismatch: expected ${payload.workspaceId}, received ${attribution.workspaceId}`
)
}
if (payload.triggeredByUserId && attribution.actorUserId !== payload.triggeredByUserId) {
throw new Error('Billing attribution actor does not match the table trigger actor')
}
return attribution
}
/** Max rate-limit retry attempts per cell before giving up and writing a
* re-runnable error. With `backoffWithJitter` (base 500ms, max 30s) this is
* ~12 minutes of pacing — enough to ride out a transient burst without
* stalling the dispatcher window indefinitely. */
const RATE_LIMIT_MAX_ATTEMPTS = 6
/**
* Builds the execution-id-guarded terminal state used when a table attempt is
* aborted. A user Stop has already persisted the
* authoritative `cancelled` tombstone, which this error write cannot overwrite;
* an uncorrelated backend abort still clears the pre-stamped pending state.
*/
export function buildTableAbortState(args: {
executionId: string
workflowId: string
timedOut: boolean
timeoutMs?: number
}): RowExecutionMetadata {
const { executionId, workflowId, timedOut, timeoutMs } = args
return {
status: 'error',
executionId,
jobId: null,
workflowId,
error: timedOut ? getTimeoutErrorMessage(null, timeoutMs) : 'Cancelled',
runningBlockIds: [],
}
}
/**
* Terminalizes only the unclaimed dispatcher marker owned by this carrier.
* The conditional update cannot create a state for an auto-cascade group that
* was never queued, or overwrite a cancellation/newer attempt.
*/
export async function terminalizeAbortedQueuedCarrierMarker(
payload: QueuedWorkflowGroupCellPayload,
signal: AbortSignal
): Promise<boolean> {
const timeoutMs = capExecutionTimeoutMs(
getAsyncExecutionTimeoutForBillingAttribution(requirePayloadBillingAttribution(payload)),
payload.executionTimeoutMs
)
const executionState = buildTableAbortState({
executionId: payload.executionId,
workflowId: payload.enrichmentId ?? payload.workflowId,
timedOut: isTimeoutAbortReason(signal.reason),
timeoutMs,
})
const [updated] = await db
.update(tableRowExecutions)
.set({
status: executionState.status,
executionId: executionState.executionId,
jobId: executionState.jobId,
workflowId: executionState.workflowId,
error: executionState.error,
runningBlockIds: executionState.runningBlockIds,
updatedAt: new Date(),
})
.where(
and(
eq(tableRowExecutions.tableId, payload.tableId),
eq(tableRowExecutions.rowId, payload.rowId),
eq(tableRowExecutions.groupId, payload.groupId),
eq(tableRowExecutions.status, 'pending'),
or(
isNull(tableRowExecutions.executionId),
eq(tableRowExecutions.executionId, payload.executionId)
)
)
)
.returning({ rowId: tableRowExecutions.rowId })
if (!updated) return false
void appendTableEvent({
kind: 'cell',
tableId: payload.tableId,
rowId: payload.rowId,
groupId: payload.groupId,
status: executionState.status,
executionId: executionState.executionId ?? null,
jobId: null,
error: executionState.error ?? null,
})
return true
}
/** Builds the guarded null patch used to clear a usage-blocked cell pre-stamp. */
export function buildTableUsageLimitClear(args: {
tableId: string
rowId: string
workspaceId: string
groupId: string
executionId: string
}): UpdateRowData {
const { tableId, rowId, workspaceId, groupId, executionId } = args
return {
tableId,
rowId,
data: {},
workspaceId,
executionsPatch: { [groupId]: null },
cancellationGuard: { groupId, executionId },
}
}
/** Starts the one active workflow deadline shared by every group in this carrier job. */
export function createWorkflowGroupCarrierTimeoutController(
payload: QueuedWorkflowGroupCellPayload,
parentSignal?: AbortSignal
): TimeoutAbortController {
const billingAttribution = requirePayloadBillingAttribution(payload)
return createTimeoutAbortController(
capExecutionTimeoutMs(
getAsyncExecutionTimeoutForBillingAttribution(billingAttribution),
payload.executionTimeoutMs
),
parentSignal
)
}
/**
* Owns cancellation for one workflow-group attempt without aborting the row
* carrier that may subsequently execute other groups. The shared in-process
* registry is an exact execution-id fast path; remote workers receive the same
* exact cancellation through the executor's cancellation channel.
*/
export function createWorkflowGroupAttemptTimeoutController(
payload: QueuedWorkflowGroupCellPayload,
parentSignal?: AbortSignal
): TimeoutAbortController {
const billingAttribution = requirePayloadBillingAttribution(payload)
const controller = createTimeoutAbortController(
capExecutionTimeoutMs(
getAsyncExecutionTimeoutForBillingAttribution(billingAttribution),
payload.executionTimeoutMs
),
parentSignal
)
registerManualExecutionAborter(payload.executionId, controller.abort)
return {
...controller,
cleanup: () => {
unregisterManualExecutionAborter(payload.executionId, controller.abort)
controller.cleanup()
},
}
}
/** Cell-task entrypoint. Holds a per-row cascade lock so only one worker
* advances a given row at a time; bails on contention. The held lock heart-
* beats every 10s so a crashed pod releases within ~30s.
*
* After the cascade finishes and the lock releases, re-checks for a runnable
* queued marker that may have landed between the cascade's final
* `pickNextEligibleGroupForRow` and the lock release (a window where a
* contender bails on the still-held lock but we're already done). If one
* appeared, re-acquire and drive it — this is the same task re-acquiring the
* lock, NOT a queue re-enqueue or a timed poll, and it loops only while a
* runnable group exists. */
export async function executeWorkflowGroupCellJob(
payload: QueuedWorkflowGroupCellPayload,
signal?: AbortSignal
) {
const carrierTimeoutController = createWorkflowGroupCarrierTimeoutController(payload, signal)
const carrierSignal = carrierTimeoutController.signal
try {
const { tableId, rowId, workspaceId } = payload
const { getTableById } = await import('@/lib/table/service')
const { getRowById } = await import('@/lib/table/rows/service')
const { pickNextEligibleGroupForRow } = await import('@/lib/table/workflow-columns')
let currentPayload = payload
while (true) {
if (carrierSignal.aborted) {
await terminalizeAbortedQueuedCarrierMarker(currentPayload, carrierSignal)
break
}
const outcome = await withCascadeLock(tableId, rowId, currentPayload.executionId, () =>
runRowCascadeLoop(currentPayload, carrierSignal)
)
if (outcome.status === 'contended') {
// Another worker owns the row's cascade; it drains the queued marker.
logger.info(
`Cascade lock held — bailing (table=${tableId} row=${rowId} executionId=${currentPayload.executionId})`
)
break
}
// Usage limit hit mid-cascade: the dispatch is halted and no cell was
// marked, so stop re-driving this row.
if (outcome.result === 'blocked') break
if (carrierSignal.aborted) break
const freshTable = await getTableById(tableId)
if (!freshTable) break
const freshRow = await getRowById(tableId, rowId, workspaceId)
if (!freshRow) break
const next = pickNextEligibleGroupForRow(freshTable, freshRow)
if (!next) break
// Only re-drive a genuine queued marker (an explicit run request whose
// cell-task bailed during our release window). The inner cascade loop has
// already drained every auto-eligible group, so re-driving a non-marker
// group here would re-run forever — e.g. a group that completed with empty
// outputs stays auto-eligible (the inner loop excludes it via
// `excludeGroupId`, but this outer pass has no such anchor).
const nextExec = freshRow.executions?.[next.id]
const hasQueuedMarker = nextExec?.status === 'pending' && nextExec.executionId == null
if (!hasQueuedMarker) break
currentPayload = {
...currentPayload,
groupId: next.id,
workflowId: next.workflowId,
// Re-derive so a workflow group after an enrichment group doesn't keep a stale enrichmentId.
enrichmentId: next.enrichmentId,
executionId: generateId(),
}
}
} finally {
carrierTimeoutController.cleanup()
}
}
/** Re-fetches the table schema each iteration so groups added DURING the
* cascade become visible to the eligibility check. The resume worker must
* already hold the row's cascade lock before calling. */
export async function runRowCascadeLoop(
payload: QueuedWorkflowGroupCellPayload,
signal?: AbortSignal
): Promise<'blocked' | undefined> {
const { tableId, rowId, workspaceId } = payload
const { getTableById } = await import('@/lib/table/service')
const { getRowById } = await import('@/lib/table/rows/service')
const { pickNextEligibleGroupForRow } = await import('@/lib/table/workflow-columns')
let currentGroupId = payload.groupId
let currentWorkflowId = payload.workflowId
// Fresh executionId per iteration: SQL guard rejects writes whose id ≠
// row.executions[gid].executionId, so we need a new claim per group.
let currentExecutionId = payload.executionId
while (true) {
if (signal?.aborted) {
await terminalizeAbortedQueuedCarrierMarker(
{
...payload,
groupId: currentGroupId,
workflowId: currentWorkflowId,
executionId: currentExecutionId,
},
signal
)
break
}
const freshTable = await getTableById(tableId)
if (!freshTable) {
logger.warn(`Table ${tableId} vanished mid-cascade`)
break
}
const currentGroup = freshTable.schema.workflowGroups?.find((g) => g.id === currentGroupId)
if (!currentGroup) {
logger.warn(`Group ${currentGroupId} no longer exists on table ${tableId}`)
break
}
const result = await runWorkflowAndWriteTerminal(
{
...payload,
groupId: currentGroupId,
workflowId: currentWorkflowId,
executionId: currentExecutionId,
},
signal,
freshTable,
currentGroup
)
if (result === 'paused' || result === 'cancelled') break
// Hard stop (e.g. usage limit): the dispatch was halted and no cell was
// marked. Propagate so the outer re-drive loop stops too — otherwise it
// would re-pick the still-pending queued marker and spin.
if (result === 'blocked') return 'blocked'
const freshRow = await getRowById(tableId, rowId, workspaceId)
if (!freshRow) break
const next = pickNextEligibleGroupForRow(freshTable, freshRow, currentGroupId)
if (!next) break
currentGroupId = next.id
currentWorkflowId = next.workflowId
currentExecutionId = generateId()
}
return undefined
}
/** Returns `'paused'` or `'cancelled'` when the cascade must exit and
* `'blocked'` for a hard stop (usage limit — dispatch halted, cell left
* unmarked). `'completed' | 'error'` keep the loop running. */
async function runWorkflowAndWriteTerminal(
payload: QueuedWorkflowGroupCellPayload,
signal: AbortSignal | undefined,
table: TableDefinition,
group: WorkflowGroup
): Promise<'completed' | 'error' | 'cancelled' | 'paused' | 'blocked'> {
const { tableId, tableName, rowId, groupId, workflowId, workspaceId, executionId, dispatchId } =
payload
const billingAttribution = requirePayloadBillingAttribution(payload)
const timeoutController = createWorkflowGroupAttemptTimeoutController(payload, signal)
const attemptSignal = timeoutController.signal
// Read from the live `group`, not the payload: in a cascade the payload is the
// first group's snapshot, so a downstream group with a different version must
// use its own setting (same reason `workflowId` is re-derived per iteration).
const deploymentMode = group.deploymentMode
const requestId = `wfgrp-${executionId}`
try {
return await runWithRequestContext({ requestId }, async () => {
const { getRowById } = await import('@/lib/table/rows/service')
const { executeWorkflow } = await import('@/lib/workflows/executor/execute-workflow')
const { loadWorkflowFromNormalizedTables, loadDeployedWorkflowState } = await import(
'@/lib/workflows/persistence/utils'
)
const {
buildCancelledExecution,
createWorkflowCellProgressWriter,
writeWorkflowGroupState,
markWorkflowGroupPickedUp,
} = await import('@/lib/table/cell-write')
const { classifyWorkflowCellTerminalResult } = await import(
'@/lib/table/workflow-cell-result'
)
const { stashCellContextForResume } = await import('@/lib/table/workflow-columns')
const cellCtx = { tableId, rowId, workspaceId, groupId, executionId, requestId, table }
const writeState = (
executionState: RowExecutionMetadata,
dataPatch?: RowData,
eventOutputs?: RowData,
secretProvenance?: TableRowSecretProvenanceWrite
) =>
writeWorkflowGroupState(cellCtx, {
executionState,
dataPatch,
eventOutputs,
secretProvenance,
})
/** Pre-execution cancellation guard: a cell cancelled while it sat in the
* queue (e.g. trigger.dev concurrency backlog) must not run once it
* dequeues. Reads the already-loaded row's exec — no extra query. */
const cancelledBeforeRun = (exec: RowExecutionMetadata | undefined): boolean => {
if (!isExecCancelled(exec)) return false
logger.info(
`Skipping cell — cancelled before execution (table=${tableId} row=${rowId} group=${groupId})`
)
return true
}
// Enrichment groups call a registry function directly instead of running a
// workflow, reusing the same pickup → run → terminal-write status flow. The
// `enrichmentId` guard ensures only true registry enrichments take this path
// — a group typed 'enrichment' without a registry id falls through to the
// workflow path rather than erroring.
if (group.type === 'enrichment' && group.enrichmentId) {
const { getEnrichment } = await import('@/enrichments/registry')
const { runEnrichment, skippedEnrichmentDetail } = await import('@/enrichments/run')
const enrichment = getEnrichment(group.enrichmentId)
// `tableRowExecutions.workflowId` is an opaque id for status; use the
// enrichment id for enrichment cells.
const statusId = group.enrichmentId ?? ''
if (!enrichment) {
await writeState({
status: 'error',
executionId,
jobId: null,
workflowId: statusId,
error: `Unknown enrichment "${group.enrichmentId ?? ''}"`,
})
return 'error'
}
const row = await getRowById(tableId, rowId, workspaceId)
if (!row) {
logger.warn(`Row ${rowId} vanished before enrichment`)
return 'error'
}
if (cancelledBeforeRun(row.executions?.[groupId])) return 'cancelled'
const enrichmentBillingAttribution = billingAttribution
/**
* Gate the exact workspace payer and member cap before hosted-key cost.
* A denial clears the cell pre-stamp and surfaces the upgrade state.
*/
const usage = await checkAttributedUsageLimits(enrichmentBillingAttribution)
if (usage.isExceeded) {
logger.warn(
`Usage limit reached — halting enrichment (table=${tableId} row=${rowId} group=${groupId})`
)
const { updateRow } = await import('@/lib/table/rows/service')
await updateRow(
buildTableUsageLimitClear({ tableId, rowId, workspaceId, groupId, executionId }),
table,
requestId
).catch((err) =>
logger.warn(`Failed to clear cell pre-stamp on usage limit`, {
error: toError(err).message,
})
)
let shouldEmit = true
if (dispatchId) {
const { completeDispatchIfActive } = await import('@/lib/table/dispatcher')
shouldEmit = await completeDispatchIfActive(dispatchId)
}
if (shouldEmit) {
await appendTableEvent({
kind: 'usageLimitReached',
tableId,
...(dispatchId ? { dispatchId } : {}),
message:
usage.message ?? 'Usage limit exceeded. Please upgrade your plan to continue.',
})
}
return 'blocked'
}
const pickedUp = await markWorkflowGroupPickedUp(cellCtx, {
workflowId: statusId,
jobId: null,
})
if (pickedUp === 'skipped') return 'error'
// Map table columns → enrichment input ids (skip this group's own outputs).
// `columnName` holds a column id; the mapper resolves select ids to names.
const ownOutputColumns = new Set(group.outputs.map((o) => o.columnName))
const enrichmentInputMappings = (group.inputMappings ?? []).filter(
(mapping) => !ownOutputColumns.has(mapping.columnName)
)
const enrichInputs = mapInputValues(row.data, table.schema.columns, enrichmentInputMappings)
// Skip (don't error) rows missing a required input — common when a table
// is partially filled. Clear any prior output values so a stale result
// doesn't linger (and doesn't mark the group `completed`-and-filled, which
// would block the auto cascade from re-enriching once inputs return).
const isEmpty = isEmptyCellValue
const missingRequired = enrichment.inputs.some(
(i) => i.required && isEmpty(enrichInputs[i.id])
)
if (missingRequired) {
const clearPatch: RowData = {}
for (const out of group.outputs) {
if (!isEmpty(row.data[out.columnName])) clearPatch[out.columnName] = ''
}
await writeState(
{
status: 'completed',
executionId,
jobId: null,
workflowId: statusId,
error: null,
enrichmentDetails: skippedEnrichmentDetail(enrichment),
},
clearPatch,
undefined,
createExactEmptyTableRowSecretProvenance(clearPatch)
)
return 'completed'
}
try {
if (attemptSignal.aborted) {
await writeState({
...buildTableAbortState({
executionId,
workflowId: statusId,
timedOut: timeoutController.isTimedOut(),
timeoutMs: timeoutController.timeoutMs,
}),
enrichmentDetails: skippedEnrichmentDetail(enrichment, { aborted: true }),
})
return 'error'
}
const inputProvenance = await loadTableRowSecretProvenance(
[
{
id: row.id,
updatedAt: row.updatedAt,
selectedValues: Object.fromEntries(
enrichmentInputMappings.map((mapping) => [
mapping.columnName,
row.data[mapping.columnName],
])
),
},
],
{ userId: enrichmentBillingAttribution.actorUserId, workspaceId }
)
const enrichmentRegistry = new ResolvedSecretTraceRegistry([], inputProvenance.scope)
await enrichmentRegistry.importCrossingProvenance(inputProvenance, enrichInputs, {
trusted: true,
})
const { result, cost, error, detail } = await runEnrichment(enrichment, enrichInputs, {
tableId,
rowId,
workspaceId,
signal: attemptSignal,
resolvedSecretTraceRegistry: enrichmentRegistry,
})
// An abort during the cascade must not be recorded as a completed cell.
if (attemptSignal.aborted) {
await writeState({
...buildTableAbortState({
executionId,
workflowId: statusId,
timedOut: timeoutController.isTimedOut(),
timeoutMs: timeoutController.timeoutMs,
}),
enrichmentDetails: detail,
})
return 'error'
}
// Every provider that ran errored (auth / rate-limit / outage) — surface
// it rather than writing a blank cell that looks like "no data found".
if (error) {
await writeState({
status: 'error',
executionId,
jobId: null,
workflowId: statusId,
error,
enrichmentDetails: detail,
})
return 'error'
}
/**
* Record the triggerer or system fallback as actor while charging the
* exact workspace payer. Billing failures do not fail a successful cell.
*/
if (cost > 0) {
try {
const { recordUsage } = await import('@/lib/billing/core/usage-log')
await recordUsage({
userId: enrichmentBillingAttribution.actorUserId,
workspaceId,
executionId,
...toBillingContext(enrichmentBillingAttribution),
entries: [
{
category: 'fixed',
source: 'enrichment',
description: enrichment.name,
cost,
sourceReference: `enrichment:${tableId}:${rowId}:${enrichment.id}`,
metadata: { enrichmentId: enrichment.id, tableId, rowId },
},
],
})
await checkAndBillPayerOverageThreshold(enrichmentBillingAttribution.billingEntity)
} catch (billingErr) {
logger.error('Failed to record enrichment usage', {
enrichmentId: enrichment.id,
cost,
error: toError(billingErr).message,
})
}
}
// Write every output column: the result value when present, else clear
// it. A partial/empty result must blank the columns it didn't fill so a
// re-run that finds less than before doesn't leave stale values.
const dataPatch: RowData = {}
for (const out of group.outputs) {
if (!out.outputId) continue
const value = result[out.outputId]
dataPatch[out.columnName] =
value === undefined || value === null ? '' : (value as RowData[string])
}
await writeState(
{
status: 'completed',
executionId,
jobId: null,
workflowId: statusId,
error: null,
enrichmentDetails: detail,
},
dataPatch,
undefined,
createTableRowSecretProvenanceFromRegistry(dataPatch, enrichmentRegistry)
)
return 'completed'
} catch (err) {
await writeState({
status: 'error',
executionId,
jobId: null,
workflowId: statusId,
error: toError(err).message,
})
return 'error'
}
}
let progressWriter: ReturnType<typeof createWorkflowCellProgressWriter> | null = null
try {
const [workflowRecord] = await db
.select()
.from(workflowTable)
.where(eq(workflowTable.id, workflowId))
.limit(1)
if (!workflowRecord) {
await writeState({
status: 'error',
executionId,
jobId: null,
workflowId,
error: 'Workflow not found',
})
return 'error'
}
// `deployed` groups run the workflow's latest active deployment; `live`
// (default) runs the editable draft. A `deployed` group whose workflow
// has never been deployed fails the cell — no silent fallback to draft.
let normalizedData: Awaited<ReturnType<typeof loadWorkflowFromNormalizedTables>>
if (deploymentMode === 'deployed') {
try {
normalizedData = await loadDeployedWorkflowState(workflowId, workspaceId)
} catch (err) {
// Surface the real reason (missing deployment vs. transient DB/migration
// failure) rather than always claiming the workflow isn't deployed.
await writeState({
status: 'error',
executionId,
jobId: null,
workflowId,
error: toError(err).message,
})
return 'error'
}
} else {
normalizedData = await loadWorkflowFromNormalizedTables(workflowId)
}
const startBlock = normalizedData
? Object.values(normalizedData.blocks).find((b) => b?.type === 'start_trigger')
: undefined
if (!startBlock) {
await writeState({
status: 'error',
executionId,
jobId: null,
workflowId,
error: 'Workflow is missing a Start trigger',
})
return 'error'
}
const row = await getRowById(tableId, rowId, workspaceId)
if (!row) {
logger.warn(`Row ${rowId} vanished before execution`)
return 'error'
}
if (cancelledBeforeRun(row.executions?.[groupId])) return 'cancelled'
// Billing / usage / timeout gate — route table cells through the same
// preprocessing every other trigger uses. Keep running draft
// (checkDeployment: false). Rate limiting is paced separately below so a
// retry doesn't re-run the (stable) billing/usage/subscription lookups.
// Failures are surfaced via cell state / SSE / dispatch halt, so suppress
// preprocessing's own execution-log writes.
// Attribute the run to the member who triggered it (manual run, row edit, or
// auto-fire from their own write) so the gate + cost land on their per-member
// meter — mirroring the enrichment branch. Falls back to the workspace billed
// account for genuinely actor-less runs.
const preprocess = await retryTableAdmission(
() =>
preprocessExecution({
workflowId,
executionId,
requestId,
workspaceId,
workflowRecord,
userId: payload.triggeredByUserId ?? workflowRecord.userId,
useAuthenticatedUserAsActor: Boolean(payload.triggeredByUserId),
triggerType: 'workflow',
checkDeployment: false,
checkRateLimit: false,
skipConcurrencyReservation: true,
logPreprocessingErrors: false,
billingAttribution,
executionType: 'async',
}),
{
signal: attemptSignal,
onRetry: ({ attempt, failure, nextAttempt, waitMs }) => {
logger.warn(
`Transient admission failure — waiting ${waitMs}ms before retry ${nextAttempt} (table=${tableId} row=${rowId} group=${groupId})`,
{ attempt, failure: failure.kind }
)
},
}
)
if (!preprocess.success) {
// Usage/quota exhausted: retrying won't help. Halt the dispatch without
// marking any cell, and signal the client to upgrade.
if (preprocess.error?.statusCode === 402) {
logger.warn(
`Usage limit reached — halting dispatch (table=${tableId} row=${rowId} group=${groupId})`
)
// Don't leave the cell stuck on its `pending` pre-stamp. Clear this
// cell's exec so it reverts to un-run (no error/cancelled badge —
// matching "don't mark"; re-runnable after upgrade). Each blocked
// cell clears its own.
const { updateRow } = await import('@/lib/table/rows/service')
await updateRow(
buildTableUsageLimitClear({ tableId, rowId, workspaceId, groupId, executionId }),
table,
requestId
).catch((err) =>
logger.warn(`Failed to clear cell pre-stamp on usage limit`, {
error: toError(err).message,
})
)
// With up to 20 concurrent cells all hitting the limit at once, only
// the cell that transitions the dispatch active→complete emits the
// event — otherwise the user sees a toast per in-flight cell. Cells
// with no owning dispatch (auto-fire) always emit.
let shouldEmit = true
if (dispatchId) {
const { completeDispatchIfActive } = await import('@/lib/table/dispatcher')
shouldEmit = await completeDispatchIfActive(dispatchId)
}
if (shouldEmit) {
await appendTableEvent({
kind: 'usageLimitReached',
tableId,
...(dispatchId ? { dispatchId } : {}),
message:
preprocess.error?.message ??
'Usage limit exceeded. Please upgrade your plan to continue.',
})
}
return 'blocked'
}
await writeState({
status: 'error',
executionId,
jobId: null,
workflowId,
error: preprocess.error?.message ?? 'Workflow could not start',
})
return 'error'
}
const actorUserId = preprocess.actorUserId ?? workflowRecord.userId
const writePacingAbortState = async (): Promise<'error'> => {
await writeState(
buildTableAbortState({
executionId,
workflowId,
timedOut: timeoutController.isTimedOut(),
timeoutMs: timeoutController.timeoutMs,
})
)
return 'error'
}
// Rate-limit pacing: tables count against the async counter (background
// jobs). On a hit, wait & retry so the row still runs rather than being
// skipped — only this cheap check repeats. The waiting cell holds its
// concurrency slot, pacing the whole dispatch to the user's rate limit.
const rateLimiter = new RateLimiter()
for (let attempt = 1; ; attempt++) {
if (attemptSignal.aborted) return await writePacingAbortState()
const rl = await rateLimiter.checkRateLimitWithSubscription(
actorUserId,
preprocess.actorSubscription ?? null,
'workflow',
true
)
if (attemptSignal.aborted) return await writePacingAbortState()
if (rl.allowed) break
if (attempt >= RATE_LIMIT_MAX_ATTEMPTS) {
await writeState({
status: 'error',
executionId,
jobId: null,
workflowId,
error: 'Rate limit exceeded — please retry later',
})
return 'error'
}
// Exponential backoff WITH jitter — pass null, not the bucket's
// resetAt. That reset time is shared across all waiters, and
// backoffWithJitter clamps a non-null hint to a fixed value with no
// jitter, so honoring it would wake all ~20 concurrent cells in
// lockstep and stampede the bucket. Jittered backoff spreads retries.
const waitMs = backoffWithJitter(attempt, null)
logger.info(
`Rate limited — waiting ${Math.round(waitMs)}ms before retry ${attempt + 1} (table=${tableId} row=${rowId} group=${groupId})`
)
await sleep(waitMs)
if (attemptSignal.aborted) return await writePacingAbortState()
// Stop All can land mid-wait. On the trigger.dev backend `signal` never
// fires (cancelByKey is a no-op there), so re-check the DB tombstone and
// release this concurrency slot promptly instead of sleeping out the
// full retry budget.
const refreshed = await getRowById(tableId, rowId, workspaceId)
if (!refreshed || cancelledBeforeRun(refreshed.executions?.[groupId])) {
return refreshed ? 'cancelled' : 'error'
}
}
// SQL guard also rejects if a stop click stamped `cancelled` between this
// check and pickup.
const pickedUp = await markWorkflowGroupPickedUp(cellCtx, {
workflowId,
jobId: null,
})
if (pickedUp === 'skipped') return 'error'
// Output columns produced by THIS group are skipped on input — they're
// populated by the run we're starting. Other group's outputs ARE
// included (they're plain primitives in `row.data` thanks to the
// flattened schema).
// `inputRow` is name-keyed: the workflow author references columns by name
// in the Start block and downstream blocks, while stored `row.data` is
// id-keyed. Translate, skipping this group's own output columns.
// NOTE: `outputs[].columnName` / `inputMappings[].columnName` hold column
// **ids**, not names — a known misnomer (renaming it is a schema migration).
const ownOutputColumnIds = new Set(group.outputs.map((o) => o.columnName))
const inputColumns = table.schema.columns.filter(
(c) => !ownOutputColumnIds.has(getColumnId(c))
)
// One column list drives both the row and its headers so they cannot drift.
// The mapper also resolves select option ids to names — the workflow author
// sees "Open", not `opt_a1b2`.
const inputRow = fillMissingColumns(namedRowMapper(inputColumns)(row.data), inputColumns)
const headers = inputColumns.map((c) => c.name)
// When the group has explicit input mappings, feed the workflow's
// Start-block fields from the mapped columns (`inputName ← row[columnId]`).
// Otherwise fall back to spreading every non-output column by name, so a
// Start field still resolves when it matches a column name. `row`/`rawRow`
// always carry the full (name-keyed) row for downstream reference.
const inputMappings = group.inputMappings ?? []
const mappedInputs = mapInputValues(row.data, table.schema.columns, inputMappings)
const input = {
...(inputMappings.length > 0 ? mappedInputs : inputRow),
row: inputRow,
rawRow: inputRow,
previousRow: null,
changedColumns: [],
rowId,
headers,
tableId,
tableName,
timestamp: new Date().toISOString(),
}
const rowInputProvenance = await loadTableRowSecretProvenance(
[
{
id: row.id,
updatedAt: row.updatedAt,
selectedValues: Object.fromEntries(
inputColumns.map((column) => {
const columnId = getColumnId(column)
return [columnId, row.data[columnId]]
})
),
},
],
{ userId: workflowRecord.userId, workspaceId }
)
const inputRegistry = new ResolvedSecretTraceRegistry([], rowInputProvenance.scope)
await inputRegistry.importCrossingProvenance(rowInputProvenance, input, { trusted: true })
progressWriter = createWorkflowCellProgressWriter({
group,
signal: attemptSignal,
writeProgress: ({
dataPatch,
eventOutputs,
secretProvenance,
runningBlockIds,
blockErrors,
}) =>
writeState(
{
status: 'running',
executionId,
jobId: null,
workflowId,
error: null,
runningBlockIds,
blockErrors,
},
dataPatch,
eventOutputs,
secretProvenance
),
onWriteError: (err) => {
logger.warn(
`Per-block partial write failed (table=${tableId} row=${rowId} group=${groupId})`,
{ cause: describeError(err), retryable: isRetryableInfrastructureError(err) }
)
},
})
const result: Awaited<ReturnType<typeof executeWorkflow>> = await executeWorkflow(
{
id: workflowRecord.id,
// Workflow owner — drives personal env-var resolution + ownership.
userId: workflowRecord.userId,
workspaceId: workflowRecord.workspaceId,
variables: (workflowRecord.variables as Record<string, unknown> | null) ?? {},
},
requestId,
input,
// Billing/usage/rate actor — the workspace billed account.
actorUserId,
{
enabled: true,
executionMode: 'sync',
workflowTriggerType: 'table',
triggerBlockId: startBlock.id,
// `deployed` groups execute the latest active deployment; everything
// else runs the editable draft (the table default). Matches the
// state loaded above for start-block / output-block resolution.
useDraftState: deploymentMode !== 'deployed',
abortSignal: attemptSignal,
onBlockStart: progressWriter.onBlockStart,
onBlockComplete: progressWriter.onBlockComplete,
trustedInitialResolvedSecretTraceProvenance: inputRegistry.exportCheckpointProvenance(),
billingAttribution: preprocess.billingAttribution,
trustedExecutionCorrelation: buildWorkflowGroupExecutionCorrelation(payload),
},
executionId
)
await progressWriter.finish()
const eventOutputs = progressWriter.getEventOutputs()
const pendingDataPatch = progressWriter.getPendingDataPatch()
const blockErrors = progressWriter.getBlockErrors()
if (result.status === 'paused') {
await writeState(
{
status: 'pending',
executionId,
jobId: `paused-${executionId}`,
workflowId,
error: null,
runningBlockIds: [],
blockErrors,
},
pendingDataPatch,
eventOutputs,
progressWriter.getPendingSecretProvenance()
)
await stashCellContextForResume({
executionId,
tableId,
tableName,
rowId,
groupId,
workflowId,
workspaceId,
})
return 'paused'
}
const terminalResult = classifyWorkflowCellTerminalResult(result, {
timedOut: timeoutController.isTimedOut(),
timeoutMs: timeoutController.timeoutMs,
})
const terminalState =
terminalResult.status === 'cancelled'
? buildCancelledExecution({ executionId, workflowId, blockErrors })
: {
status: terminalResult.status,
executionId,
jobId: null,
workflowId,
error: terminalResult.error,
runningBlockIds: [],
blockErrors,
}
await writeState(
terminalState,
pendingDataPatch,
eventOutputs,
progressWriter.getPendingSecretProvenance()
)
return terminalResult.status
} catch (err) {
const message = toError(err).message
logger.error(
`Workflow group cell execution failed (table=${tableId} row=${rowId} group=${groupId})`,
{
error: message,
executionId,
cause: describeError(err),
retryable: isRetryableInfrastructureError(err),
}
)
await progressWriter?.finish()
try {
await writeState({
status: 'error',
executionId,
jobId: null,
workflowId,
error: message,
runningBlockIds: [],
blockErrors: progressWriter?.getBlockErrors() ?? {},
})
} catch (writeErr) {
logger.error('Also failed to write error state', {
error: toError(writeErr).message,
cause: describeError(writeErr),
retryable: isRetryableInfrastructureError(writeErr),
})
}
return 'error'
}
})
} finally {
timeoutController.cleanup()
}
}
export const workflowGroupCellTask = task({
id: 'workflow-group-cell',
maxDuration: timeout.None,
machine: 'medium-1x',
retry: { maxAttempts: 1 },
// Combined with `concurrencyKey: tableId`, caps each table's sub-queue of
// in-flight cell jobs while letting different tables run in parallel. The
// cap is the highest per-plan dispatch window so the queue never throttles
// below a plan's window — the dispatcher window is the real per-run limiter.
// Read at trigger.dev deploy time: raising a TABLE_DISPATCH_CONCURRENCY_*
// env var above the current max needs a trigger.dev redeploy to take effect.
queue: {
name: 'workflow-group-cell',
concurrencyLimit: getMaxTableDispatchConcurrency(),
},
run: (payload: QueuedWorkflowGroupCellPayload, { signal }) =>
executeWorkflowGroupCellJob(payload, signal),
})