Files
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

1188 lines
39 KiB
TypeScript

import { trace } from '@opentelemetry/api'
import {
db,
workflow,
workflowDeploymentVersion,
workflowExecutionLogs,
workflowSchedule,
} from '@sim/db'
import { createLogger, runWithRequestContext } from '@sim/logger'
import { describeError, toError } from '@sim/utils/errors'
import { generateId } from '@sim/utils/id'
import { task, timeout } from '@trigger.dev/sdk'
import { Cron } from 'croner'
import { and, eq, isNull, ne, type SQL, sql } from 'drizzle-orm'
import {
assertBillingAttributionSnapshot,
type BillingAttributionSnapshot,
} from '@/lib/billing/core/billing-attribution'
import { classifyTransientAdmissionFailure } from '@/lib/core/admission/transient-failure'
import type { AsyncExecutionCorrelation } from '@/lib/core/async-jobs/types'
import {
describeRetryableInfrastructureError,
isRetryableInfrastructureError,
} from '@/lib/core/errors/retryable-infrastructure'
import {
capExecutionTimeoutMs,
createTimeoutAbortController,
getAsyncExecutionTimeoutForBillingAttribution,
getExecutionDeadlineAt,
getTimeoutErrorMessage,
} from '@/lib/core/execution-limits'
import type { DbOrTx } from '@/lib/db/types'
import { preprocessExecution } from '@/lib/execution/preprocessing'
import { LoggingSession } from '@/lib/logs/execution/logging-session'
import { buildTraceSpans } from '@/lib/logs/execution/trace-spans/trace-spans'
import { cleanupExecutionBase64Cache } from '@/lib/uploads/utils/user-file-base64.server'
import {
executeWorkflowCore,
wasExecutionFinalizedByCore,
} from '@/lib/workflows/executor/execution-core'
import { handlePostExecutionPauseState } from '@/lib/workflows/executor/pause-persistence'
import { loadDeployedWorkflowState } from '@/lib/workflows/persistence/utils'
import { notifyScheduleAutoDisabled } from '@/lib/workflows/schedules/disable-notifications'
import type { ScheduleDisableReason } from '@/lib/workflows/schedules/disable-reasons'
import {
SCHEDULE_EXECUTION_CONCURRENCY_LIMIT,
SCHEDULE_EXECUTION_QUEUE_NAME,
SCHEDULE_INFRA_RETRY_MAX_ATTEMPTS,
} from '@/lib/workflows/schedules/execution-limits'
import { calculateScheduleInfraRetryDelayMs } from '@/lib/workflows/schedules/retry'
import {
type BlockState,
calculateNextRunTime as calculateNextTime,
getScheduleTimeValues,
getSubBlockValue,
} from '@/lib/workflows/schedules/utils'
import { ExecutionSnapshot } from '@/executor/execution/snapshot'
import type { ExecutionMetadata } from '@/executor/execution/types'
import { hasExecutionResult } from '@/executor/utils/errors'
import { MAX_CONSECUTIVE_FAILURES } from '@/triggers/constants'
const logger = createLogger('ScheduleExecution')
type WorkflowRecord = typeof workflow.$inferSelect
type WorkflowScheduleInsert = typeof workflowSchedule.$inferInsert
type WorkflowScheduleUpdate = Partial<Omit<WorkflowScheduleInsert, 'failedCount' | 'status'>> & {
failedCount?: WorkflowScheduleInsert['failedCount'] | SQL
status?: WorkflowScheduleInsert['status'] | SQL
}
type ExecutionCoreResult = Awaited<ReturnType<typeof executeWorkflowCore>>
/** Result of a guarded schedule UPDATE. `status` is the row's value after the write. */
type ScheduleUpdateOutcome = {
updated: boolean
status: string | null
}
function incrementScheduleFailedCount(): SQL {
return sql`COALESCE(${workflowSchedule.failedCount}, 0) + 1`
}
function scheduleStatusAfterFailedCountIncrement(): SQL {
return sql`CASE WHEN COALESCE(${workflowSchedule.failedCount}, 0) + 1 >= ${MAX_CONSECUTIVE_FAILURES} THEN 'disabled' ELSE 'active' END`
}
function resetScheduleInfraRetryCount(): Pick<WorkflowScheduleUpdate, 'infraRetryCount'> {
return { infraRetryCount: 0 }
}
/**
* Builds the schedule update shared by every path that treats a run as a failure:
* clears the claim, advances to `nextRunAt`, increments the consecutive-failure
* counter, stamps `lastFailedAt`, and auto-disables once `MAX_CONSECUTIVE_FAILURES`
* is reached. Centralizing this keeps all failure branches (preprocessing,
* execution, exhausted infra retries, usage limit) from diverging — only the
* `nextRunAt` cadence differs per caller.
*/
export function buildScheduleFailureUpdate(
now: Date,
nextRunAt: Date | null
): WorkflowScheduleUpdate {
return {
updatedAt: now,
lastQueuedAt: null,
nextRunAt,
failedCount: incrementScheduleFailedCount(),
lastFailedAt: now,
status: scheduleStatusAfterFailedCountIncrement(),
...resetScheduleInfraRetryCount(),
}
}
type RunWorkflowResult =
| {
status: 'skip'
reason: 'stale_deployment' | 'invalid_schedule' | 'stale_claim'
blocks: Record<string, BlockState>
}
| { status: 'success'; blocks: Record<string, BlockState>; executionResult: ExecutionCoreResult }
| {
status: 'cancelled'
blocks: Record<string, BlockState>
executionResult: ExecutionCoreResult
}
| { status: 'failure'; blocks: Record<string, BlockState>; executionResult: ExecutionCoreResult }
| {
status: 'retryable_setup_failure'
error: unknown
cause?: Record<string, unknown>
}
export function buildScheduleCorrelation(
payload: ScheduleExecutionPayload
): AsyncExecutionCorrelation {
const executionId = payload.executionId || generateId()
const requestId = payload.requestId || payload.correlation?.requestId || executionId.slice(0, 8)
return {
executionId,
requestId,
source: 'schedule',
workflowId: payload.workflowId,
scheduleId: payload.scheduleId,
triggerType: payload.correlation?.triggerType || 'schedule',
scheduledFor: payload.scheduledFor || payload.correlation?.scheduledFor,
}
}
export function classifyScheduleExecutionResult(
executionResult: { success: boolean; status?: string },
timedOut: boolean,
persistedStatus?: string | null
): 'success' | 'cancelled' | 'failure' {
if (persistedStatus === 'cancelled') return 'cancelled'
if (executionResult.success) return 'success'
if (executionResult.status === 'cancelled' && !timedOut) return 'cancelled'
return 'failure'
}
/** Advances cadence after user cancellation without mutating the failure counter. */
export function buildScheduleCancellationUpdate(
now: Date,
nextRunAt: Date
): WorkflowScheduleUpdate {
return {
lastRanAt: now,
updatedAt: now,
nextRunAt,
lastQueuedAt: null,
...resetScheduleInfraRetryCount(),
}
}
async function applyScheduleUpdate(
scheduleId: string,
updates: WorkflowScheduleUpdate,
requestId: string,
context: string,
options: {
expectedLastQueuedAt?: Date | null
/**
* Set at call sites that can transition the row to `disabled`. Presence both
* opts the site into the auto-disable email and adds a `status <> 'disabled'`
* guard, so the transition fires exactly once per disable.
*/
disableReason?: ScheduleDisableReason
/** Required inside a transaction, where mail must wait for commit. */
deferNotification?: boolean
/** Join a caller's transaction instead of using the pooled client. */
executor?: DbOrTx
} = {}
): Promise<ScheduleUpdateOutcome> {
let outcome: ScheduleUpdateOutcome
try {
const claimGuard =
options.expectedLastQueuedAt === undefined
? undefined
: options.expectedLastQueuedAt === null
? isNull(workflowSchedule.lastQueuedAt)
: eq(workflowSchedule.lastQueuedAt, options.expectedLastQueuedAt)
// Terminal means terminal: a completed row is never moved back to active
// with a fresh nextRunAt. The claim guard does not cover this on its own,
// because reaching 'completed' does not touch lastQueuedAt.
const notCompletedGuard = ne(workflowSchedule.status, 'completed')
/**
* `RETURNING` yields the NEW row, so `status === 'disabled'` alone only means
* "is disabled". Excluding rows that were already disabled makes a returned
* row a true `active -> disabled` edge. Scoped to disable-capable call sites
* so lock releases on already-disabled rows still work.
*/
const notAlreadyDisabled = options.disableReason
? ne(workflowSchedule.status, 'disabled')
: undefined
const updatedRows = await (options.executor ?? db)
.update(workflowSchedule)
.set(updates)
.where(
and(
eq(workflowSchedule.id, scheduleId),
isNull(workflowSchedule.archivedAt),
claimGuard,
notCompletedGuard,
notAlreadyDisabled
)
)
.returning({ id: workflowSchedule.id, status: workflowSchedule.status })
const row = updatedRows[0]
outcome = { updated: Boolean(row), status: row?.status ?? null }
} catch (error) {
logger.error(`[${requestId}] ${context}`, error, { cause: describeError(error) })
throw error
}
// Outside the try: a mail failure must never surface as a schedule-tick fault.
if (options.disableReason && !options.deferNotification && outcome.status === 'disabled') {
await notifyScheduleAutoDisabled({
scheduleId,
reason: options.disableReason,
requestId,
})
}
return outcome
}
export async function releaseScheduleLock(
scheduleId: string,
requestId: string,
now: Date,
context: string,
nextRunAt?: Date | null,
options: { expectedLastQueuedAt?: Date | null } = {}
): Promise<boolean> {
const updates: WorkflowScheduleUpdate = {
updatedAt: now,
lastQueuedAt: null,
}
if (nextRunAt) {
updates.nextRunAt = nextRunAt
}
const outcome = await applyScheduleUpdate(scheduleId, updates, requestId, context, options)
return outcome.updated
}
/**
* Applies {@link buildScheduleFailureUpdate} through the same guarded write the
* trigger.dev path uses, and reports whether the row just transitioned to
* `disabled`. Callers own the notification so an in-transaction caller can defer
* it until after commit.
*/
export async function applyScheduleFailureUpdate(params: {
scheduleId: string
now: Date
nextRunAt: Date | null
expectedLastQueuedAt: Date
requestId: string
context: string
executor?: DbOrTx
}): Promise<{ updated: boolean; disabled: boolean }> {
const { scheduleId, now, nextRunAt, expectedLastQueuedAt, requestId, context, executor } = params
const outcome = await applyScheduleUpdate(
scheduleId,
buildScheduleFailureUpdate(now, nextRunAt),
requestId,
context,
{
expectedLastQueuedAt,
disableReason: 'consecutive_failures',
deferNotification: true,
executor,
}
)
return { updated: outcome.updated, disabled: outcome.status === 'disabled' }
}
function getScheduleClaimedAt(payload: ScheduleExecutionPayload): Date | null {
const claimedAt = new Date(payload.now)
return Number.isNaN(claimedAt.getTime()) ? null : claimedAt
}
async function retryScheduleAfterInfraFailure({
payload,
requestId,
claimedAt,
error,
message,
cause,
}: {
payload: ScheduleExecutionPayload
requestId: string
claimedAt: Date | null
error?: unknown
message?: string
cause?: Record<string, unknown>
}) {
const now = new Date()
const retryAttempt = (payload.infraRetryCount || 0) + 1
if (retryAttempt > SCHEDULE_INFRA_RETRY_MAX_ATTEMPTS) {
logger.error(`[${requestId}] Retryable infrastructure failures exhausted for schedule`, {
scheduleId: payload.scheduleId,
workflowId: payload.workflowId,
retryAttempt,
maxAttempts: SCHEDULE_INFRA_RETRY_MAX_ATTEMPTS,
cause: cause ?? describeRetryableInfrastructureError(error),
})
const nextRunAt = await determineNextRunAfterError(payload, now, requestId)
await applyScheduleUpdate(
payload.scheduleId,
buildScheduleFailureUpdate(now, nextRunAt),
requestId,
`Error updating schedule ${payload.scheduleId} after exhausted infrastructure retries`,
{ expectedLastQueuedAt: claimedAt, disableReason: 'consecutive_failures' }
)
return
}
const retryDelayMs = calculateScheduleInfraRetryDelayMs(retryAttempt)
const nextRetryAt = new Date(now.getTime() + retryDelayMs)
const failureCause = cause ?? describeRetryableInfrastructureError(error)
const errorMessage = message ?? (error ? toError(error).message : undefined)
logger.warn(`[${requestId}] Retryable infrastructure failure during scheduled setup`, {
scheduleId: payload.scheduleId,
workflowId: payload.workflowId,
retryAttempt,
error: errorMessage,
retryDelayMs,
nextRetryAt: nextRetryAt.toISOString(),
cause: failureCause,
})
await applyScheduleUpdate(
payload.scheduleId,
{
updatedAt: now,
nextRunAt: nextRetryAt,
lastQueuedAt: null,
infraRetryCount: retryAttempt,
},
requestId,
`Error updating schedule ${payload.scheduleId} after retryable infrastructure failure`,
{ expectedLastQueuedAt: claimedAt }
)
}
async function calculateNextRunFromDeployment(
payload: ScheduleExecutionPayload,
requestId: string
) {
try {
const deployedData = await loadDeployedWorkflowState(payload.workflowId)
return calculateNextRunTime(payload, deployedData.blocks as Record<string, BlockState>)
} catch (error) {
logger.warn(
`[${requestId}] Unable to calculate nextRunAt for schedule ${payload.scheduleId}`,
error
)
return null
}
}
async function determineNextRunAfterError(
payload: ScheduleExecutionPayload,
now: Date,
requestId: string
) {
try {
const [workflowRecord] = await db
.select()
.from(workflow)
.where(eq(workflow.id, payload.workflowId))
.limit(1)
if (workflowRecord?.isDeployed) {
const nextRunAt = await calculateNextRunFromDeployment(payload, requestId)
if (nextRunAt) {
return nextRunAt
}
}
} catch (workflowError) {
logger.error(`[${requestId}] Error retrieving workflow for next run calculation`, workflowError)
}
return new Date(now.getTime() + 24 * 60 * 60 * 1000)
}
async function isScheduleDeploymentVersionActive(
workflowId: string,
deploymentVersionId: string
): Promise<boolean> {
const [activeDeployment] = await db
.select({ id: workflowDeploymentVersion.id })
.from(workflowDeploymentVersion)
.where(
and(
eq(workflowDeploymentVersion.workflowId, workflowId),
eq(workflowDeploymentVersion.id, deploymentVersionId),
eq(workflowDeploymentVersion.isActive, true)
)
)
.limit(1)
return Boolean(activeDeployment)
}
async function isScheduleClaimCurrent(
scheduleId: string,
claimedAt: Date | null,
deploymentOperationId?: string
): Promise<boolean> {
if (!claimedAt && !deploymentOperationId) return true
const [scheduleRecord] = await db
.select({
lastQueuedAt: workflowSchedule.lastQueuedAt,
deploymentOperationId: workflowSchedule.deploymentOperationId,
})
.from(workflowSchedule)
.where(and(eq(workflowSchedule.id, scheduleId), isNull(workflowSchedule.archivedAt)))
.limit(1)
if (!scheduleRecord) return false
if (claimedAt && scheduleRecord.lastQueuedAt?.getTime() !== claimedAt.getTime()) return false
return scheduleRecord.deploymentOperationId === (deploymentOperationId ?? null)
}
async function runWorkflowExecution({
payload,
correlation,
workflowRecord,
actorUserId,
billingAttribution,
loggingSession,
requestId,
executionId,
timeoutController,
}: {
payload: ScheduleExecutionPayload
correlation: AsyncExecutionCorrelation
workflowRecord: WorkflowRecord
actorUserId: string
billingAttribution: BillingAttributionSnapshot
loggingSession: LoggingSession
requestId: string
executionId: string
timeoutController: ReturnType<typeof createTimeoutAbortController>
}): Promise<RunWorkflowResult> {
let workflowCoreStarted = false
try {
const deployedData = await loadDeployedWorkflowState(
payload.workflowId,
workflowRecord.workspaceId ?? undefined
)
const blocks = deployedData.blocks
const { deploymentVersionId } = deployedData
if (payload.deploymentVersionId && deploymentVersionId !== payload.deploymentVersionId) {
logger.info(`[${requestId}] Loaded deployment no longer matches queued schedule, skipping`, {
scheduleId: payload.scheduleId,
workflowId: payload.workflowId,
queuedDeploymentVersionId: payload.deploymentVersionId,
loadedDeploymentVersionId: deploymentVersionId,
})
return {
status: 'skip',
reason: 'stale_deployment',
blocks: {} as Record<string, BlockState>,
}
}
logger.info(`[${requestId}] Loaded deployed workflow ${payload.workflowId}`)
if (payload.blockId) {
if (!blocks[payload.blockId]) {
logger.warn(
`[${requestId}] Schedule trigger block ${payload.blockId} not found in deployed workflow ${payload.workflowId}. Skipping execution.`
)
return {
status: 'skip',
reason: 'invalid_schedule',
blocks: {} as Record<string, BlockState>,
}
}
}
const workspaceId = workflowRecord.workspaceId
if (!workspaceId) {
throw new Error(`Workflow ${payload.workflowId} has no associated workspace`)
}
const input = {
_context: {
workflowId: payload.workflowId,
},
}
const metadata: ExecutionMetadata = {
requestId,
executionId,
workflowId: payload.workflowId,
workspaceId,
userId: actorUserId,
billingAttribution,
sessionUserId: undefined,
workflowUserId: workflowRecord.userId,
triggerType: 'schedule',
triggerBlockId: payload.blockId || undefined,
useDraftState: false,
workflowStateOverride: {
blocks: deployedData.blocks,
edges: deployedData.edges,
loops: deployedData.loops,
parallels: deployedData.parallels,
deploymentVersionId,
},
startTime: new Date().toISOString(),
isClientSession: false,
correlation,
}
const snapshot = new ExecutionSnapshot(
metadata,
workflowRecord,
input,
workflowRecord.variables || {},
[]
)
let executionResult
if (
payload.deploymentVersionId &&
!(await isScheduleDeploymentVersionActive(payload.workflowId, payload.deploymentVersionId))
) {
logger.info(`[${requestId}] Schedule deployment changed before execution, skipping`, {
scheduleId: payload.scheduleId,
workflowId: payload.workflowId,
deploymentVersionId: payload.deploymentVersionId,
})
return {
status: 'skip',
reason: 'stale_deployment',
blocks: {} as Record<string, BlockState>,
}
}
const claimedAt = getScheduleClaimedAt(payload)
if (
!(await isScheduleClaimCurrent(payload.scheduleId, claimedAt, payload.deploymentOperationId))
) {
logger.info(`[${requestId}] Schedule claim changed before workflow core started, skipping`, {
scheduleId: payload.scheduleId,
workflowId: payload.workflowId,
claimedAt: claimedAt?.toISOString(),
})
return {
status: 'skip',
reason: 'stale_claim',
blocks: {} as Record<string, BlockState>,
}
}
workflowCoreStarted = true
executionResult = await executeWorkflowCore({
snapshot,
callbacks: {},
loggingSession,
includeFileBase64: true,
base64MaxBytes: undefined,
abortSignal: timeoutController.signal,
})
const timeoutMs = timeoutController.timeoutMs
const timedOut =
executionResult.status === 'cancelled' &&
timeoutController.isTimedOut() &&
timeoutMs !== undefined
if (timedOut) {
const timeoutErrorMessage = getTimeoutErrorMessage(null, timeoutMs)
logger.info(`[${requestId}] Scheduled workflow execution timed out`, {
timeoutMs,
})
await loggingSession.markAsFailed(timeoutErrorMessage)
} else {
await handlePostExecutionPauseState({
result: executionResult,
workflowId: payload.workflowId,
executionId,
loggingSession,
})
}
await loggingSession.waitForPostExecution()
const [persistedExecution] = await db
.select({ status: workflowExecutionLogs.status })
.from(workflowExecutionLogs)
.where(
and(
eq(workflowExecutionLogs.executionId, executionId),
eq(workflowExecutionLogs.workflowId, payload.workflowId)
)
)
.limit(1)
logger.info(`[${requestId}] Workflow execution completed: ${payload.workflowId}`, {
success: executionResult.success,
executionTime: executionResult.metadata?.duration,
})
return {
status: classifyScheduleExecutionResult(
executionResult,
timedOut,
persistedExecution?.status
),
blocks,
executionResult,
}
} catch (error: unknown) {
if (!workflowCoreStarted && isRetryableInfrastructureError(error)) {
const cause = describeRetryableInfrastructureError(error)
logger.warn(`[${requestId}] Retryable setup failure before scheduled workflow started`, {
scheduleId: payload.scheduleId,
workflowId: payload.workflowId,
cause,
})
return {
status: 'retryable_setup_failure',
error,
cause,
}
}
if (wasExecutionFinalizedByCore(error, executionId)) {
throw error
}
const executionResult = hasExecutionResult(error) ? error.executionResult : undefined
const { traceSpans } = executionResult ? buildTraceSpans(executionResult) : { traceSpans: [] }
await loggingSession.safeCompleteWithError({
error: {
message: toError(error).message,
stackTrace: error instanceof Error ? error.stack : undefined,
},
traceSpans,
executionState: executionResult?.executionState,
})
throw error
} finally {
void cleanupExecutionBase64Cache(executionId)
}
}
export type ScheduleExecutionPayload = {
scheduleId: string
workflowId: string
workspaceId: string
billingAttribution: BillingAttributionSnapshot
executionId?: string
requestId?: string
correlation?: AsyncExecutionCorrelation
blockId?: string
deploymentVersionId?: string
deploymentOperationId?: string
cronExpression?: string
timezone?: string
lastRanAt?: string
failedCount?: number
infraRetryCount?: number
now: string
scheduledFor?: string
/** Trusted attempt budget resolved before the schedule enters the queue. */
executionTimeoutMs?: number
}
function calculateNextRunTime(
schedule: { cronExpression?: string; lastRanAt?: string },
blocks: Record<string, BlockState>
): Date {
const scheduleBlock = Object.values(blocks).find(
(block) => block.type === 'starter' || block.type === 'schedule'
)
if (!scheduleBlock) throw new Error('No starter or schedule block found')
const scheduleType = getSubBlockValue(scheduleBlock, 'scheduleType')
const scheduleValues = getScheduleTimeValues(scheduleBlock)
const timezone = scheduleValues.timezone || 'UTC'
if (schedule.cronExpression) {
const cron = new Cron(schedule.cronExpression, {
timezone,
})
const nextDate = cron.nextRun()
if (!nextDate) throw new Error('Invalid cron expression or no future occurrences')
return nextDate
}
return calculateNextTime(scheduleType, scheduleValues)
}
export async function executeScheduleJob(
payload: ScheduleExecutionPayload,
externalAbortSignal?: AbortSignal
) {
const payloadBillingAttribution = assertBillingAttributionSnapshot(payload.billingAttribution)
if (payloadBillingAttribution.workspaceId !== payload.workspaceId) {
throw new Error('Schedule job billing attribution does not match its workspace')
}
const timeoutController = createTimeoutAbortController(
capExecutionTimeoutMs(
getAsyncExecutionTimeoutForBillingAttribution(payloadBillingAttribution),
payload.executionTimeoutMs
),
externalAbortSignal
)
const correlation = buildScheduleCorrelation(payload)
const executionId = correlation.executionId
const requestId = correlation.requestId
const claimedAt = getScheduleClaimedAt(payload)
const now = new Date()
const scheduledFor = payload.scheduledFor ? new Date(payload.scheduledFor) : null
try {
return await runWithRequestContext({ requestId }, async () => {
logger.info(`[${requestId}] Starting schedule execution`, {
scheduleId: payload.scheduleId,
workflowId: payload.workflowId,
executionId,
scheduledFor: scheduledFor?.toISOString(),
claimedAt: claimedAt?.toISOString(),
})
const releaseClaim = (
releaseNow: Date,
context: string,
nextRunAt?: Date | null
): Promise<boolean> =>
releaseScheduleLock(payload.scheduleId, requestId, releaseNow, context, nextRunAt, {
expectedLastQueuedAt: claimedAt,
})
const updateClaimedSchedule = (
updates: WorkflowScheduleUpdate,
context: string,
disableReason?: ScheduleDisableReason
): Promise<ScheduleUpdateOutcome> =>
applyScheduleUpdate(payload.scheduleId, updates, requestId, context, {
expectedLastQueuedAt: claimedAt,
disableReason,
})
try {
const [scheduleRecord] = await db
.select({
id: workflowSchedule.id,
workflowId: workflowSchedule.workflowId,
deploymentVersionId: workflowSchedule.deploymentVersionId,
status: workflowSchedule.status,
archivedAt: workflowSchedule.archivedAt,
lastQueuedAt: workflowSchedule.lastQueuedAt,
})
.from(workflowSchedule)
.where(eq(workflowSchedule.id, payload.scheduleId))
.limit(1)
if (!scheduleRecord) {
logger.info(`[${requestId}] Schedule no longer exists, skipping execution`, {
scheduleId: payload.scheduleId,
})
return
}
if (
claimedAt &&
(!scheduleRecord.lastQueuedAt ||
scheduleRecord.lastQueuedAt.getTime() !== claimedAt.getTime())
) {
logger.info(
`[${requestId}] Schedule claim no longer matches payload, skipping execution`,
{
scheduleId: payload.scheduleId,
claimedAt: claimedAt.toISOString(),
currentLastQueuedAt: scheduleRecord.lastQueuedAt?.toISOString(),
}
)
return
}
if (scheduleRecord.archivedAt || scheduleRecord.status === 'disabled') {
logger.info(`[${requestId}] Schedule is archived or disabled, skipping execution`, {
scheduleId: payload.scheduleId,
})
await releaseClaim(
now,
`Failed to release schedule ${payload.scheduleId} after archive/disabled check`
)
return
}
const expectedDeploymentVersionId =
payload.deploymentVersionId ?? scheduleRecord.deploymentVersionId ?? undefined
if (expectedDeploymentVersionId) {
const [activeDeployment] = await db
.select({ id: workflowDeploymentVersion.id })
.from(workflowDeploymentVersion)
.where(
and(
eq(workflowDeploymentVersion.workflowId, payload.workflowId),
eq(workflowDeploymentVersion.id, expectedDeploymentVersionId),
eq(workflowDeploymentVersion.isActive, true)
)
)
.limit(1)
if (!activeDeployment) {
logger.info(
`[${requestId}] Schedule deployment version is no longer active, skipping`,
{
scheduleId: payload.scheduleId,
workflowId: payload.workflowId,
deploymentVersionId: expectedDeploymentVersionId,
}
)
await releaseClaim(
now,
`Failed to release stale deployment schedule ${payload.scheduleId}`
)
return
}
}
const loggingSession = new LoggingSession(
payload.workflowId,
executionId,
'schedule',
requestId
)
loggingSession.setExecutionDeadlineAt(getExecutionDeadlineAt(timeoutController.signal))
const preprocessResult = await preprocessExecution({
workflowId: payload.workflowId,
userId: 'unknown', // Will be resolved from workflow record
triggerType: 'schedule',
executionId,
requestId,
checkRateLimit: true,
checkDeployment: true,
loggingSession,
triggerData: { correlation },
billingAttribution: payloadBillingAttribution,
executionType: 'async',
executionDeadlineAt: getExecutionDeadlineAt(timeoutController.signal)?.getTime(),
})
if (!preprocessResult.success) {
const preprocessingError = preprocessResult.error
const statusCode = preprocessingError.statusCode
const transientAdmissionFailure = classifyTransientAdmissionFailure(preprocessingError)
if (transientAdmissionFailure) {
await retryScheduleAfterInfraFailure({
payload,
requestId,
claimedAt,
message: preprocessingError.message,
cause: preprocessingError.cause,
})
return
}
switch (statusCode) {
case 401: {
logger.warn(
`[${requestId}] Authentication error during preprocessing, disabling schedule`
)
await updateClaimedSchedule(
{
updatedAt: now,
lastQueuedAt: null,
lastFailedAt: now,
status: 'disabled',
...resetScheduleInfraRetryCount(),
},
`Failed to disable schedule ${payload.scheduleId} after authentication error`,
'authentication_error'
)
return
}
case 403: {
logger.warn(
`[${requestId}] Authorization error during preprocessing, disabling schedule: ${preprocessingError.message}`
)
await updateClaimedSchedule(
{
updatedAt: now,
lastQueuedAt: null,
lastFailedAt: now,
status: 'disabled',
...resetScheduleInfraRetryCount(),
},
`Failed to disable schedule ${payload.scheduleId} after authorization error`,
'authorization_error'
)
return
}
case 404: {
logger.warn(`[${requestId}] Workflow not found, disabling schedule`)
await updateClaimedSchedule(
{
updatedAt: now,
lastQueuedAt: null,
status: 'disabled',
...resetScheduleInfraRetryCount(),
},
`Failed to disable schedule ${payload.scheduleId} after missing workflow`,
'workflow_not_found'
)
return
}
case 429: {
logger.warn(`[${requestId}] Rate limit exceeded, scheduling retry`)
const retryDelay = 5 * 60 * 1000
const nextRetryAt = new Date(now.getTime() + retryDelay)
await updateClaimedSchedule(
{
updatedAt: now,
nextRunAt: nextRetryAt,
lastQueuedAt: null,
...resetScheduleInfraRetryCount(),
},
`Error updating schedule ${payload.scheduleId} for rate limit`
)
return
}
case 402: {
/**
* Usage limits are a billing state, not a broken workflow, but they only
* clear on billing-period rollover or upgrade. Keep retrying at the normal
* cadence, but count each hit toward the shared auto-disable threshold so an
* abandoned over-limit schedule eventually stops instead of running forever.
* A successful run resets failedCount, so transient overages self-heal.
*/
const nextRunAt =
(await calculateNextRunFromDeployment(payload, requestId)) ??
new Date(now.getTime() + 60 * 60 * 1000)
logger.warn(`[${requestId}] Usage limit exceeded, counting as failed run`, {
scheduleId: payload.scheduleId,
nextRunAt: nextRunAt.toISOString(),
})
await updateClaimedSchedule(
buildScheduleFailureUpdate(now, nextRunAt),
`Error updating schedule ${payload.scheduleId} after usage limit check`,
'consecutive_failures'
)
return
}
default: {
if (statusCode >= 500 && preprocessingError.retryable) {
await retryScheduleAfterInfraFailure({
payload,
requestId,
claimedAt,
message: preprocessingError.message,
cause: preprocessingError.cause,
})
return
}
logger.error(`[${requestId}] Preprocessing failed: ${preprocessingError.message}`)
const nextRunAt = await determineNextRunAfterError(payload, now, requestId)
await updateClaimedSchedule(
buildScheduleFailureUpdate(now, nextRunAt),
`Error updating schedule ${payload.scheduleId} after preprocessing failure`,
'consecutive_failures'
)
return
}
}
}
const { actorUserId, billingAttribution, workflowRecord } = preprocessResult
if (!actorUserId || !billingAttribution || !workflowRecord) {
logger.error(`[${requestId}] Missing required preprocessing data`)
await releaseClaim(
now,
`Failed to release schedule ${payload.scheduleId} after missing preprocessing data`
)
return
}
if (!workflowRecord.workspaceId) {
throw new Error(`Workflow ${payload.workflowId} has no associated workspace`)
}
logger.info(`[${requestId}] Executing scheduled workflow ${payload.workflowId}`)
try {
const executionResult = await runWorkflowExecution({
payload,
correlation,
workflowRecord,
actorUserId,
billingAttribution,
loggingSession,
requestId,
executionId,
timeoutController,
})
if (executionResult.status === 'retryable_setup_failure') {
await retryScheduleAfterInfraFailure({
payload,
requestId,
claimedAt,
error: executionResult.error,
cause: executionResult.cause,
})
return
}
if (executionResult.status === 'skip') {
if (executionResult.reason === 'stale_deployment') {
await releaseClaim(
now,
`Failed to release stale schedule ${payload.scheduleId} after deployment version changed`
)
return
}
if (executionResult.reason === 'stale_claim') {
return
}
await updateClaimedSchedule(
{
updatedAt: now,
lastQueuedAt: null,
lastFailedAt: now,
status: 'disabled',
nextRunAt: null,
...resetScheduleInfraRetryCount(),
},
`Failed to disable schedule ${payload.scheduleId} after skip`,
'invalid_schedule'
)
return
}
if (executionResult.status === 'success') {
logger.info(`[${requestId}] Workflow ${payload.workflowId} executed successfully`)
const nextRunAt = calculateNextRunTime(payload, executionResult.blocks)
await updateClaimedSchedule(
{
lastRanAt: now,
updatedAt: now,
nextRunAt,
failedCount: 0,
lastQueuedAt: null,
...resetScheduleInfraRetryCount(),
},
`Error updating schedule ${payload.scheduleId} after success`
)
return
}
if (executionResult.status === 'cancelled') {
logger.info(`[${requestId}] Workflow ${payload.workflowId} execution was cancelled`)
const nextRunAt = calculateNextRunTime(payload, executionResult.blocks)
await updateClaimedSchedule(
buildScheduleCancellationUpdate(now, nextRunAt),
`Error updating schedule ${payload.scheduleId} after cancellation`
)
return
}
logger.warn(`[${requestId}] Workflow ${payload.workflowId} execution failed`)
const nextRunAt = calculateNextRunTime(payload, executionResult.blocks)
await updateClaimedSchedule(
buildScheduleFailureUpdate(now, nextRunAt),
`Error updating schedule ${payload.scheduleId} after failure`,
'consecutive_failures'
)
} catch (error: unknown) {
logger.error(
`[${requestId}] Error executing scheduled workflow ${payload.workflowId}`,
loggingSession.projectDiagnosticError(error)
)
const nextRunAt = await determineNextRunAfterError(payload, now, requestId)
await updateClaimedSchedule(
buildScheduleFailureUpdate(now, nextRunAt),
`Error updating schedule ${payload.scheduleId} after execution error`,
'consecutive_failures'
)
}
} catch (error: unknown) {
try {
if (isRetryableInfrastructureError(error)) {
await retryScheduleAfterInfraFailure({ payload, requestId, claimedAt, error })
return
}
logger.error(`[${requestId}] Error processing schedule ${payload.scheduleId}`, error, {
cause: describeError(error),
})
await releaseClaim(
now,
`Failed to release schedule ${payload.scheduleId} after unhandled error`
)
} catch (recoveryError: unknown) {
// A secondary failure during error recovery (e.g. a transient DB blip while
// releasing the claim or scheduling an infra retry) must not fault the run. The
// claim expires on its TTL and the next tick re-claims the schedule. Record the
// exception on the span so it stays visible in traces without faulting the run.
logger.error(
`[${requestId}] Failed to recover schedule ${payload.scheduleId} after error`,
recoveryError
)
trace.getActiveSpan()?.recordException(toError(recoveryError))
}
}
})
} finally {
timeoutController.cleanup()
}
}
export const scheduleExecutionTaskOptions = {
id: 'schedule-execution',
maxDuration: timeout.None,
machine: 'medium-2x' as const,
retry: {
maxAttempts: 1,
},
queue: {
name: SCHEDULE_EXECUTION_QUEUE_NAME,
concurrencyLimit: SCHEDULE_EXECUTION_CONCURRENCY_LIMIT,
},
run: async (payload: ScheduleExecutionPayload, { signal }: { signal: AbortSignal }) =>
executeScheduleJob(payload, signal),
}
export const scheduleExecution = task(scheduleExecutionTaskOptions)