Files
wehub-resource-sync d25d482dc2
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
Publish CLI Package / publish-npm (push) Has been cancelled
Publish Python SDK / publish-pypi (push) Has been cancelled
Publish TypeScript SDK / publish-npm (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:20:55 +08:00

1182 lines
36 KiB
TypeScript

import { asyncJobs, db, workflow, workflowDeploymentVersion, workflowSchedule } from '@sim/db'
import { createLogger } from '@sim/logger'
import { sha256Hex } from '@sim/security/hash'
import { toError } from '@sim/utils/errors'
import { sleep } from '@sim/utils/helpers'
import { generateId } from '@sim/utils/id'
import { randomInt } from '@sim/utils/random'
import { backoffWithJitter } from '@sim/utils/retry'
import { Cron } from 'croner'
import { and, asc, eq, inArray, isNull, lt, lte, or, sql } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import type { ExecuteSchedulesResponse } from '@/lib/api/contracts/schedules'
import { verifyCronAuth } from '@/lib/auth/internal'
import { getJobQueue, shouldExecuteInline } from '@/lib/core/async-jobs'
import { JOB_STATUS, type Job } from '@/lib/core/async-jobs/types'
import { isRetryableInfrastructureError } from '@/lib/core/errors/retryable-infrastructure'
import { getMaxExecutionTimeout } from '@/lib/core/execution-limits'
import { runDetached } from '@/lib/core/utils/background'
import { generateRequestId } from '@/lib/core/utils/request'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import {
SCHEDULE_EXECUTION_CONCURRENCY_LIMIT,
SCHEDULE_EXECUTION_QUEUE_NAME,
SCHEDULE_INFRA_RETRY_BASE_MS,
SCHEDULE_INFRA_RETRY_MAX_ATTEMPTS,
SCHEDULE_INFRA_RETRY_MAX_MS,
SCHEDULE_JITTER_MAX_MS,
SCHEDULE_WORKFLOW_ENQUEUE_LIMIT,
} from '@/lib/workflows/schedules/execution-limits'
import {
buildScheduleFailureUpdate,
executeJobInline,
executeScheduleJob,
releaseScheduleLock,
type ScheduleExecutionPayload,
} from '@/background/schedule-execution'
export const dynamic = 'force-dynamic'
export const maxDuration = 3600
const logger = createLogger('ScheduledExecuteAPI')
const WORKFLOW_CHUNK_SIZE = 100
const JOB_CHUNK_SIZE = 100
const MAX_TICK_DURATION_MS = 3 * 60 * 1000
const STALE_SCHEDULE_CLAIM_MS = getMaxExecutionTimeout()
const STALE_SCHEDULE_RECOVERY_BATCH_SIZE = 100
const DATABASE_SCHEDULE_START_TURN_WAIT_MS = 1_000
type DatabaseScheduleStartResult = 'started' | 'capacity_full' | 'not_pending'
let databaseScheduleStartTurn: Promise<void> | null = null
const dueFilter = (queuedAt: Date) =>
and(
isNull(workflowSchedule.archivedAt),
lte(workflowSchedule.nextRunAt, queuedAt),
sql`${workflowSchedule.status} NOT IN ('disabled', 'completed')`,
or(
isNull(workflowSchedule.lastQueuedAt),
lt(workflowSchedule.lastQueuedAt, workflowSchedule.nextRunAt),
lt(workflowSchedule.lastQueuedAt, new Date(queuedAt.getTime() - STALE_SCHEDULE_CLAIM_MS))
)
)
const activeWorkflowDeploymentFilter = () =>
sql`${workflowSchedule.deploymentVersionId} = (select ${workflowDeploymentVersion.id} from ${workflowDeploymentVersion} where ${workflowDeploymentVersion.workflowId} = ${workflowSchedule.workflowId} and ${workflowDeploymentVersion.isActive} = true)`
const workflowScheduleFilter = (queuedAt: Date) =>
and(
dueFilter(queuedAt),
sql`(${workflowSchedule.sourceType} = 'workflow' OR ${workflowSchedule.sourceType} IS NULL)`,
activeWorkflowDeploymentFilter()
)
const jobScheduleFilter = (queuedAt: Date) =>
and(dueFilter(queuedAt), sql`${workflowSchedule.sourceType} = 'job'`)
async function runWithDatabaseScheduleStartTurn(
operation: () => Promise<DatabaseScheduleStartResult>
): Promise<DatabaseScheduleStartResult> {
const activeTurn = databaseScheduleStartTurn
if (activeTurn) {
const turnOpened = await Promise.race([
activeTurn.then(() => true),
sleep(DATABASE_SCHEDULE_START_TURN_WAIT_MS).then(() => false),
])
if (!turnOpened || databaseScheduleStartTurn) return 'capacity_full'
}
let releaseTurn = () => {}
const currentTurn = new Promise<void>((resolve) => {
releaseTurn = resolve
})
databaseScheduleStartTurn = currentTurn
try {
return await operation()
} finally {
if (databaseScheduleStartTurn === currentTurn) {
databaseScheduleStartTurn = null
}
releaseTurn()
}
}
function buildScheduleExecutionJobId(schedule: {
id: string
nextRunAt?: Date | null
lastQueuedAt?: Date | null
}): string {
const occurrence =
schedule.nextRunAt?.toISOString() ?? schedule.lastQueuedAt?.toISOString() ?? 'due'
return `schedule_${sha256Hex(`${schedule.id}:${occurrence}`).slice(0, 32)}`
}
function getNextRunFromCronExpression(
cronExpression?: string | null,
timezone = 'UTC'
): Date | null {
if (!cronExpression) return null
const cron = new Cron(cronExpression, { timezone })
return cron.nextRun()
}
async function claimWorkflowSchedules(queuedAt: Date, limit: number) {
if (limit <= 0) return []
return db.transaction(async (tx) => {
const rows = await tx
.select({
id: workflowSchedule.id,
workspaceId: workflow.workspaceId,
})
.from(workflowSchedule)
.innerJoin(workflow, eq(workflowSchedule.workflowId, workflow.id))
.where(workflowScheduleFilter(queuedAt))
.for('update', { skipLocked: true })
.limit(limit)
if (rows.length === 0) return []
const workspaceIdsByScheduleId = new Map(rows.map((row) => [row.id, row.workspaceId]))
const claimedRows = await tx
.update(workflowSchedule)
.set({ lastQueuedAt: queuedAt, updatedAt: queuedAt })
.where(
and(
workflowScheduleFilter(queuedAt),
inArray(
workflowSchedule.id,
rows.map((row) => row.id)
)
)
)
.returning({
id: workflowSchedule.id,
workflowId: workflowSchedule.workflowId,
blockId: workflowSchedule.blockId,
cronExpression: workflowSchedule.cronExpression,
lastRanAt: workflowSchedule.lastRanAt,
failedCount: workflowSchedule.failedCount,
infraRetryCount: workflowSchedule.infraRetryCount,
nextRunAt: workflowSchedule.nextRunAt,
lastQueuedAt: workflowSchedule.lastQueuedAt,
timezone: workflowSchedule.timezone,
deploymentVersionId: workflowSchedule.deploymentVersionId,
sourceType: workflowSchedule.sourceType,
})
return claimedRows.map((row) => ({
...row,
workspaceId: workspaceIdsByScheduleId.get(row.id) ?? null,
}))
})
}
async function claimJobSchedules(queuedAt: Date, limit: number) {
if (limit <= 0) return []
return db.transaction(async (tx) => {
const rows = await tx
.select({ id: workflowSchedule.id })
.from(workflowSchedule)
.where(jobScheduleFilter(queuedAt))
.for('update', { skipLocked: true })
.limit(limit)
if (rows.length === 0) return []
return tx
.update(workflowSchedule)
.set({ lastQueuedAt: queuedAt, updatedAt: queuedAt })
.where(
and(
jobScheduleFilter(queuedAt),
inArray(
workflowSchedule.id,
rows.map((row) => row.id)
)
)
)
.returning({
id: workflowSchedule.id,
cronExpression: workflowSchedule.cronExpression,
timezone: workflowSchedule.timezone,
failedCount: workflowSchedule.failedCount,
lastQueuedAt: workflowSchedule.lastQueuedAt,
sourceType: workflowSchedule.sourceType,
})
})
}
type ClaimedSchedule = Awaited<ReturnType<typeof claimWorkflowSchedules>>[number]
type ClaimedJob = Awaited<ReturnType<typeof claimJobSchedules>>[number]
type JobQueue = Awaited<ReturnType<typeof getJobQueue>>
type DatabaseScheduleExecutionTarget = Pick<
ClaimedSchedule,
'id' | 'workflowId' | 'cronExpression' | 'timezone'
>
function getSchedulePayloadFromValue(payload: unknown): ScheduleExecutionPayload | null {
if (!payload || typeof payload !== 'object') return null
const candidate = payload as Partial<ScheduleExecutionPayload>
if (
typeof candidate.scheduleId !== 'string' ||
typeof candidate.workflowId !== 'string' ||
typeof candidate.now !== 'string'
) {
return null
}
return candidate as ScheduleExecutionPayload
}
function getSchedulePayloadFromJob(job: Job): ScheduleExecutionPayload | null {
return getSchedulePayloadFromValue(job.payload)
}
function getSchedulePayloadClaimedAt(payload: ScheduleExecutionPayload | null): Date | null {
if (!payload) return null
const claimedAt = new Date(payload.now)
return Number.isNaN(claimedAt.getTime()) ? null : claimedAt
}
async function restoreScheduleClaim(
scheduleId: string,
requestId: string,
currentClaim: Date,
activeClaim: Date,
context: string
): Promise<void> {
if (currentClaim.getTime() === activeClaim.getTime()) return
const [restored] = await db
.update(workflowSchedule)
.set({ lastQueuedAt: activeClaim, updatedAt: new Date() })
.where(
and(
eq(workflowSchedule.id, scheduleId),
isNull(workflowSchedule.archivedAt),
eq(workflowSchedule.lastQueuedAt, currentClaim)
)
)
.returning({ id: workflowSchedule.id })
.catch((error) => {
logger.error(`[${requestId}] ${context}`, error)
throw error
})
if (!restored) {
const error = new Error(`Schedule claim restore did not update schedule ${scheduleId}`)
logger.warn(`[${requestId}] ${context}`, {
scheduleId,
currentClaim: currentClaim.toISOString(),
activeClaim: activeClaim.toISOString(),
})
throw error
}
}
function getStaleScheduleExecutionCutoff(now: Date): Date {
return new Date(now.getTime() - STALE_SCHEDULE_CLAIM_MS)
}
function isStaleScheduleClaim(claimedAt: Date): boolean {
return claimedAt < getStaleScheduleExecutionCutoff(new Date())
}
function activeScheduleExecutionJobsFilter() {
return sql`${asyncJobs.type} = 'schedule-execution' AND ${asyncJobs.status} = 'processing'`
}
function pendingScheduleExecutionJobsFilter(now: Date) {
return and(
sql`${asyncJobs.type} = 'schedule-execution' AND ${asyncJobs.status} = 'pending'`,
sql`${asyncJobs.attempts} < ${asyncJobs.maxAttempts}`,
or(isNull(asyncJobs.runAt), lte(asyncJobs.runAt, now))
)
}
function staleScheduleExecutionJobsFilter(staleStartedBefore: Date) {
return and(
activeScheduleExecutionJobsFilter(),
or(isNull(asyncJobs.startedAt), lt(asyncJobs.startedAt, staleStartedBefore))
)
}
function getScheduleNextRunAt(
schedule: { cronExpression?: string | null; timezone?: string },
now: Date
): Date {
return (
getNextRunFromCronExpression(schedule.cronExpression, schedule.timezone) ??
new Date(now.getTime() + 24 * 60 * 60 * 1000)
)
}
async function markClaimedScheduleFailed(
schedule: DatabaseScheduleExecutionTarget,
requestId: string,
expectedLastQueuedAt: Date,
context: string
): Promise<void> {
const now = new Date()
await db
.update(workflowSchedule)
.set(buildScheduleFailureUpdate(now, getScheduleNextRunAt(schedule, now)))
.where(
and(
eq(workflowSchedule.id, schedule.id),
isNull(workflowSchedule.archivedAt),
eq(workflowSchedule.lastQueuedAt, expectedLastQueuedAt)
)
)
.catch((error) => {
logger.error(`[${requestId}] ${context}`, error)
throw error
})
}
async function deferClaimedScheduleAfterQueueFailure(
schedule: ClaimedSchedule,
requestId: string,
expectedLastQueuedAt: Date,
error: unknown,
context: string
): Promise<void> {
const now = new Date()
const retryAttempt = (schedule.infraRetryCount || 0) + 1
if (retryAttempt > SCHEDULE_INFRA_RETRY_MAX_ATTEMPTS) {
await markClaimedScheduleFailed(
schedule,
requestId,
expectedLastQueuedAt,
`Failed to mark schedule ${schedule.id} failed after queue retry exhaustion`
)
return
}
const retryDelayMs = Math.min(
SCHEDULE_INFRA_RETRY_MAX_MS,
Math.round(
backoffWithJitter(retryAttempt, null, {
baseMs: SCHEDULE_INFRA_RETRY_BASE_MS,
maxMs: SCHEDULE_INFRA_RETRY_MAX_MS,
})
)
)
const nextRetryAt = new Date(now.getTime() + retryDelayMs)
logger.warn(`[${requestId}] Deferring schedule after queue infrastructure failure`, {
scheduleId: schedule.id,
workflowId: schedule.workflowId,
retryAttempt,
retryDelayMs,
error: toError(error).message,
})
await db
.update(workflowSchedule)
.set({
updatedAt: now,
nextRunAt: nextRetryAt,
lastQueuedAt: null,
infraRetryCount: retryAttempt,
})
.where(
and(
eq(workflowSchedule.id, schedule.id),
isNull(workflowSchedule.archivedAt),
eq(workflowSchedule.lastQueuedAt, expectedLastQueuedAt)
)
)
.catch((updateError) => {
logger.error(`[${requestId}] ${context}`, updateError)
throw updateError
})
}
async function handleClaimedScheduleSetupFailure(
schedule: ClaimedSchedule,
requestId: string,
expectedLastQueuedAt: Date,
error: unknown,
retryContext: string,
failureContext: string
): Promise<void> {
if (isRetryableInfrastructureError(error)) {
await deferClaimedScheduleAfterQueueFailure(
schedule,
requestId,
expectedLastQueuedAt,
error,
retryContext
)
return
}
logger.error(`[${requestId}] Non-retryable schedule setup failure`, {
scheduleId: schedule.id,
workflowId: schedule.workflowId,
error: toError(error).message,
})
await markClaimedScheduleFailed(schedule, requestId, expectedLastQueuedAt, failureContext)
}
async function recoverStaleDatabaseScheduleJobs(now: Date): Promise<void> {
const staleStartedBefore = getStaleScheduleExecutionCutoff(now)
await db.transaction(async (tx) => {
const [lock] = await tx.execute<{ acquired: boolean }>(
sql`SELECT pg_try_advisory_xact_lock(hashtextextended(${SCHEDULE_EXECUTION_QUEUE_NAME}, 0)) AS acquired`
)
if (!lock?.acquired) {
logger.info(
'Skipped stale database schedule job recovery because another worker holds the lock'
)
return
}
const staleRows = await tx
.select({
id: asyncJobs.id,
payload: asyncJobs.payload,
attempts: asyncJobs.attempts,
maxAttempts: asyncJobs.maxAttempts,
})
.from(asyncJobs)
.where(staleScheduleExecutionJobsFilter(staleStartedBefore))
.orderBy(asc(asyncJobs.startedAt), asc(asyncJobs.id))
.limit(STALE_SCHEDULE_RECOVERY_BATCH_SIZE)
const exhaustedRows = staleRows.filter((row) => row.attempts >= row.maxAttempts)
const retryableRows = staleRows.filter((row) => row.attempts < row.maxAttempts)
if (exhaustedRows.length > 0) {
await tx
.update(asyncJobs)
.set({
status: JOB_STATUS.FAILED,
completedAt: now,
error: 'Stale schedule execution processing lease exhausted retry attempts',
updatedAt: now,
})
.where(
inArray(
asyncJobs.id,
exhaustedRows.map((row) => row.id)
)
)
}
for (const row of exhaustedRows) {
const payload = getSchedulePayloadFromValue(row.payload)
const claimedAt = getSchedulePayloadClaimedAt(payload)
if (!payload || !claimedAt) continue
await tx
.update(workflowSchedule)
.set(buildScheduleFailureUpdate(now, getScheduleNextRunAt(payload, now)))
.where(
and(
eq(workflowSchedule.id, payload.scheduleId),
isNull(workflowSchedule.archivedAt),
eq(workflowSchedule.lastQueuedAt, claimedAt)
)
)
}
if (retryableRows.length > 0) {
await tx
.update(asyncJobs)
.set({
status: JOB_STATUS.PENDING,
startedAt: null,
error: 'Recovered after stale schedule execution processing lease',
updatedAt: now,
})
.where(
inArray(
asyncJobs.id,
retryableRows.map((row) => row.id)
)
)
}
})
}
function isStaleDatabaseScheduleJob(job: { status: string; startedAt?: Date }): boolean {
return (
job.status === JOB_STATUS.PROCESSING &&
(!job.startedAt || job.startedAt < getStaleScheduleExecutionCutoff(new Date()))
)
}
async function getDatabaseScheduleExecutionSlots(): Promise<number> {
const [row] = await db
.select({
count: sql<number>`count(*)`,
})
.from(asyncJobs)
.where(activeScheduleExecutionJobsFilter())
const processingCount = Number(row?.count ?? 0)
return Math.max(0, SCHEDULE_EXECUTION_CONCURRENCY_LIMIT - processingCount)
}
async function tryStartDatabaseScheduleJob(jobId: string): Promise<DatabaseScheduleStartResult> {
const now = new Date()
return db.transaction(async (tx) => {
const [lock] = await tx.execute<{ acquired: boolean }>(
sql`SELECT pg_try_advisory_xact_lock(hashtextextended(${SCHEDULE_EXECUTION_QUEUE_NAME}, 0)) AS acquired`
)
if (!lock?.acquired) return 'capacity_full'
const [row] = await tx
.select({
count: sql<number>`count(*)`,
})
.from(asyncJobs)
.where(activeScheduleExecutionJobsFilter())
if (Number(row?.count ?? 0) >= SCHEDULE_EXECUTION_CONCURRENCY_LIMIT) {
return 'capacity_full'
}
const [startedJob] = await tx
.update(asyncJobs)
.set({
status: JOB_STATUS.PROCESSING,
startedAt: now,
attempts: sql`${asyncJobs.attempts} + 1`,
updatedAt: now,
})
.where(and(eq(asyncJobs.id, jobId), eq(asyncJobs.status, JOB_STATUS.PENDING)))
.returning({ id: asyncJobs.id })
return startedJob ? 'started' : 'not_pending'
})
}
async function executeDatabaseScheduleJob(
jobQueue: JobQueue,
jobId: string,
payload: ScheduleExecutionPayload,
schedule: DatabaseScheduleExecutionTarget,
queuedAt: Date,
requestId: string,
delayMs: number
): Promise<void> {
if (delayMs > 0) await sleep(delayMs)
const startResult = await runWithDatabaseScheduleStartTurn(() =>
tryStartDatabaseScheduleJob(jobId)
)
if (startResult === 'not_pending') {
logger.info(`[${requestId}] Database schedule execution job is no longer pending`, {
scheduleId: schedule.id,
workflowId: schedule.workflowId,
jobId,
})
return
}
if (startResult === 'capacity_full') {
logger.info(`[${requestId}] Deferred database schedule execution because capacity is full`, {
scheduleId: schedule.id,
workflowId: schedule.workflowId,
jobId,
concurrencyLimit: SCHEDULE_EXECUTION_CONCURRENCY_LIMIT,
})
return
}
try {
const output = await executeScheduleJob(payload)
await jobQueue.completeJob(jobId, output ?? null)
} catch (error) {
const errorMessage = toError(error).message
logger.error(`[${requestId}] Schedule execution failed for workflow ${schedule.workflowId}`, {
scheduleId: schedule.id,
jobId,
error: errorMessage,
})
await jobQueue.markJobFailed(jobId, errorMessage)
await releaseScheduleLock(
schedule.id,
requestId,
new Date(),
`Failed to release lock for schedule ${schedule.id} after inline execution failure`,
undefined,
{ expectedLastQueuedAt: queuedAt }
)
}
}
async function getPendingDatabaseScheduleJobs(limit: number) {
if (limit <= 0) return []
const now = new Date()
return db
.select({
id: asyncJobs.id,
payload: asyncJobs.payload,
})
.from(asyncJobs)
.where(pendingScheduleExecutionJobsFilter(now))
.orderBy(asc(asyncJobs.runAt), asc(asyncJobs.createdAt), asc(asyncJobs.id))
.limit(limit)
}
function getScheduleTargetFromPayload(
payload: ScheduleExecutionPayload
): DatabaseScheduleExecutionTarget {
return {
id: payload.scheduleId,
workflowId: payload.workflowId,
cronExpression: payload.cronExpression ?? null,
timezone: payload.timezone ?? 'UTC',
}
}
async function getScheduleClaimState(
payload: ScheduleExecutionPayload,
claimedAt: Date
): Promise<'matches' | 'released' | 'claimed_by_other'> {
const [schedule] = await db
.select({
lastQueuedAt: workflowSchedule.lastQueuedAt,
})
.from(workflowSchedule)
.where(and(eq(workflowSchedule.id, payload.scheduleId), isNull(workflowSchedule.archivedAt)))
.limit(1)
if (!schedule?.lastQueuedAt) return 'released'
return schedule.lastQueuedAt.getTime() === claimedAt.getTime() ? 'matches' : 'claimed_by_other'
}
async function resumePendingDatabaseScheduleJobs(
jobQueue: JobQueue,
requestId: string,
slots: number
): Promise<number> {
const pendingJobs = await getPendingDatabaseScheduleJobs(slots)
if (pendingJobs.length === 0) return 0
const results = await Promise.allSettled(
pendingJobs.map(async (job) => {
const payload = getSchedulePayloadFromValue(job.payload)
const claimedAt = getSchedulePayloadClaimedAt(payload)
if (!payload || !claimedAt) {
await jobQueue.markJobFailed(job.id, 'Invalid pending schedule execution payload')
return true
}
const claimState = await getScheduleClaimState(payload, claimedAt)
if (claimState === 'released') {
logger.info(`[${requestId}] Completing stale pending schedule execution job`, {
scheduleId: payload.scheduleId,
workflowId: payload.workflowId,
jobId: job.id,
})
await jobQueue.completeJob(job.id, {
skipped: true,
reason: 'schedule claim no longer matches pending job occurrence',
})
return true
}
if (claimState === 'claimed_by_other') {
logger.info(`[${requestId}] Leaving pending schedule execution job for active claimant`, {
scheduleId: payload.scheduleId,
workflowId: payload.workflowId,
jobId: job.id,
})
return false
}
logger.info(`[${requestId}] Resuming pending database schedule execution job`, {
scheduleId: payload.scheduleId,
workflowId: payload.workflowId,
jobId: job.id,
})
await executeDatabaseScheduleJob(
jobQueue,
job.id,
payload,
getScheduleTargetFromPayload(payload),
claimedAt,
requestId,
0
)
return true
})
)
let processedCount = 0
results.forEach((result, index) => {
if (result.status === 'fulfilled' && result.value) {
processedCount += 1
return
}
if (result.status === 'rejected') {
logger.error(`[${requestId}] Failed to resume pending database schedule execution job`, {
jobId: pendingJobs[index]?.id,
error: toError(result.reason).message,
})
}
})
return processedCount
}
async function processScheduleItem(
schedule: ClaimedSchedule,
queuedAt: Date,
requestId: string,
jobQueue: JobQueue,
useDatabaseFallback: boolean
) {
const queueTime = schedule.lastQueuedAt ?? queuedAt
const executionId = generateId()
const correlation = {
executionId,
requestId,
source: 'schedule' as const,
workflowId: schedule.workflowId!,
scheduleId: schedule.id,
triggerType: 'schedule',
scheduledFor: schedule.nextRunAt?.toISOString(),
}
const payload = {
scheduleId: schedule.id,
workflowId: schedule.workflowId!,
executionId,
requestId,
correlation,
blockId: schedule.blockId || undefined,
workspaceId: schedule.workspaceId || undefined,
deploymentVersionId: schedule.deploymentVersionId || undefined,
cronExpression: schedule.cronExpression || undefined,
timezone: schedule.timezone || undefined,
lastRanAt: schedule.lastRanAt?.toISOString(),
failedCount: schedule.failedCount || 0,
infraRetryCount: schedule.infraRetryCount || 0,
now: queueTime.toISOString(),
scheduledFor: schedule.nextRunAt?.toISOString(),
}
let enqueuedJobId: string | null = null
try {
const delayMs = randomInt(0, SCHEDULE_JITTER_MAX_MS)
const scheduleJobId = buildScheduleExecutionJobId(schedule)
const existingJob = await jobQueue.getJob(scheduleJobId)
if (existingJob && ['pending', 'processing'].includes(existingJob.status)) {
const activeJobPayload = getSchedulePayloadFromJob(existingJob)
const activeJobClaim = getSchedulePayloadClaimedAt(activeJobPayload)
if (useDatabaseFallback && isStaleDatabaseScheduleJob(existingJob)) {
await recoverStaleDatabaseScheduleJobs(new Date())
logger.info(`[${requestId}] Recovered stale database schedule execution jobs`, {
scheduleId: schedule.id,
jobId: scheduleJobId,
})
}
const databaseJob = useDatabaseFallback ? await jobQueue.getJob(scheduleJobId) : existingJob
const databaseJobPayload = databaseJob ? getSchedulePayloadFromJob(databaseJob) : null
const databaseJobClaim = getSchedulePayloadClaimedAt(databaseJobPayload) ?? activeJobClaim
if (!useDatabaseFallback && activeJobClaim && isStaleScheduleClaim(activeJobClaim)) {
logger.warn(`[${requestId}] Cancelling stale schedule execution job`, {
scheduleId: schedule.id,
jobId: existingJob.id,
claimedAt: activeJobClaim.toISOString(),
})
await jobQueue.cancelJob(existingJob.id)
await releaseScheduleLock(
schedule.id,
requestId,
queuedAt,
`Released stale schedule ${schedule.id} after cancelling stale schedule execution job`,
undefined,
{ expectedLastQueuedAt: queueTime }
)
return
}
if (useDatabaseFallback && databaseJob?.status === JOB_STATUS.PENDING) {
logger.info(`[${requestId}] Resuming pending database schedule execution job`, {
scheduleId: schedule.id,
jobId: scheduleJobId,
})
if (databaseJobClaim) {
await restoreScheduleClaim(
schedule.id,
requestId,
queueTime,
databaseJobClaim,
`Failed to restore schedule ${schedule.id} claim for pending database fallback job`
)
}
enqueuedJobId = scheduleJobId
await executeDatabaseScheduleJob(
jobQueue,
scheduleJobId,
databaseJobPayload ?? payload,
schedule,
databaseJobClaim ?? queueTime,
requestId,
delayMs
)
return
}
if (
useDatabaseFallback &&
databaseJob &&
databaseJob.status !== JOB_STATUS.PENDING &&
databaseJob.status !== JOB_STATUS.PROCESSING
) {
logger.info(`[${requestId}] Database schedule execution job reached terminal state`, {
scheduleId: schedule.id,
jobId: scheduleJobId,
status: databaseJob.status,
})
if (databaseJob.status === JOB_STATUS.FAILED) {
await markClaimedScheduleFailed(
schedule,
requestId,
queueTime,
`Failed to mark schedule ${schedule.id} failed after terminal database fallback job`
)
return
}
await releaseScheduleLock(
schedule.id,
requestId,
queuedAt,
`Released stale schedule ${schedule.id} for terminal database fallback job ${scheduleJobId}`,
getNextRunFromCronExpression(schedule.cronExpression, schedule.timezone),
{ expectedLastQueuedAt: queueTime }
)
return
}
logger.info(`[${requestId}] Schedule execution job already exists`, {
scheduleId: schedule.id,
jobId: scheduleJobId,
status: databaseJob?.status ?? existingJob.status,
})
const shouldRestoreActiveClaim =
activeJobClaim &&
(!useDatabaseFallback ||
databaseJob?.status !== JOB_STATUS.PROCESSING ||
!isStaleScheduleClaim(activeJobClaim))
if (shouldRestoreActiveClaim) {
await restoreScheduleClaim(
schedule.id,
requestId,
queueTime,
activeJobClaim,
`Failed to restore schedule ${schedule.id} claim for active schedule execution job`
)
}
return
}
if (existingJob) {
logger.info(`[${requestId}] Releasing stale schedule claim for finished job`, {
scheduleId: schedule.id,
jobId: scheduleJobId,
status: existingJob.status,
})
await releaseScheduleLock(
schedule.id,
requestId,
queuedAt,
`Released stale schedule ${schedule.id} for finished job ${scheduleJobId}`,
getNextRunFromCronExpression(schedule.cronExpression, schedule.timezone),
{ expectedLastQueuedAt: queueTime }
)
return
}
let jobId: string
try {
jobId = await jobQueue.enqueue(SCHEDULE_EXECUTION_QUEUE_NAME, payload, {
jobId: scheduleJobId,
delayMs,
metadata: {
workflowId: schedule.workflowId ?? undefined,
workspaceId: schedule.workspaceId ?? undefined,
correlation,
},
})
enqueuedJobId = jobId
} catch (error) {
logger.error(
`[${requestId}] Failed to enqueue schedule execution for workflow ${schedule.workflowId}`,
error
)
await handleClaimedScheduleSetupFailure(
schedule,
requestId,
queueTime,
error,
`Failed to defer schedule ${schedule.id} after enqueue failure`,
`Failed to mark schedule ${schedule.id} failed after non-retryable enqueue failure`
)
return
}
logger.info(
`[${requestId}] Queued schedule execution task ${jobId} for workflow ${schedule.workflowId}`
)
if (useDatabaseFallback) {
logger.info(`[${requestId}] Executing durable database schedule execution job`, {
scheduleId: schedule.id,
workflowId: schedule.workflowId,
jobId,
delayMs,
concurrencyLimit: SCHEDULE_EXECUTION_CONCURRENCY_LIMIT,
})
await executeDatabaseScheduleJob(
jobQueue,
jobId,
payload,
schedule,
queueTime,
requestId,
delayMs
)
return
}
const queuedJob = await jobQueue.getJob(jobId)
if (queuedJob && !['pending', 'processing'].includes(queuedJob.status)) {
logger.info(`[${requestId}] Schedule execution job already finished`, {
scheduleId: schedule.id,
jobId,
status: queuedJob.status,
})
await releaseScheduleLock(
schedule.id,
requestId,
queuedAt,
`Released stale schedule ${schedule.id} for finished job ${jobId}`,
getNextRunFromCronExpression(schedule.cronExpression, schedule.timezone),
{ expectedLastQueuedAt: queueTime }
)
return
}
if (queuedJob) {
const queuedJobClaim = getSchedulePayloadClaimedAt(getSchedulePayloadFromJob(queuedJob))
if (queuedJobClaim) {
if (isStaleScheduleClaim(queuedJobClaim)) {
logger.warn(`[${requestId}] Cancelling stale queued schedule execution job`, {
scheduleId: schedule.id,
jobId,
claimedAt: queuedJobClaim.toISOString(),
})
await jobQueue.cancelJob(jobId)
await releaseScheduleLock(
schedule.id,
requestId,
queuedAt,
`Released stale schedule ${schedule.id} after cancelling stale queued schedule execution job`,
undefined,
{ expectedLastQueuedAt: queueTime }
)
return
}
await restoreScheduleClaim(
schedule.id,
requestId,
queueTime,
queuedJobClaim,
`Failed to restore schedule ${schedule.id} claim for queued schedule execution job`
)
}
}
logger.info(`[${requestId}] Schedule execution task accepted`, {
scheduleId: schedule.id,
workflowId: schedule.workflowId,
jobId,
delayMs,
concurrencyLimit: SCHEDULE_EXECUTION_CONCURRENCY_LIMIT,
backend: useDatabaseFallback ? 'database-fallback' : 'trigger-dev',
})
} catch (error) {
logger.error(
`[${requestId}] Failed after queueing schedule execution for workflow ${schedule.workflowId}`,
error
)
if (!enqueuedJobId) {
await handleClaimedScheduleSetupFailure(
schedule,
requestId,
queueTime,
error,
`Failed to defer schedule ${schedule.id} after pre-enqueue failure`,
`Failed to mark schedule ${schedule.id} failed after non-retryable setup failure`
)
}
}
}
async function processJobItem(job: ClaimedJob, queuedAt: Date, requestId: string) {
const queueTime = job.lastQueuedAt ?? queuedAt
const payload = {
scheduleId: job.id,
cronExpression: job.cronExpression || undefined,
failedCount: job.failedCount || 0,
now: queueTime.toISOString(),
}
try {
await executeJobInline(payload)
} catch (error) {
logger.error(`[${requestId}] Job execution failed for ${job.id}`, {
error: toError(error).message,
})
await releaseScheduleLock(
job.id,
requestId,
queuedAt,
`Failed to release lock for job ${job.id}`,
undefined,
{ expectedLastQueuedAt: queueTime }
)
}
}
interface ScheduleTickResult {
processedCount: number
totalSchedules: number
totalJobs: number
}
/**
* Drains due schedules and jobs, claiming and enqueuing work until the tick
* budget is exhausted or no more items are due. Runs detached from the HTTP
* response so the cron caller does not wait; cross-replica safety is provided by
* the `FOR UPDATE SKIP LOCKED` claim layer, not this function.
*/
export async function runScheduleTick(requestId: string): Promise<ScheduleTickResult> {
const tickStart = Date.now()
const jobQueue = await getJobQueue()
const useDatabaseFallback = shouldExecuteInline()
let totalSchedules = 0
let totalJobs = 0
let iterations = 0
let remainingWorkflowBudget = SCHEDULE_WORKFLOW_ENQUEUE_LIMIT
let schedulesExhausted = false
let jobsExhausted = false
while (Date.now() - tickStart < MAX_TICK_DURATION_MS) {
if (schedulesExhausted && jobsExhausted) break
const queuedAt = new Date()
let resumedPendingSchedules = 0
let databaseScheduleSlots = SCHEDULE_EXECUTION_CONCURRENCY_LIMIT
if (useDatabaseFallback) {
await recoverStaleDatabaseScheduleJobs(queuedAt)
databaseScheduleSlots = await getDatabaseScheduleExecutionSlots()
resumedPendingSchedules = await resumePendingDatabaseScheduleJobs(
jobQueue,
requestId,
databaseScheduleSlots
)
databaseScheduleSlots = await getDatabaseScheduleExecutionSlots()
}
const workflowClaimLimit = Math.min(
WORKFLOW_CHUNK_SIZE,
remainingWorkflowBudget,
useDatabaseFallback ? databaseScheduleSlots : WORKFLOW_CHUNK_SIZE
)
if (useDatabaseFallback && workflowClaimLimit <= 0) {
schedulesExhausted = true
}
const [dueSchedules, dueJobs] = await Promise.all([
schedulesExhausted ? [] : claimWorkflowSchedules(queuedAt, workflowClaimLimit),
jobsExhausted ? [] : claimJobSchedules(queuedAt, JOB_CHUNK_SIZE),
])
remainingWorkflowBudget -= dueSchedules.length
if (dueSchedules.length < workflowClaimLimit || remainingWorkflowBudget <= 0) {
schedulesExhausted = true
}
if (dueJobs.length < JOB_CHUNK_SIZE) jobsExhausted = true
if (dueSchedules.length === 0 && dueJobs.length === 0 && resumedPendingSchedules === 0) break
iterations += 1
totalSchedules += dueSchedules.length + resumedPendingSchedules
totalJobs += dueJobs.length
logger.info(
`[${requestId}] Iteration ${iterations}: claimed ${dueSchedules.length} schedules, resumed ${resumedPendingSchedules} pending schedule jobs, ${dueJobs.length} jobs`,
{
remainingWorkflowBudget,
scheduleConcurrencyLimit: SCHEDULE_EXECUTION_CONCURRENCY_LIMIT,
databaseScheduleSlots,
}
)
const schedulePromises =
dueSchedules.length > 0
? dueSchedules.map((schedule) =>
processScheduleItem(schedule, queuedAt, requestId, jobQueue, useDatabaseFallback)
)
: []
await Promise.allSettled([
...schedulePromises,
...dueJobs.map((job) => processJobItem(job, queuedAt, requestId)),
])
}
const totalCount = totalSchedules + totalJobs
const durationMs = Date.now() - tickStart
logger.info(
`[${requestId}] Processed ${totalCount} items across ${iterations} iteration(s) in ${durationMs}ms (${totalSchedules} schedules, ${totalJobs} jobs)`,
{
scheduleConcurrencyLimit: SCHEDULE_EXECUTION_CONCURRENCY_LIMIT,
scheduleEnqueueBudget: SCHEDULE_WORKFLOW_ENQUEUE_LIMIT,
remainingWorkflowBudget,
}
)
return { processedCount: totalCount, totalSchedules, totalJobs }
}
export const GET = withRouteHandler(async (request: NextRequest) => {
const requestId = generateRequestId()
logger.info(`[${requestId}] Scheduled execution triggered at ${new Date().toISOString()}`)
const authError = verifyCronAuth(request, 'Schedule execution')
if (authError) {
return authError
}
runDetached('schedule-execution-tick', () => runScheduleTick(requestId))
const response = {
message: 'Scheduled execution started',
status: 'started',
} satisfies ExecuteSchedulesResponse
return NextResponse.json(response, { status: 202 })
})