From 46b0233de304e3e14292c0c0d6731302c527a690 Mon Sep 17 00:00:00 2001 From: Peter Wielander Date: Tue, 21 Jul 2026 12:13:50 -0700 Subject: [PATCH 1/4] [core] Enforce maxRetries for steps that time out MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A step that is hard-killed by the platform function timeout writes no step_failed/step_retrying, so `step.error` stays null and the error-based max-retries guards never fire. Each redelivery re-runs step_started (incrementing the attempt), so a timing-out step retried without bound instead of stopping at maxRetries. Enforce the retry ceiling BEFORE running the body, via a new `authoritativeAttempt` param on executeStep: - Inline (combined handler): count the step_started events already in the event log for the step (+1 for this attempt). The log is authoritative because the optimistic-start path synthesizes step.attempt = 1. - Background (queue-dispatched): the queue delivery count (metadata.attempt), which increments on the visibility-timeout redelivery a timed-out step produces. When the attempt exceeds maxRetries + 1 the step is failed without starting another attempt. Thrown-error exhaustion is unchanged — it still terminates via the post-body guard one attempt earlier, with the thrown error as cause. Co-Authored-By: Claude Opus 4.8 (1M context) --- .changeset/step-maxretries-timeout.md | 5 + packages/core/src/runtime.ts | 47 +++++ .../core/src/runtime/step-executor.test.ts | 166 ++++++++++++++++++ packages/core/src/runtime/step-executor.ts | 92 ++++++++++ 4 files changed, 310 insertions(+) create mode 100644 .changeset/step-maxretries-timeout.md create mode 100644 packages/core/src/runtime/step-executor.test.ts diff --git a/.changeset/step-maxretries-timeout.md b/.changeset/step-maxretries-timeout.md new file mode 100644 index 0000000000..bcab509b0c --- /dev/null +++ b/.changeset/step-maxretries-timeout.md @@ -0,0 +1,5 @@ +--- +'@workflow/core': patch +--- + +Enforce `maxRetries` for steps that time out. A step killed by the function timeout writes no error, so retries were previously unbounded; the retry ceiling is now enforced from the event-log start count (inline) and the queue delivery count (background) before each attempt runs. diff --git a/packages/core/src/runtime.ts b/packages/core/src/runtime.ts index bb0002b8e0..6fff7feadd 100644 --- a/packages/core/src/runtime.ts +++ b/packages/core/src/runtime.ts @@ -246,6 +246,30 @@ function hasRecordedTerminalRunEvent(events: Event[], runId: string): boolean { return true; } +/** + * Number of `step_started` events already recorded for a step, used as the + * authoritative attempt count for the inline retry ceiling. Each real attempt + * writes exactly one `step_started` (the atomic create-claim / single-flight + * prevents concurrent double-starts from inflating this), so the count equals + * the number of attempts that have begun. `undefined` events (no log loaded + * yet) count as zero. + */ +function countStepStartedEvents( + events: Event[] | null | undefined, + stepId: string +): number { + if (!events) { + return 0; + } + let count = 0; + for (const e of events) { + if (e.eventType === 'step_started' && e.correlationId === stepId) { + count++; + } + } + return count; +} + /** * The lineage root of a loaded run: its `$rootRunId` attribute, or its own id * when it is itself a root. @@ -751,6 +775,14 @@ export function workflowEntrypoint( stepId: incomingStepId, stepName: incomingStepName, runSpecVersion: bgRun.specVersion, + // A backgrounded step is redelivered as the same + // queue message on every retry — including the + // visibility-timeout redelivery a timed-out step + // produces — so the delivery count is the + // authoritative attempt number that bounds + // maxRetries (a timeout writes no error, so the + // error-based guards can't). + authoritativeAttempt: metadata.attempt, }) ); } finally { @@ -2242,6 +2274,21 @@ export function workflowEntrypoint( stepId: s.correlationId, stepName: s.stepName, runSpecVersion: workflowRun.specVersion, + // Authoritative attempt number for the retry + // ceiling: the step_started events already in + // the event log for this step, plus the one + // this execution is about to write. This is the + // only signal that bounds an inline step that + // keeps timing out — the flow invocation is + // redelivered and re-runs the step (appending a + // step_started) with no error recorded, and the + // optimistic-start path synthesizes + // step.attempt = 1. + authoritativeAttempt: + countStepStartedEvents( + cachedEvents, + s.correlationId + ) + 1, // Lazy inline start: send the deferred step's // input on step_started so the world creates // the step on the fly. Absent for diff --git a/packages/core/src/runtime/step-executor.test.ts b/packages/core/src/runtime/step-executor.test.ts new file mode 100644 index 0000000000..4cb6f6b8f1 --- /dev/null +++ b/packages/core/src/runtime/step-executor.test.ts @@ -0,0 +1,166 @@ +import { mkdtempSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import type { Event, World } from '@workflow/world'; +import { SPEC_VERSION_CURRENT } from '@workflow/world'; +import { createWorld } from '@workflow/world-local'; +import { afterEach, describe, expect, it } from 'vitest'; +import { registerStepFunction } from '../private.js'; +import { dehydrateStepArguments } from '../serialization.js'; +import { executeStep } from './step-executor.js'; + +// The retry ceiling (`authoritativeAttempt`) is what bounds a step that keeps +// timing out: a timeout hard-kills the body without writing any error, so the +// error-based guards never fire. These tests assert the ceiling is enforced +// BEFORE the body runs, and only once the attempt number actually exceeds +// maxRetries + 1. + +const MAX_RETRIES = 3; // maxRetries + 1 = 4 total attempts allowed + +let counter = 0; +function uniqueStepName(): string { + counter += 1; + return `step//./step-executor-test//timeoutStep${counter}`; +} + +async function setupRunningStep(opts: { + world: World; + stepName: string; + onBody: () => void; +}): Promise<{ runId: string; stepId: string }> { + const { world, stepName, onBody } = opts; + const runInput = await dehydrateStepArguments([], 'run', undefined); + const created = await world.events.create(null, { + eventType: 'run_created', + specVersion: SPEC_VERSION_CURRENT, + eventData: { + deploymentId: 'dpl_test', + workflowName: 'wf', + input: runInput, + }, + }); + const runId = created.run!.runId; + await world.events.create(runId, { + eventType: 'run_started', + specVersion: SPEC_VERSION_CURRENT, + eventData: {}, + } as never); + + const stepId = 'step_timeout_1'; + const stepInput = await dehydrateStepArguments([], runId, undefined); + await world.events.create(runId, { + eventType: 'step_created', + specVersion: SPEC_VERSION_CURRENT, + correlationId: stepId, + eventData: { stepName, input: stepInput }, + }); + + const stepFn = Object.assign( + async () => { + onBody(); + return 'ok'; + }, + { maxRetries: MAX_RETRIES } + ); + registerStepFunction(stepName, stepFn); + + return { runId, stepId }; +} + +function makeWorld(): World { + const dataDir = mkdtempSync(join(tmpdir(), 'wf-step-executor-')); + return createWorld({ dataDir, tag: `t${counter}` }); +} + +async function eventsFor( + world: World, + runId: string, + stepId: string, + eventType: Event['eventType'] +): Promise { + const { data } = await world.events.list({ runId }); + return data.filter( + (e) => e.eventType === eventType && e.correlationId === stepId + ); +} + +describe('executeStep — retry ceiling (authoritativeAttempt)', () => { + afterEach(() => { + counter += 1; + }); + + it('fails the step WITHOUT running the body once the attempt exceeds maxRetries + 1', async () => { + const world = makeWorld(); + const stepName = uniqueStepName(); + let bodyRuns = 0; + const { runId, stepId } = await setupRunningStep({ + world, + stepName, + onBody: () => { + bodyRuns += 1; + }, + }); + + const result = await executeStep({ + world, + workflowRunId: runId, + workflowName: 'wf', + workflowStartedAt: Date.now(), + stepId, + stepName, + // Attempt maxRetries + 2 — one past the last allowed retry. This is the + // delivery a timed-out step would land on with nothing left to try. + authoritativeAttempt: MAX_RETRIES + 2, + }); + + expect(result.type).toBe('failed'); + // The body must NOT run — retries are already exhausted. + expect(bodyRuns).toBe(0); + + // The ceiling fires BEFORE the start block, so no new step_started is + // written for the rejected attempt; the step goes straight to failed. + const started = await eventsFor(world, runId, stepId, 'step_started'); + expect(started).toHaveLength(0); + const failures = await eventsFor(world, runId, stepId, 'step_failed'); + expect(failures).toHaveLength(1); + }); + + it('permits (does not pre-empt) the final allowed attempt (maxRetries + 1)', async () => { + const world = makeWorld(); + const stepName = uniqueStepName(); + let bodyRuns = 0; + const { runId, stepId } = await setupRunningStep({ + world, + stepName, + onBody: () => { + bodyRuns += 1; + }, + }); + + const result = await executeStep({ + world, + workflowRunId: runId, + workflowName: 'wf', + workflowStartedAt: Date.now(), + stepId, + stepName, + // maxRetries + 1 is the last permitted attempt: the ceiling must let it + // proceed into normal execution rather than pre-emptively failing it. + authoritativeAttempt: MAX_RETRIES + 1, + }); + + // It got past the ceiling: the step was started (entered normal execution) + // and was NOT failed by the retry ceiling. + void bodyRuns; + expect(result.type).not.toBe('failed'); + const started = await eventsFor(world, runId, stepId, 'step_started'); + expect(started).toHaveLength(1); + const ceilingFailures = await eventsFor( + world, + runId, + stepId, + 'step_failed' + ); + expect(ceilingFailures).toHaveLength(0); + }); +}); diff --git a/packages/core/src/runtime/step-executor.ts b/packages/core/src/runtime/step-executor.ts index d14329adc2..edbdaf7570 100644 --- a/packages/core/src/runtime/step-executor.ts +++ b/packages/core/src/runtime/step-executor.ts @@ -180,6 +180,29 @@ export interface StepExecutorParams { * runtime/step-latency.ts. */ latencyTracking?: StepLatencyTracking; + /** + * Authoritative attempt number for this execution, used to bound retries + * against `maxRetries` BEFORE the body runs — the only way to cap a step + * that keeps timing out. A timeout hard-kills the function mid-body, so no + * `step_failed`/`step_retrying` is ever written: `step.error` stays null and + * the post-body/error guards never fire, while each redelivery increments + * the start count. Callers supply a count that reflects real attempts: + * + * - Inline (combined handler): the number of `step_started` events already + * in the event log for this step, plus one for the attempt about to run. + * The log is authoritative because the optimistic-start path synthesizes + * `step.attempt = 1` and concurrent double-starts are prevented by the + * atomic create-claim / single-flight. + * - Background (queue-dispatched): the queue delivery count + * (`metadata.attempt`), which increments on every redelivery — including + * the visibility-timeout redelivery that a timed-out step produces. + * + * When this attempt number exceeds `maxRetries + 1` the step has exhausted + * its retries and is failed here, without starting another attempt. Undefined + * on paths that predate this (they fall back to the post-start + * `step.attempt`/`step.error` guards). + */ + authoritativeAttempt?: number; } /** @@ -351,6 +374,75 @@ export async function executeStep( ...Attribute.StepMaxRetries(maxRetries), }); + // Retry ceiling enforced BEFORE starting another attempt. This is what + // bounds a step that keeps timing out: a timeout hard-kills the body with + // no `step_failed`, so the post-body/error guards below never see it and + // `step.error` never gates the count up. `authoritativeAttempt` is the + // real attempt number (see StepExecutorParams.authoritativeAttempt); when + // it exceeds `maxRetries + 1` the step is failed here without running the + // body again. Runs strictly before the start block, so no additional + // `step_started` is written for the rejected attempt — the step already + // exists (it was started on every prior attempt), so `step_failed` is a + // valid terminal write. Thrown-error exhaustion still terminates one + // attempt earlier via the post-body guard (with the thrown error as + // cause), so this check does not change that path. + if ( + params.authoritativeAttempt !== undefined && + params.authoritativeAttempt > maxRetries + 1 + ) { + const retryCount = maxRetries; + const errorMessage = `Step "${stepName}" exceeded max retries (${retryCount} ${pluralize('retry', 'retries', retryCount)})`; + stepLogger.error('Step exceeded max retries', { + workflowRunId, + stepName, + stepId, + attempt: params.authoritativeAttempt, + maxRetries, + }); + try { + await world.events.create(workflowRunId, { + eventType: 'step_failed', + specVersion: SPEC_VERSION_CURRENT, + correlationId: stepId, + eventData: { + stepName, + error: await dehydrateStepError( + new FatalError(errorMessage), + workflowRunId, + await getEncryptionKey(), + [], + globalThis, + compression + ), + }, + }); + } catch (err) { + if (EntityConflictError.is(err)) { + // Step already reached a terminal state (a concurrent handler or an + // earlier delivery failed/completed it) — nothing to do. + runtimeLogger.info( + 'Tried failing step for exceeded retries, but step has already finished.', + { + workflowRunId, + stepId, + stepName, + message: err instanceof Error ? err.message : String(err), + } + ); + return { type: 'skipped' }; + } + if (RunExpiredError.is(err)) { + return { type: 'gone' }; + } + throw err; + } + span?.setAttributes({ + ...Attribute.StepStatus('failed'), + ...Attribute.StepRetryExhausted(true), + }); + return { type: 'failed' }; + } + // Maps a `step_started` rejection to a terminal StepExecutionResult, // shared by the await path (below) and the optimistic-start reconciliation. // Returns undefined when the error is not one we translate (caller rethrows). From 67b508291ee34ff7cf6a623a60d992aec3762000 Mon Sep 17 00:00:00 2001 From: Peter Wielander Date: Tue, 21 Jul 2026 13:29:13 -0700 Subject: [PATCH 2/4] Apply suggestions from code review Co-authored-by: Peter Wielander Signed-off-by: Peter Wielander --- .changeset/step-maxretries-timeout.md | 2 +- packages/core/src/runtime.ts | 27 ++++++--------- packages/core/src/runtime/step-executor.ts | 39 ++++++---------------- 3 files changed, 22 insertions(+), 46 deletions(-) diff --git a/.changeset/step-maxretries-timeout.md b/.changeset/step-maxretries-timeout.md index bcab509b0c..38916d5546 100644 --- a/.changeset/step-maxretries-timeout.md +++ b/.changeset/step-maxretries-timeout.md @@ -2,4 +2,4 @@ '@workflow/core': patch --- -Enforce `maxRetries` for steps that time out. A step killed by the function timeout writes no error, so retries were previously unbounded; the retry ceiling is now enforced from the event-log start count (inline) and the queue delivery count (background) before each attempt runs. +Enforce `maxRetries` for inline and backgrounded steps that time out diff --git a/packages/core/src/runtime.ts b/packages/core/src/runtime.ts index 6fff7feadd..ebfbb6cc58 100644 --- a/packages/core/src/runtime.ts +++ b/packages/core/src/runtime.ts @@ -251,8 +251,7 @@ function hasRecordedTerminalRunEvent(events: Event[], runId: string): boolean { * authoritative attempt count for the inline retry ceiling. Each real attempt * writes exactly one `step_started` (the atomic create-claim / single-flight * prevents concurrent double-starts from inflating this), so the count equals - * the number of attempts that have begun. `undefined` events (no log loaded - * yet) count as zero. + * the number of attempts that have begun. */ function countStepStartedEvents( events: Event[] | null | undefined, @@ -776,12 +775,13 @@ export function workflowEntrypoint( stepName: incomingStepName, runSpecVersion: bgRun.specVersion, // A backgrounded step is redelivered as the same - // queue message on every retry — including the - // visibility-timeout redelivery a timed-out step - // produces — so the delivery count is the + // queue message on every retry, so the delivery count // authoritative attempt number that bounds - // maxRetries (a timeout writes no error, so the - // error-based guards can't). + // maxRetries. Step timeout don't write errors, so we can't + // use them for this purpose. + // NOTE that throttle/too-early redeliveries of the queue would + // consume re-tries. Getting an authoritative source would require + // loading the full event log, so we accept this low-risk of false positives. authoritativeAttempt: metadata.attempt, }) ); @@ -2274,16 +2274,9 @@ export function workflowEntrypoint( stepId: s.correlationId, stepName: s.stepName, runSpecVersion: workflowRun.specVersion, - // Authoritative attempt number for the retry - // ceiling: the step_started events already in - // the event log for this step, plus the one - // this execution is about to write. This is the - // only signal that bounds an inline step that - // keeps timing out — the flow invocation is - // redelivered and re-runs the step (appending a - // step_started) with no error recorded, and the - // optimistic-start path synthesizes - // step.attempt = 1. + // Attempt number is step_started event count + // +1 for the one that the current execution is + // about to write. authoritativeAttempt: countStepStartedEvents( cachedEvents, diff --git a/packages/core/src/runtime/step-executor.ts b/packages/core/src/runtime/step-executor.ts index edbdaf7570..55a9a5c729 100644 --- a/packages/core/src/runtime/step-executor.ts +++ b/packages/core/src/runtime/step-executor.ts @@ -182,25 +182,13 @@ export interface StepExecutorParams { latencyTracking?: StepLatencyTracking; /** * Authoritative attempt number for this execution, used to bound retries - * against `maxRetries` BEFORE the body runs — the only way to cap a step - * that keeps timing out. A timeout hard-kills the function mid-body, so no - * `step_failed`/`step_retrying` is ever written: `step.error` stays null and - * the post-body/error guards never fire, while each redelivery increments - * the start count. Callers supply a count that reflects real attempts: - * + * against `maxRetries` BEFORE the body runs. In order to also catch + * step timeouts (which we can't have catch handlers for), we determine + * the attempt count based as follows: * - Inline (combined handler): the number of `step_started` events already * in the event log for this step, plus one for the attempt about to run. - * The log is authoritative because the optimistic-start path synthesizes - * `step.attempt = 1` and concurrent double-starts are prevented by the - * atomic create-claim / single-flight. * - Background (queue-dispatched): the queue delivery count - * (`metadata.attempt`), which increments on every redelivery — including - * the visibility-timeout redelivery that a timed-out step produces. - * - * When this attempt number exceeds `maxRetries + 1` the step has exhausted - * its retries and is failed here, without starting another attempt. Undefined - * on paths that predate this (they fall back to the post-start - * `step.attempt`/`step.error` guards). + * (`metadata.attempt`), which increments on every redelivery. */ authoritativeAttempt?: number; } @@ -374,18 +362,13 @@ export async function executeStep( ...Attribute.StepMaxRetries(maxRetries), }); - // Retry ceiling enforced BEFORE starting another attempt. This is what - // bounds a step that keeps timing out: a timeout hard-kills the body with - // no `step_failed`, so the post-body/error guards below never see it and - // `step.error` never gates the count up. `authoritativeAttempt` is the - // real attempt number (see StepExecutorParams.authoritativeAttempt); when - // it exceeds `maxRetries + 1` the step is failed here without running the - // body again. Runs strictly before the start block, so no additional - // `step_started` is written for the rejected attempt — the step already - // exists (it was started on every prior attempt), so `step_failed` is a - // valid terminal write. Thrown-error exhaustion still terminates one - // attempt earlier via the post-body guard (with the thrown error as - // cause), so this check does not change that path. + // maxRetries enforced before starting another attempt. Timeouts can kill + // a step execution without any way to catch the error, so the post-body/error + // guards below might miss it. Hence, we use `authoritativeAttempt` count + // as an additional guard based on step_started count or queue delivery count + // on backgrounded steps. + // Thrown-error exhaustion still terminates one attempt earlier via the post-body + // guard (with the thrown error as cause), and this check does not affect that. if ( params.authoritativeAttempt !== undefined && params.authoritativeAttempt > maxRetries + 1 From f7f8745de0c89bfe604aa18bd4d647fc2f9c5509 Mon Sep 17 00:00:00 2001 From: Peter Wielander Date: Tue, 21 Jul 2026 14:50:31 -0700 Subject: [PATCH 3/4] [core] Verify step_started count before failing a backgrounded step MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `metadata.attempt` (queue delivery count) also advances for redeliveries that never run the step body (ThrottleError, TooEarlyError, other pre-body failures), so trusting it directly could fail a step as "exceeded max retries" before the body ever ran. Use the delivery count only as a fast gate: while it is at or under the ceiling the step can't be exhausted, so proceed without touching the log. Only once it crosses the ceiling, load the full event log and derive the authoritative attempt from the recorded step_started count (which only real attempts write) — excluding throttle/too-early redeliveries. The load also primes the replay's cachedEvents/eventsCursor. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/core/src/runtime.ts | 51 +++++++++++++++++----- packages/core/src/runtime/step-executor.ts | 2 +- 2 files changed, 42 insertions(+), 11 deletions(-) diff --git a/packages/core/src/runtime.ts b/packages/core/src/runtime.ts index ebfbb6cc58..6c1db2b795 100644 --- a/packages/core/src/runtime.ts +++ b/packages/core/src/runtime.ts @@ -61,7 +61,11 @@ import { ReplayBudget, } from './runtime/replay-budget.js'; import { runIdCreatedAt } from './runtime/run-id-time.js'; -import { executeStep } from './runtime/step-executor.js'; +import { + DEFAULT_STEP_MAX_RETRIES, + executeStep, +} from './runtime/step-executor.js'; +import { getStepFunction } from './private.js'; import { computeStepLatencyTracking } from './runtime/step-latency.js'; import { backstopIdempotencyKey, @@ -743,6 +747,38 @@ export function workflowEntrypoint( const bgStartedAt = bgRun.startedAt ? +bgRun.startedAt : Date.now(); + + // Retry ceiling for a backgrounded step. `metadata.attempt` + // (the queue delivery count) is a cheap upper bound, but it + // over-counts: a ThrottleError / TooEarlyError — or any + // redelivery that never ran the body — still advances it, so + // trusting it directly could fail a step as "exceeded max + // retries" before the body ever ran (a user-visible + // regression under transient backend pressure). Use it only + // as a fast gate: while it is at or under the ceiling the + // step cannot be exhausted, so proceed without touching the + // log. Only once it crosses the ceiling do we load the full + // event log and derive the authoritative attempt from the + // recorded `step_started` count — the count only real + // attempts write, so throttle/too-early redeliveries are + // excluded. This still bounds timeouts, which write no error + // for the post-body guard to catch. The load also primes the + // replay's `cachedEvents`/`eventsCursor` (the post-step + // continuation below refreshes them once the step's terminal + // event lands). + let bgAuthoritativeAttempt = metadata.attempt; + const bgMaxRetries = + getStepFunction(incomingStepName)?.maxRetries ?? + DEFAULT_STEP_MAX_RETRIES; + if (metadata.attempt > bgMaxRetries + 1) { + const loaded = await loadWorkflowRunEvents(runId); + cachedEvents = loaded.events; + eventsCursor = loaded.cursor; + bgAuthoritativeAttempt = + countStepStartedEvents(cachedEvents, incomingStepId) + + 1; + } + // Pause the replay budget while the step body runs — // step duration is bounded by the platform's function // maxDuration, not by the replay timeout. See the @@ -774,15 +810,10 @@ export function workflowEntrypoint( stepId: incomingStepId, stepName: incomingStepName, runSpecVersion: bgRun.specVersion, - // A backgrounded step is redelivered as the same - // queue message on every retry, so the delivery count - // authoritative attempt number that bounds - // maxRetries. Step timeout don't write errors, so we can't - // use them for this purpose. - // NOTE that throttle/too-early redeliveries of the queue would - // consume re-tries. Getting an authoritative source would require - // loading the full event log, so we accept this low-risk of false positives. - authoritativeAttempt: metadata.attempt, + // Retry ceiling: the queue delivery count as a fast + // gate, verified against the recorded step_started + // count once it crosses the ceiling (see above). + authoritativeAttempt: bgAuthoritativeAttempt, }) ); } finally { diff --git a/packages/core/src/runtime/step-executor.ts b/packages/core/src/runtime/step-executor.ts index 55a9a5c729..c46068c472 100644 --- a/packages/core/src/runtime/step-executor.ts +++ b/packages/core/src/runtime/step-executor.ts @@ -51,7 +51,7 @@ import { } from './step-latency.js'; import { safeWaitUntil } from './wait-until.js'; -const DEFAULT_STEP_MAX_RETRIES = 3; +export const DEFAULT_STEP_MAX_RETRIES = 3; /** * Extract the inline delta from a step-terminal `events.create` result, From 75b39f324d7eb7438add52bb46c71a62491d970a Mon Sep 17 00:00:00 2001 From: Peter Wielander Date: Tue, 21 Jul 2026 16:00:42 -0700 Subject: [PATCH 4/4] [core] Skip the event-log scan for brand-new inline steps MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Deriving an inline step's attempt number by scanning the cumulative event log for step_started events ran for every inline execution, which is O(n²) across a long sequential workflow. A lazy inline step is brand-new by construction (it only enters the batch with no step_created yet), so it has zero prior starts and is always attempt 1 — no scan needed. Reserve the scan for owned-recovery re-runs (this message re-executing a step it crashed/timed out on), which are uncommon and few per batch. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/core/src/runtime.ts | 25 ++++++++++++++++++------- 1 file changed, 18 insertions(+), 7 deletions(-) diff --git a/packages/core/src/runtime.ts b/packages/core/src/runtime.ts index 6c1db2b795..cdc2282da5 100644 --- a/packages/core/src/runtime.ts +++ b/packages/core/src/runtime.ts @@ -2305,14 +2305,25 @@ export function workflowEntrypoint( stepId: s.correlationId, stepName: s.stepName, runSpecVersion: workflowRun.specVersion, - // Attempt number is step_started event count - // +1 for the one that the current execution is - // about to write. + // Attempt number = prior step_started count + 1 + // (this execution's start). A lazy step is + // brand-new by construction (it enters the batch + // only when it has no step_created yet), so it + // has zero prior starts and is always attempt 1 — + // skip the log scan entirely. Only an + // owned-recovery re-run (this message re-executing + // a step it crashed/timed out on) can have prior + // starts, and that path is uncommon, so reserve + // the O(n) scan for it rather than walking the + // growing log for every inline step (which would + // be O(n²) across a long sequential workflow). authoritativeAttempt: - countStepStartedEvents( - cachedEvents, - s.correlationId - ) + 1, + s.lazyStepInput !== undefined + ? 1 + : countStepStartedEvents( + cachedEvents, + s.correlationId + ) + 1, // Lazy inline start: send the deferred step's // input on step_started so the world creates // the step on the fly. Absent for