') + ')', 'gi'); if (regex.test(text)) { found = true; var frag = document.createDocumentFragment(); var parts = text.split(regex); parts.forEach(function(part, i) { if (i % 2 === 0) { frag.appendChild(document.createTextNode(part)); } else { var span = document.createElement('span'); span.className = 'userscript-highlight'; span.textContent = part; frag.appendChild(span); } }); node.parentNode.replaceChild(frag, node); } }); } else if (node.nodeType === 1 && node.childNodes) { // element var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT']; if (!skipTags.includes(node.tagName)) { Array.from(node.childNodes).forEach(highlight); } } } highlight(document.body); // Re-highlight on dynamic content var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1 || node.nodeType === 3) highlight(node); }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ', 'i'); if (__m === '*' || __re.test(location.href)) { // Strip utm_, fbclid, gclid, etc. from all links on page (function() { var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content', 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid', 'ref', 'ref_src', 'source', 'medium', 'campaign']; function cleanUrl(url) { try { var u = new URL(url, window.location.origin); var changed = false; trackingParams.forEach(function(p) { if (u.searchParams.has(p)) { u.searchParams.delete(p); changed = true; } }); return changed ? u.toString() : url; } catch (e) { return url; } } function cleanLinks() { document.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } cleanLinks(); var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1) { if (node.tagName === 'A') cleanLinks(); node.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + ', 'i'); if (__m === '*' || __re.test(location.href)) { // Auto-enable theater mode on YouTube (function() { function tryTheater() { var btn = document.querySelector('button[aria-label="Theater mode"], ytd-player #player button[title="Theater mode"]'); if (btn && !btn.classList.contains('activated')) { btn.click(); } } // Try immediately tryTheater(); // Try after navigation (SPA) var lastUrl = location.href; setInterval(function() { if (location.href !== lastUrl) { lastUrl = location.href; setTimeout(tryTheater, 500); } }, 1000); // Also try on player load var observer = new MutationObserver(tryTheater); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ', 'i'); if (__m === '*' || __re.test(location.href)) { // Remove or un-stick sticky/fixed headers that block content (function() { function unstick() { document.querySelectorAll('header, nav, [role="banner"], .header, .navbar, .sticky, .fixed-top, [style*="position: fixed"], [style*="position:sticky"]').forEach(function(el) { if (el.style.position === 'fixed' || el.style.position === 'sticky' || getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') { el.style.position = 'static'; el.style.top = 'auto'; el.style.zIndex = 'auto'; } }); } unstick(); var observer = new MutationObserver(unstick); observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] }); })(); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); })(); Carry run identity on step-dispatch messages; drop the blocking runs.get from the queued-step prologue by TooTallNate · Pull Request #3457 · vercel/workflow · GitHub
Skip to content

Carry run identity on step-dispatch messages; drop the blocking runs.get from the queued-step prologue - #3457

Open
TooTallNate wants to merge 2 commits into
mainfrom
step-dispatch-run-context
Open

Carry run identity on step-dispatch messages; drop the blocking runs.get from the queued-step prologue#3457
TooTallNate wants to merge 2 commits into
mainfrom
step-dispatch-run-context

Conversation

@TooTallNate

Copy link
Copy Markdown
Member

Summary

Closes#3456. Stacked on #3365 (base: resilient-step-dispatch) — same code region, and the sweep data motivating both came from that PR's benchmarking.

Every queued step execution paid a blocking world.runs.get before its step_started claim: one round trip per branch on the TTLS-critical path (~30–80ms p50), and under a 256-branch fan-out burst the read amplification drove that read to p90 ≈ 5.1s (durabench parallel sweeps, wrun_41KZR0MW890GWZK34RD4Y1JBDT), directly smearing branch starts across the ~17s TTLS cliff.

What changed

  • @workflow/world: additive WorkflowInvokePayload.runContext (deploymentId, specVersion, startedAt epoch-ms, rootRunId) — the run's immutable identity, stamped at dispatch time from the run row the producer already holds. Run status is deliberately not carried: liveness is enforced by the step_started claim, which every World rejects on a terminal run (RunExpiredgone, terminal step → skipped) — same outcome as the old status check, minus the read.
  • Producers (node dispatch loop, delayed retries, the suspension handler's resilient publish, quickjs queueStepMessage): stamp runContext.
  • Consumer: with runContext, the prologue makes zero readsguardDeployment takes the carried identity (Pick<WorkflowRun, 'runId'|'deploymentId'|'specVersion'> is all it needs), executeStep params come from the message, and only the fan-out's last completer fetches the full run row, lazily, for its inline replay: once per fan-out instead of once per branch. Legacy messages (no runContext) keep the exact previous path; messages are deployment-pinned so mixed handling within a run cannot occur.
  • The deployment-mismatch re-route now preserves stepInput/runContext on the re-enqueued payload (previously dropped).

Wins

Testing

  • 3 new consumer tests: runContextruns.get never called (start path); legacy message ⇒ exactly one fetch; runContext + in-band step-missing recovery combined (still zero fetches).
  • Producer assertion: resilient publishes carry runContext.
  • Full suites green: 2029 core / 99 world.

Verification plan: re-run the durabench parallel sweep at {64, 256} against this branch — expect TTFS/TTLS p50 improvement at 256 branches and no change in semantics elsewhere.

@TooTallNate
TooTallNate requested a review from a team as a code ownerAugust 11, 2026 09:52
CopilotAI lite review requested due to automatic review settings August 11, 2026 09:52
@vercel

vercelBot commented Aug 11, 2026

Copy link
Copy Markdown
Contributor

The latest updates on your projects. Learn more about Vercel for GitHub.

ProjectDeploymentActionsUpdated (UTC)
example-nextjs-workflow-turbopackReadyReadyPreviewAug 11, 2026 8:01pm
example-nextjs-workflow-webpackReadyReadyPreviewAug 11, 2026 8:01pm
example-workflowReadyReadyPreviewAug 11, 2026 8:01pm
workbench-astro-workflowReadyReadyPreviewAug 11, 2026 8:01pm
workbench-express-workflowReadyReadyPreviewAug 11, 2026 8:01pm
workbench-fastify-workflowReadyReadyPreviewAug 11, 2026 8:01pm
workbench-hono-workflowReadyReadyPreviewAug 11, 2026 8:01pm
workbench-nestjs-workflowReadyReadyPreviewAug 11, 2026 8:01pm
workbench-nitro-workflowReadyReadyPreviewAug 11, 2026 8:01pm
workbench-nuxt-workflowReadyReadyPreviewAug 11, 2026 8:01pm
workbench-python-workflowErrorErrorAug 11, 2026 8:01pm
workbench-sveltekit-workflowReadyReadyPreviewAug 11, 2026 8:01pm
workbench-tanstack-start-workflowReadyReadyPreviewAug 11, 2026 8:01pm
workbench-vite-workflowReadyReadyPreviewAug 11, 2026 8:01pm
workflow-docsReadyReadyPreview, v0Aug 11, 2026 8:01pm
workflow-swc-playgroundReadyReadyPreviewAug 11, 2026 8:01pm
workflow-tarballsReadyReadyPreviewAug 11, 2026 8:01pm
workflow-webReadyReadyPreviewAug 11, 2026 8:01pm

@changeset-bot

changeset-botBot commented Aug 11, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 03bf52c

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 20 packages
NameType
@workflow/worldMinor
@workflow/coreMinor
@workflow/world-localPatch
@workflow/world-postgresPatch
@workflow/cliPatch
@workflow/vitestPatch
@workflow/web-sharedPatch
@workflow/webPatch
@workflow/world-testingPatch
@workflow/world-vercelPatch
@workflow/buildersPatch
@workflow/nextPatch
@workflow/nitroPatch
workflowMinor
@workflow/astroPatch
@workflow/nestPatch
@workflow/rollupPatch
@workflow/sveltekitPatch
@workflow/vitePatch
@workflow/nuxtPatch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@github-actions

github-actionsBot commented Aug 11, 2026

Copy link
Copy Markdown
Contributor

🧪 E2E Test Results

Some tests failed

❌ Failed E2E Tests

▲ Vercel Production (1 failed)

fastify-quickjs (1 failed):

  • parallelStepsThenWebhookWorkflow - no hook_conflict from same-tick replay race | wrun_41KZS6M9550GVJE4XA97AMBF17 | 🔍 observability

E2E Test Summary

Summary
PassedFailedSkippedTotal
❌ ▲ Vercel Production346515904056
✅ 💻 Local Development336105393900
✅ 📦 Local Production381005584368
✅ 🐘 Local Postgres381005584368
✅ 🪟 Windows31200312
✅ vercel-multi-region270027
Total147851224517031
Details by Category

❌ ▲ Vercel Production

AppPassedFailedSkipped
✅ astro-node128028
✅ astro-quickjs128028
✅ example-node128028
✅ example-quickjs128028
✅ express-node128028
✅ express-quickjs128028
✅ fastify-node128028
❌ fastify-quickjs127128
✅ hono-node128028
✅ hono-quickjs128028
✅ nest-node128028
✅ nest-quickjs128028
✅ nextjs-turbopack-node15303
✅ nextjs-turbopack-quickjs15303
✅ nextjs-webpack-node15303
✅ nextjs-webpack-quickjs15303
✅ nitro-node128028
✅ nitro-quickjs128028
✅ nuxt-node128028
✅ nuxt-quickjs128028
✅ sveltekit-node14709
✅ sveltekit-quickjs14709
✅ tanstack-start-node128028
✅ tanstack-start-quickjs128028
✅ vite-node128028
✅ vite-quickjs128028

✅ 💻 Local Development

AppPassedFailedSkipped
✅ astro-stable-node130026
✅ astro-stable-quickjs130026
✅ express-stable-node130026
✅ express-stable-quickjs130026
✅ fastify-stable-node130026
✅ fastify-stable-quickjs130026
✅ hono-stable-node130026
✅ hono-stable-quickjs130026
✅ nest-stable-node130026
✅ nest-stable-quickjs130026
✅ nextjs-turbopack-canary-node137019
✅ nextjs-turbopack-canary-quickjs137019
✅ nextjs-turbopack-stable-quickjs15600
✅ nextjs-webpack-canary-node137019
✅ nextjs-webpack-stable-quickjs15600
✅ nitro-stable-node130026
✅ nitro-stable-quickjs130026
✅ nuxt-stable-node130026
✅ nuxt-stable-quickjs130026
✅ sveltekit-stable-node14907
✅ sveltekit-stable-quickjs14907
✅ tanstack-start-node130026
✅ tanstack-start-quickjs130026
✅ vite-stable-node130026
✅ vite-stable-quickjs130026

✅ 📦 Local Production

AppPassedFailedSkipped
✅ astro-stable-node130026
✅ astro-stable-quickjs130026
✅ express-stable-node130026
✅ express-stable-quickjs130026
✅ fastify-stable-node130026
✅ fastify-stable-quickjs130026
✅ hono-stable-node130026
✅ hono-stable-quickjs130026
✅ nest-stable-node130026
✅ nest-stable-quickjs130026
✅ nextjs-turbopack-canary-node137019
✅ nextjs-turbopack-canary-quickjs137019
✅ nextjs-turbopack-stable-node15600
✅ nextjs-turbopack-stable-quickjs15600
✅ nextjs-webpack-canary-node137019
✅ nextjs-webpack-canary-quickjs137019
✅ nextjs-webpack-stable-node15600
✅ nextjs-webpack-stable-quickjs15600
✅ nitro-stable-node130026
✅ nitro-stable-quickjs130026
✅ nuxt-stable-node130026
✅ nuxt-stable-quickjs130026
✅ sveltekit-stable-node14907
✅ sveltekit-stable-quickjs14907
✅ tanstack-start-node130026
✅ tanstack-start-quickjs130026
✅ vite-stable-node130026
✅ vite-stable-quickjs130026

✅ 🐘 Local Postgres

AppPassedFailedSkipped
✅ astro-stable-node130026
✅ astro-stable-quickjs130026
✅ express-stable-node130026
✅ express-stable-quickjs130026
✅ fastify-stable-node130026
✅ fastify-stable-quickjs130026
✅ hono-stable-node130026
✅ hono-stable-quickjs130026
✅ nest-stable-node130026
✅ nest-stable-quickjs130026
✅ nextjs-turbopack-canary-node137019
✅ nextjs-turbopack-canary-quickjs137019
✅ nextjs-turbopack-stable-node15600
✅ nextjs-turbopack-stable-quickjs15600
✅ nextjs-webpack-canary-node137019
✅ nextjs-webpack-canary-quickjs137019
✅ nextjs-webpack-stable-node15600
✅ nextjs-webpack-stable-quickjs15600
✅ nitro-stable-node130026
✅ nitro-stable-quickjs130026
✅ nuxt-stable-node130026
✅ nuxt-stable-quickjs130026
✅ sveltekit-stable-node14907
✅ sveltekit-stable-quickjs14907
✅ tanstack-start-node130026
✅ tanstack-start-quickjs130026
✅ vite-stable-node130026
✅ vite-stable-quickjs130026

✅ 🪟 Windows

AppPassedFailedSkipped
✅ nextjs-turbopack-node15600
✅ nextjs-turbopack-quickjs15600

✅ vercel-multi-region

AppPassedFailedSkipped
✅ nextjs-turbopack2700

📋 View full workflow run

CopilotAI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR reduces queued step start latency and read amplification by carrying immutable run identity (runContext) on step-dispatch messages so the consumer can skip the blocking world.runs.get in the queued-step prologue, while still lazily fetching the run row only for the fan-out’s last completer.

Changes:

  • Added WorkflowInvokePayload.runContext (deploymentId/specVersion/startedAt/rootRunId) to the @workflow/world queue message schema.
  • Updated all step-dispatch producers (node suspension handler, QuickJS step queueing, runtime retry dispatch) to stamp runContext, and updated deployment-mismatch re-enqueue to preserve stepInput/runContext.
  • Updated the queued-step consumer path to use runContext to avoid runs.get, with new tests covering both runContext and legacy-message behavior.

Reviewed changes

Copilot reviewed 8 out of 8 changed files in this pull request and generated no comments.

Show a summary per file
FileDescription
packages/world/src/queue.tsIntroduces RunDispatchContextSchema and adds runContext to WorkflowInvokePayloadSchema.
packages/core/src/runtime/suspension-handler.tsStamps runContext on step-dispatch messages produced during suspension handling.
packages/core/src/runtime/suspension-handler.test.tsAsserts resilient publishes include the expected runContext.
packages/core/src/runtime/quickjs-entrypoint.tsStamps runContext on QuickJS step-dispatch messages.
packages/core/src/runtime/helpers.tsAdds helpers to compute rootRunId and build runDispatchContext from a run row.
packages/core/src/runtime.tsRemoves the queued-step prologue runs.get when runContext is present; preserves payload on re-route; lazy-fetches run row only for inline replay synthesis.
packages/core/src/runtime.test.tsAdds consumer tests verifying runs.get is skipped with runContext and still occurs for legacy messages.
.changeset/step-dispatch-run-context.mdDeclares minor bumps for @workflow/world and @workflow/core describing the new behavior.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

@TooTallNate

Copy link
Copy Markdown
MemberAuthor

Durabench verification (sweep psweep-1786442289510, node engine, n=15/cell, iad1)

brversionTTLS p50p75p95TTFS p50skew p99
64baseline beta.40138415193715856526
64this PR133714291693742516
256baseline beta.4016608194382010932019602
256this PR16116168721953216679151

No regressions at either scale; semantics unchanged for legacy (no-runContext) messages.

@VaguelySeriousVaguelySerious left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

AI review: no blocking issues

Comment threadpackages/core/src/runtime.ts Outdated
(await world.runs.get(runId, {
resolveData: 'none',
}));
if (replayRunRow.status !== 'running') {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

AI Review: Note

This gate also returns on pending, and it returns silently. Under the fetch-free prologue this is the only status read the last completer performs, so a stale pending here abandons the fan-out's continuation: the final step_completed is already written, the inline replay never runs, and nothing is logged. The legacy prologue's equivalent early exit (line 1532) at least logs the observed status, so the same stall was diagnosable before.

This is not a regression in outcome (the legacy read would also have returned on a stale pending), but it moves the drop to a quieter place. Two small changes keep the intent and make it debuggable:

  • gate on isTerminalWorkflowRunStatus(replayRunRow.status) (exported from @workflow/world, already used in runtime/resume-hook.ts) so a non-terminal read falls through to the replay rather than dropping it;
  • add a runtimeLogger.debug with the observed status on the early exit, matching line 1533.

I reproduced the stall locally — see the note on runtime.test.ts.

Copy link
Copy Markdown
MemberAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed on both counts — and thanks for reproducing the stall; that made the severity unambiguous. Fixed in 03bf52c: the gate is now isTerminalWorkflowRunStatus(replayRunRow.status) with a runtimeLogger.debug logging the observed status on the early exit. A stale pending falls through to the inline replay (a run with completed steps has necessarily started, so pending here can only be a stale row), and the replay's next entity write is fenced server-side if the run truly ended meanwhile. Covered by the new two-phase fan-out test: the stale-pending variant asserts run_completed is still written, and it fails without this fix.

Comment threadpackages/core/src/runtime.ts Outdated
// fan-out (vercel/workflow#3456). The run-status early
// exit is not lost: a terminal run rejects the
// `step_started` claim server-side (RunExpired → gone,
// terminal step → skipped). Older messages without the

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

AI Review: Note

"a terminal run rejects the step_started claim server-side" holds on world-vercel, but it is overbroad for the adapters in this repo. world-local (storage/events-storage.ts:996) and world-postgres (storage.ts:858) only raise RunExpiredError on a terminal run when the step's own status is not already running. A redelivery of a step that a previous delivery had already started therefore passes the claim on a cancelled/completed run and executes the user's step body, where the old prologue's status check would have skipped it. The result is discarded at the step_completed write, so nothing corrupts, but the side effects run.

Worth either narrowing this comment to the adapters that enforce it, or adding the run-status check to those two.

Copy link
Copy Markdown
MemberAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You're right that the claim was overbroad — and rather than narrowing the comment, I closed the adapter gap in 03bf52c: world-local and world-postgres now reject step_started on a terminal run even when the step row still reads running. Starting work on a finished run is never valid (the carve-out exists so in-flight steps can write their terminal events, and step_completed/step_failed remain unchanged), so a redelivered start on a cancelled/completed run now gets RunExpiredErrorgone → ack instead of re-running the body with an unconsumable outcome. Both worlds' suites pass (542 local / 179 postgres incl. the shared spec suite), a patch changeset covers the behavior change, and the prologue comment now states the contract precisely — including that this change is what makes it hold on the local adapters.

);
return;
}
runIdentity = runContext;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

AI Review: Note

Nothing distinguishes the fetch-free path from the legacy one in telemetry, so neither adoption nor the claimed round-trip saving is measurable after rollout. During a skew window both paths run concurrently across deployments, and the only way to tell them apart will be inference from runs.get volume. A span attribute on the step-execution span (Attribute.StepResilientDispatchMaterialized is the existing precedent) would make this observable for the cost of one line.

Copy link
Copy Markdown
MemberAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added in 03bf52c: workflow.step.dispatch_prologue span attribute (run_context | runs_get), set on the step-execution handler span right where the prologue forks — one line, following the StepResilientDispatchMaterialized precedent. Adoption and the saved round trip are now directly queryable during skew windows.


expect(response.status).toBe(204);
// The step executed to completion with the run identity from the message
// — no run fetch on the start path. (The all-done inline replay would

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

AI Review: Note

The comment is accurate, and it marks the gap: the lazy runs.get at runtime.ts:1777 and the new status gate at :1782 are the fan-out path this PR exists to optimize, and no test in the PR reaches them, because the harness always keeps an unrelated step pending.

I covered it locally with a two-phase test (not committed): phase 1 drives a real replay of a two-step Promise.all fan-out with WORKFLOW_MAX_INLINE_STEPS=1 so the runtime itself emits the queued step message and its seeded correlation id; phase 2 redelivers that exact message against the shared event log, making it the last completer. Results:

  • the producer stamps runContext (deploymentId, specVersion, rootRunId) on the queued message;
  • the last completer calls runs.get exactly once — zero reads before the step, one for the inline replay — and reaches run_completed;
  • with the lazy read returning pending, step_completed is written and run_completed never is: the run is silently abandoned. That is the empirical basis for the note on runtime.ts:1782.

The harness needs no new fixtures, just a fan-out workflow and a shared in-memory event log across the two deliveries — worth adding, since the third case is the one behavior change here that no existing test would catch.

Copy link
Copy Markdown
MemberAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added in 03bf52c, following your two-phase construction: phase 1 drives a real replay of a two-step Promise.all fan-out with WORKFLOW_MAX_INLINE_STEPS=1 (asserting the runtime's own queued message carries the stamped runContext), phase 2 redelivers that exact message against the shared event log as the last completer. Three variants: happy path (zero reads before the step, exactly one lazy runs.get, run_completed written), the stale-pending fall-through (passes only with the isTerminalWorkflowRunStatus gate — your reproduced stall, now pinned), and the genuinely-terminal skip. Thanks for the harness sketch — the shared-log two-delivery shape dropped in cleanly next to the existing suites.

if (runContext) {
const ensureOutcome =
stepInput && metadata.attempt > 1
? await ensureStepFromMessage()

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

AI Review: Nit

The rationale above this call (line 1416: "in parallel with the run fetch below … at no wall-time cost") no longer describes this branch, where there is no run fetch to overlap with. The cost is unchanged (one round trip before step_started either way), so this is comment drift only — but on the fetch-free path the eager re-ensure is now the sole pre-step write, which makes it worth restating why it is still preferred over letting the in-band recovery handle a missing step_created.

Copy link
Copy Markdown
MemberAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Comment drift fixed in 03bf52c: the doc now states both shapes — on the legacy prologue the eager ensure overlaps the run fetch (no wall-time cost); on the fetch-free path it is the sole pre-step write and is kept because a redelivered dispatch has already had its create race resolved, so one conditional write is cheaper than letting the bare start fail and paying the in-band recovery's extra start round trip. The fork site in the runContext branch restates the same rationale.

Base automatically changed from resilient-step-dispatch to mainAugust 11, 2026 19:34
…king runs.get from the consumer prologue
Closes#3456. Every queued step execution paid a runs.get round trip
before its step_started claim — one RTT per branch on the TTLS-critical
path, and under a 256-branch fan-out burst the read amplification drove
that read to p90 ~5.1s (durabench parallel sweeps), smearing branch
starts.
The dispatch sites (node dispatch loop, delayed retries, the suspension
handler's resilient publish, and the quickjs engine's queueStepMessage)
now stamp WorkflowInvokePayload.runContext with the fields the consumer
actually needs — deploymentId, specVersion, startedAt, rootRunId — all
immutable for the life of a run and known from the run row the producer
already holds. A consumer that receives it skips the run fetch: the
run-status early exit is enforced by the step_started claim itself
(RunExpired → gone, terminal step → skipped), guardDeployment takes the
carried identity, and only the fan-out's LAST completer fetches the full
run row, lazily, for its inline replay — once per fan-out instead of
once per branch. The deployment-mismatch re-route now also preserves
stepInput/runContext on the re-enqueued payload.
Messages without runContext (older producers) keep the legacy prologue;
messages are deployment-pinned, so mixed handling within one run cannot
occur.
…nce in local worlds, prologue telemetry, last-completer coverage
- The last completer's lazy runs.get result is now gated on
isTerminalWorkflowRunStatus (with a debug log): a stale 'pending' read
— a run with completed steps has necessarily started — no longer
silently abandons the fan-out's continuation; it falls through to the
inline replay, whose next entity write is fenced server-side if the
run truly ended meanwhile.
- world-local / world-postgres now reject step_started on terminal runs
even when the step row still reads 'running' (a redelivered start a
previous delivery claimed): starting work on a finished run is never
valid, and previously the body re-ran with its outcome unconsumable.
In-flight steps still write their terminal events unchanged. This
closes the adapter gap behind the fetch-free prologue's reliance on
the step_started claim as the run-liveness check, and the prologue
comment now states the contract precisely.
- workflow.step.dispatch_prologue span attribute ('run_context' |
'runs_get') makes fetch-free adoption and the saved round trip
observable during version-skew windows.
- Restated why the eager redelivery re-ensure survives on the
fetch-free path (no run fetch to overlap; still cheaper than the
in-band recovery's failed-start round trip).
- New two-phase fan-out coverage: a real replay emits the queued step
message (asserting the stamped runContext), then its redelivery runs
as the LAST completer — zero reads before the step, exactly one lazy
runs.get, run completed; plus the stale-'pending' fall-through and
the genuinely-terminal skip.
@TooTallNate
TooTallNateforce-pushed the step-dispatch-run-context branch from f3d9a8e to 03bf52cCompareAugust 11, 2026 19:57
@github-actions

github-actionsBot commented Aug 11, 2026

Copy link
Copy Markdown
Contributor

📊 Workflow Benchmarks

commit 03bf52c · Tue, 11 Aug 2026 20:17:50 GMT · run logs

Backend: vercel · app: nextjs-turbopack

MetricScenarioBest (ms)P75 (ms)P90 (ms)P99 (ms)Samples
TTFSstep316 (-63%) 💚1519 🔴 (+31%) 🔻1551 🔴 (+29%) 🔻4047 🔴 (+151%) 🔻30
TTFSstream316 (+13%)1444 🔴 (+23%) 🔻1487 🔴 (+23%) 🔻1603 🔴 (+31%) 🔻30
TTFShook + stream1511 (+11%)1824 🔴 (+24%) 🔻1847 🔴 (+23%) 🔻1956 🔴 (+7.4%)30
STSO1020 steps (inline)153 (+11%)214 (-7.8%)240 (-20%) 💚344 (-51%) 💚1019
WO1020 steps207278 (-10%)207278 (-10%)207278 (-10%)207278 (-10%)1
SLstream latency120 (+17%) 🔻193 🔴 (-24%) 💚318 🔴 (+7.4%)355 🔴 (-38%) 💚30
SOstream overhead (text)157 (+6.1%)227 (-34%) 💚244 (-54%) 💚1856 🔴 (+104%) 🔻30
SOstream overhead (structured)147 (+3.5%)233 (-8.3%)269 (-64%) 💚352 (-73%) 💚30
📈 STSO distribution vs main (inline / queue-hop histograms)

1020 steps (inline)

Cumulative STSO time: main 229937ms → this run 206100ms (Δ -23837ms, -10%)

 100-150 ms ┃ main 19 this 0 -19
150-200 ms ████████████████████░░░┃ main 505 this 594 +89
200-250 ms █████████████┃ main 316 this 348 +32
250-300 ms █┃█ main 80 this 52 -28
300-350 ms ┃ main 28 this 16 -12
350-400 ms ┃ main 28 this 5 -23
400-450 ms ┃ main 9 this 2 -7
450-500 ms ┃ main 6 this 1 -5
500-550 ms ┃ main 8 this 1 -7
550-600 ms ┃ main 2 this 0 -2
600-650 ms ┃ main 3 this 0 -3
650-700 ms ┃ main 4 this 0 -4
700-750 ms ┃ main 5 this 0 -5
850-900 ms ┃ main 1 this 0 -1
900-950 ms ┃ main 3 this 0 -3
950-1000 ms ┃ main 1 this 0 -1
3350-3400 ms ┃ main 1 this 0 -1
ℹ️ Metric definitions & methodology

The collapsed STSO distribution section above buckets every step gap of the sequential-steps run (not a sampled window), split by whether the step ending the gap ran inline — in the same warm process as the step before it, so the gap is pure framework overhead — or after a queue-hop — the first step of a fresh process, which pays queue dispatch, client reinit and event-log replay. Bars overlay the two runs: is main, marks where this run lands, bridges the gap when this run has more samples in a bucket.

Best/P75/P90/P99 deltas compare against the most recent benchmark run on main at the time of this run. 🔻 flags a delta worse than +15%, 💚 one better than −15%.

Metrics — TTFS: time to first step body (in-deployment start() → first step body, deployment clocks) · STSO: step-to-step overhead (gap between consecutive step bodies) · WO: workflow overhead (whole-run time outside step bodies, in-deployment anchored) · SL: stream latency (in-deployment write → read propagation, readAt - writtenAt) · SO: stream overhead (end-to-end write+consume time beyond the modelled generation window)

Scenarios — step: one trivial no-op step, no stream; no hooks, so the run stays in turbo mode (in-process fast path) · stream: one streaming step; no hooks, so the run stays in turbo mode (in-process fast path) · hook + stream: registers a hook before one step, which exits turbo mode (dispatch path) · 1020 steps: 1020 trivial sequential steps; STSO is measured between consecutive steps in the given step ranges, and WO is the whole-run overhead outside step bodies · stream latency: parallel reader/writer steps on a dedicated stream; SL is the in-deployment write->read propagation (readAt - writtenAt) · stream overhead (text): writer streams 300 variable-length text token deltas paced at 100/s for 3s (a haiku-size LLM's token throughput) while a parallel reader drains the whole stream; SO is the end-to-end write+consume time beyond the 3s generation window (overhead/backpressure) · stream overhead (structured): same workload as stream overhead (text), but each delta is an AI-SDK-style structured object ({ type: 'text-delta', id, text }) instead of a raw string, so the SO gap vs the text scenario is the added serialization cost

🔴 marks a percentile over its target (within target is left unmarked). Targets (p75/p90/p99, ms) — TTFS 200/300/600 · SL 50/60/125 · SO 250/500/1000

All metrics are measured from deployment-side timestamps only. Runs are triggered by an in-deployment route that stamps the anchor (clientStart) right before start(), so the CI runner’s request and its path through api.vercel.com sit outside every measured window. TTFS = in-deployment start() → first step body (turbo uses the in-process fast path, non-turbo the dispatch path), and includes the VQS dispatch hop plus any /flow cold start. STSO/WO are measured between step bodies on the deployment. SL is measured inside the workflow (parallel reader/writer steps), so it no longer includes the api.vercel.com read path.

Cold starts are kept in the numbers on purpose — they are part of real bursty-workload latency. The workbench deployment cold-starts the /flow invocation for a large fraction of runs, inflating P75+; the Best column shows the fastest (warm-start) sample for comparison.

@github-actions

Copy link
Copy Markdown
Contributor

Sim World

Simulated world deterministic testing for races. Traces

🟠 Mint-ordered log — 6 fail of 41 total

log=mint-ordered · fence=per-spec

scenariooutcomeeventsvirtreplayviolations
smoke-no-stepscompleted30msok0
smoke-one-stepcompleted60msok0
hook-at-step-startedcompleted120msok0
hook-at-step-completedcompleted120msok0
hook-at-hook-createdcompleted120msok0
deadline-hook-winscompleted71.0hok0
deadline-expirescompleted71.0hok0
long-sleepcompleted1130.0dok0
hook-never-arrivesstalled30msskipped0
step-retries-twicecompleted102.0sok0
parallel-stepscompleted90msok0
hook-on-execution-statecompleted120msok0
peek-hook-before-branchcompleted120msok0
peek-hook-after-branchcompleted120msok0
peek-hook-at-registrationcompleted120msok0
race-hook-before-probecompleted120msok0
race-hook-after-probecompleted120msok0
race-duplicate-deliverycompleted130msok0
attr-hook-before-stepcompleted110msok0
attr-hook-after-stepcompleted110msok0
attr-from-step-bodycompleted130msok0
fork-hook-after-timeoutcompleted141.0mok0
fork-hook-before-timeoutcompleted141.0mok0
count-hook-after-timeoutcompleted171.0mok0
count-hook-before-timeoutcompleted201.0mok0
stale-read-step-count-forkcompleted171.0mMISMATCH1
stale-read-equal-step-countscompleted141.0mMISMATCH1
step-vs-step-forkcompleted120msMISMATCH1
step-vs-step-fork-fencedcompleted120msMISMATCH1
fence-catches-benign-directioncompleted125msok0
in-flight-before-decisioncompleted171.0mMISMATCH1
in-flight-before-decision-countedcompleted201.0mok0
in-flight-after-decisionfailed142.0mMISMATCH1
stale-read-step-count-fork-fencedcompleted201.0mok0
fork-hook-winscompleted131.0mok0
fork-timeout-winscompleted131.0mok0
unclaimed-payload-under-forkcompleted171.0mok0
claimed-payload-under-forkcompleted171.0mok0
writers-independent-step-bodiescompleted120msok0
writers-scripted-tempocompleted120msok0
cancel-mid-stepcancelled70msskipped0

Full trace: world-sim-mint.txt

🟢 Append-only log — 0 fail of 41 total

log=append-only · fence=per-spec

scenariooutcomeeventsvirtreplayviolations
smoke-no-stepscompleted30msok0
smoke-one-stepcompleted60msok0
hook-at-step-startedcompleted120msok0
hook-at-step-completedcompleted120msok0
hook-at-hook-createdcompleted120msok0
deadline-hook-winscompleted71.0hok0
deadline-expirescompleted71.0hok0
long-sleepcompleted1130.0dok0
hook-never-arrivesstalled30msskipped0
step-retries-twicecompleted102.0sok0
parallel-stepscompleted90msok0
hook-on-execution-statecompleted120msok0
peek-hook-before-branchcompleted120msok0
peek-hook-after-branchcompleted120msok0
peek-hook-at-registrationcompleted120msok0
race-hook-before-probecompleted120msok0
race-hook-after-probecompleted120msok0
race-duplicate-deliverycompleted130msok0
attr-hook-before-stepcompleted110msok0
attr-hook-after-stepcompleted110msok0
attr-from-step-bodycompleted130msok0
fork-hook-after-timeoutcompleted141.0mok0
fork-hook-before-timeoutcompleted141.0mok0
count-hook-after-timeoutcompleted171.0mok0
count-hook-before-timeoutcompleted201.0mok0
stale-read-step-count-forkcompleted201.0mok0
stale-read-equal-step-countscompleted141.0mok0
step-vs-step-forkcompleted120msok0
step-vs-step-fork-fencedcompleted120msok0
fence-catches-benign-directioncompleted125msok0
in-flight-before-decisioncompleted171.0mok0
in-flight-before-decision-countedcompleted171.0mok0
in-flight-after-decisioncompleted192.0mok0
stale-read-step-count-fork-fencedcompleted201.0mok0
fork-hook-winscompleted131.0mok0
fork-timeout-winscompleted131.0mok0
unclaimed-payload-under-forkcompleted171.0mok0
claimed-payload-under-forkcompleted171.0mok0
writers-independent-step-bodiescompleted120msok0
writers-scripted-tempocompleted120msok0
cancel-mid-stepcancelled70msskipped0

Full trace: world-sim-append-only.txt

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

TTLS: drop the blocking runs.get from the queued-step consumer prologue (carry run context on the dispatch message)

3 participants

@TooTallNate@VaguelySerious