diff --git a/.changeset/quiet-plums-speak.md b/.changeset/quiet-plums-speak.md new file mode 100644 index 0000000000..6de20c22db --- /dev/null +++ b/.changeset/quiet-plums-speak.md @@ -0,0 +1,9 @@ +--- +"@workflow/sveltekit": patch +"@workflow/builders": patch +"@workflow/errors": patch +"@workflow/core": patch +"@workflow/next": patch +--- + +Increase flow route limit to max fluid duration and fail run if a single replay exceeds 300s diff --git a/packages/builders/src/vercel-build-output-api.ts b/packages/builders/src/vercel-build-output-api.ts index e8dd1768d6..38ecaa2ae9 100644 --- a/packages/builders/src/vercel-build-output-api.ts +++ b/packages/builders/src/vercel-build-output-api.ts @@ -110,7 +110,7 @@ export class VercelBuildOutputAPIBuilder extends BaseBuilder { // Create package.json and .vc-config.json for workflows function await this.createPackageJson(workflowsFuncDir, 'commonjs'); await this.createVcConfig(workflowsFuncDir, { - maxDuration: 60, + maxDuration: 'max', experimentalTriggers: [WORKFLOW_QUEUE_TRIGGER], runtime: this.config.runtime, }); diff --git a/packages/core/src/runtime.ts b/packages/core/src/runtime.ts index da7a407bd3..3ac54ca7d1 100644 --- a/packages/core/src/runtime.ts +++ b/packages/core/src/runtime.ts @@ -5,7 +5,10 @@ import { WorkflowRuntimeError, } from '@workflow/errors'; import { classifyRunError } from './classify-error.js'; -import { MAX_QUEUE_DELIVERIES } from './runtime/constants.js'; +import { + MAX_QUEUE_DELIVERIES, + REPLAY_TIMEOUT_MS, +} from './runtime/constants.js'; import { parseWorkflowName } from '@workflow/utils/parse-name'; import { type Event, @@ -161,6 +164,37 @@ export function workflowEntrypoint( const spanLinks = await linkToCurrentContext(); + // --- Replay timeout guard --- + // If the replay takes longer than the timeout, fail the run and exit. + // This must be lower than the function's maxDuration (180s) to ensure + // the failure is recorded before the platform kills the function. + const replayTimeout = setTimeout(async () => { + runtimeLogger.error('Workflow replay exceeded timeout', { + workflowRunId: runId, + timeoutMs: REPLAY_TIMEOUT_MS, + }); + try { + const world = getWorld(); + await world.events.create( + runId, + { + eventType: 'run_failed', + specVersion: SPEC_VERSION_CURRENT, + eventData: { + error: { + message: `Workflow replay exceeded maximum duration (${REPLAY_TIMEOUT_MS / 1000}s)`, + }, + errorCode: RUN_ERROR_CODES.REPLAY_TIMEOUT, + }, + }, + { requestId } + ); + } catch { + // Best effort — process exits regardless + } + process.exit(1); + }, REPLAY_TIMEOUT_MS); + // Invoke user workflow within the propagated trace context and baggage return await withTraceContext(traceContext, async () => { // Set workflow context as baggage for automatic propagation @@ -525,6 +559,8 @@ export function workflowEntrypoint( ); // End trace } ); // End withWorkflowBaggage + }).finally(() => { + clearTimeout(replayTimeout); }); // End withTraceContext } ); diff --git a/packages/core/src/runtime/constants.ts b/packages/core/src/runtime/constants.ts index 398177d053..b63211c06e 100644 --- a/packages/core/src/runtime/constants.ts +++ b/packages/core/src/runtime/constants.ts @@ -11,3 +11,10 @@ // At 48 attempts the total elapsed time is approximately 20 hours, which is // safely under the 24-hour message visibility limit. export const MAX_QUEUE_DELIVERIES = 48; + +// Maximum time allowed for a single workflow replay execution (in ms). +// If a replay exceeds this duration, the run is failed and the process exits. +// This must be lower than the function's maxDuration to ensure the +// timeout handler has time to post the run_failed event before the platform +// kills the function. +export const REPLAY_TIMEOUT_MS = 240_000; diff --git a/packages/errors/src/error-codes.ts b/packages/errors/src/error-codes.ts index f83b1baadf..b945303e93 100644 --- a/packages/errors/src/error-codes.ts +++ b/packages/errors/src/error-codes.ts @@ -10,6 +10,8 @@ export const RUN_ERROR_CODES = { RUNTIME_ERROR: 'RUNTIME_ERROR', /** Run exceeded the maximum number of queue deliveries */ MAX_DELIVERIES_EXCEEDED: 'MAX_DELIVERIES_EXCEEDED', + /** Workflow replay exceeded the maximum allowed duration */ + REPLAY_TIMEOUT: 'REPLAY_TIMEOUT', } as const; export type RunErrorCode = diff --git a/packages/next/src/builder-deferred.ts b/packages/next/src/builder-deferred.ts index 86b9eb1bc2..86e1e58c86 100644 --- a/packages/next/src/builder-deferred.ts +++ b/packages/next/src/builder-deferred.ts @@ -1109,7 +1109,7 @@ export async function getNextBuilderDeferred() { experimentalTriggers: [STEP_QUEUE_TRIGGER], }, workflows: { - maxDuration: 60, + maxDuration: 'max', experimentalTriggers: [WORKFLOW_QUEUE_TRIGGER], }, }; diff --git a/packages/next/src/builder-eager.ts b/packages/next/src/builder-eager.ts index 11aa2a5c08..c6d6fac0e2 100644 --- a/packages/next/src/builder-eager.ts +++ b/packages/next/src/builder-eager.ts @@ -420,7 +420,7 @@ export async function getNextBuilderEager() { experimentalTriggers: [STEP_QUEUE_TRIGGER], }, workflows: { - maxDuration: 60, + maxDuration: 'max', experimentalTriggers: [WORKFLOW_QUEUE_TRIGGER], }, }; diff --git a/packages/sveltekit/src/index.ts b/packages/sveltekit/src/index.ts index 0bb0af3518..3923ecd621 100644 --- a/packages/sveltekit/src/index.ts +++ b/packages/sveltekit/src/index.ts @@ -19,7 +19,7 @@ process.on('beforeExit', () => { { file: '.vercel/output/functions/.well-known/workflow/v1/flow.func/.vc-config.json', config: { - maxDuration: 60, + maxDuration: 'max', experimentalTriggers: [ { type: 'queue/v2beta',