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 owner August 11, 2026 09:52
Copilot AI lite review requested due to automatic review settings August 11, 2026 09:52
@vercel

vercel Bot commented Aug 11, 2026

Copy link
Copy Markdown
Contributor

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

Project Deployment Actions Updated (UTC)
example-nextjs-workflow-turbopack Ready Ready Preview Aug 11, 2026 8:01pm
example-nextjs-workflow-webpack Ready Ready Preview Aug 11, 2026 8:01pm
example-workflow Ready Ready Preview Aug 11, 2026 8:01pm
workbench-astro-workflow Ready Ready Preview Aug 11, 2026 8:01pm
workbench-express-workflow Ready Ready Preview Aug 11, 2026 8:01pm
workbench-fastify-workflow Ready Ready Preview Aug 11, 2026 8:01pm
workbench-hono-workflow Ready Ready Preview Aug 11, 2026 8:01pm
workbench-nestjs-workflow Ready Ready Preview Aug 11, 2026 8:01pm
workbench-nitro-workflow Ready Ready Preview Aug 11, 2026 8:01pm
workbench-nuxt-workflow Ready Ready Preview Aug 11, 2026 8:01pm
workbench-python-workflow Error Error Aug 11, 2026 8:01pm
workbench-sveltekit-workflow Ready Ready Preview Aug 11, 2026 8:01pm
workbench-tanstack-start-workflow Ready Ready Preview Aug 11, 2026 8:01pm
workbench-vite-workflow Ready Ready Preview Aug 11, 2026 8:01pm
workflow-docs Ready Ready Preview, v0 Aug 11, 2026 8:01pm
workflow-swc-playground Ready Ready Preview Aug 11, 2026 8:01pm
workflow-tarballs Ready Ready Preview Aug 11, 2026 8:01pm
workflow-web Ready Ready Preview Aug 11, 2026 8:01pm

@changeset-bot

changeset-bot Bot 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
Name Type
@workflow/world Minor
@workflow/core Minor
@workflow/world-local Patch
@workflow/world-postgres Patch
@workflow/cli Patch
@workflow/vitest Patch
@workflow/web-shared Patch
@workflow/web Patch
@workflow/world-testing Patch
@workflow/world-vercel Patch
@workflow/builders Patch
@workflow/next Patch
@workflow/nitro Patch
workflow Minor
@workflow/astro Patch
@workflow/nest Patch
@workflow/rollup Patch
@workflow/sveltekit Patch
@workflow/vite Patch
@workflow/nuxt Patch

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-actions Bot 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
Passed Failed Skipped Total
❌ ▲ Vercel Production 3465 1 590 4056
✅ 💻 Local Development 3361 0 539 3900
✅ 📦 Local Production 3810 0 558 4368
✅ 🐘 Local Postgres 3810 0 558 4368
✅ 🪟 Windows 312 0 0 312
✅ vercel-multi-region 27 0 0 27
Total 14785 1 2245 17031
Details by Category

❌ ▲ Vercel Production

App Passed Failed Skipped
✅ astro-node 128 0 28
✅ astro-quickjs 128 0 28
✅ example-node 128 0 28
✅ example-quickjs 128 0 28
✅ express-node 128 0 28
✅ express-quickjs 128 0 28
✅ fastify-node 128 0 28
❌ fastify-quickjs 127 1 28
✅ hono-node 128 0 28
✅ hono-quickjs 128 0 28
✅ nest-node 128 0 28
✅ nest-quickjs 128 0 28
✅ nextjs-turbopack-node 153 0 3
✅ nextjs-turbopack-quickjs 153 0 3
✅ nextjs-webpack-node 153 0 3
✅ nextjs-webpack-quickjs 153 0 3
✅ nitro-node 128 0 28
✅ nitro-quickjs 128 0 28
✅ nuxt-node 128 0 28
✅ nuxt-quickjs 128 0 28
✅ sveltekit-node 147 0 9
✅ sveltekit-quickjs 147 0 9
✅ tanstack-start-node 128 0 28
✅ tanstack-start-quickjs 128 0 28
✅ vite-node 128 0 28
✅ vite-quickjs 128 0 28

✅ 💻 Local Development

App Passed Failed Skipped
✅ astro-stable-node 130 0 26
✅ astro-stable-quickjs 130 0 26
✅ express-stable-node 130 0 26
✅ express-stable-quickjs 130 0 26
✅ fastify-stable-node 130 0 26
✅ fastify-stable-quickjs 130 0 26
✅ hono-stable-node 130 0 26
✅ hono-stable-quickjs 130 0 26
✅ nest-stable-node 130 0 26
✅ nest-stable-quickjs 130 0 26
✅ nextjs-turbopack-canary-node 137 0 19
✅ nextjs-turbopack-canary-quickjs 137 0 19
✅ nextjs-turbopack-stable-quickjs 156 0 0
✅ nextjs-webpack-canary-node 137 0 19
✅ nextjs-webpack-stable-quickjs 156 0 0
✅ nitro-stable-node 130 0 26
✅ nitro-stable-quickjs 130 0 26
✅ nuxt-stable-node 130 0 26
✅ nuxt-stable-quickjs 130 0 26
✅ sveltekit-stable-node 149 0 7
✅ sveltekit-stable-quickjs 149 0 7
✅ tanstack-start-node 130 0 26
✅ tanstack-start-quickjs 130 0 26
✅ vite-stable-node 130 0 26
✅ vite-stable-quickjs 130 0 26

✅ 📦 Local Production

App Passed Failed Skipped
✅ astro-stable-node 130 0 26
✅ astro-stable-quickjs 130 0 26
✅ express-stable-node 130 0 26
✅ express-stable-quickjs 130 0 26
✅ fastify-stable-node 130 0 26
✅ fastify-stable-quickjs 130 0 26
✅ hono-stable-node 130 0 26
✅ hono-stable-quickjs 130 0 26
✅ nest-stable-node 130 0 26
✅ nest-stable-quickjs 130 0 26
✅ nextjs-turbopack-canary-node 137 0 19
✅ nextjs-turbopack-canary-quickjs 137 0 19
✅ nextjs-turbopack-stable-node 156 0 0
✅ nextjs-turbopack-stable-quickjs 156 0 0
✅ nextjs-webpack-canary-node 137 0 19
✅ nextjs-webpack-canary-quickjs 137 0 19
✅ nextjs-webpack-stable-node 156 0 0
✅ nextjs-webpack-stable-quickjs 156 0 0
✅ nitro-stable-node 130 0 26
✅ nitro-stable-quickjs 130 0 26
✅ nuxt-stable-node 130 0 26
✅ nuxt-stable-quickjs 130 0 26
✅ sveltekit-stable-node 149 0 7
✅ sveltekit-stable-quickjs 149 0 7
✅ tanstack-start-node 130 0 26
✅ tanstack-start-quickjs 130 0 26
✅ vite-stable-node 130 0 26
✅ vite-stable-quickjs 130 0 26

✅ 🐘 Local Postgres

App Passed Failed Skipped
✅ astro-stable-node 130 0 26
✅ astro-stable-quickjs 130 0 26
✅ express-stable-node 130 0 26
✅ express-stable-quickjs 130 0 26
✅ fastify-stable-node 130 0 26
✅ fastify-stable-quickjs 130 0 26
✅ hono-stable-node 130 0 26
✅ hono-stable-quickjs 130 0 26
✅ nest-stable-node 130 0 26
✅ nest-stable-quickjs 130 0 26
✅ nextjs-turbopack-canary-node 137 0 19
✅ nextjs-turbopack-canary-quickjs 137 0 19
✅ nextjs-turbopack-stable-node 156 0 0
✅ nextjs-turbopack-stable-quickjs 156 0 0
✅ nextjs-webpack-canary-node 137 0 19
✅ nextjs-webpack-canary-quickjs 137 0 19
✅ nextjs-webpack-stable-node 156 0 0
✅ nextjs-webpack-stable-quickjs 156 0 0
✅ nitro-stable-node 130 0 26
✅ nitro-stable-quickjs 130 0 26
✅ nuxt-stable-node 130 0 26
✅ nuxt-stable-quickjs 130 0 26
✅ sveltekit-stable-node 149 0 7
✅ sveltekit-stable-quickjs 149 0 7
✅ tanstack-start-node 130 0 26
✅ tanstack-start-quickjs 130 0 26
✅ vite-stable-node 130 0 26
✅ vite-stable-quickjs 130 0 26

✅ 🪟 Windows

App Passed Failed Skipped
✅ nextjs-turbopack-node 156 0 0
✅ nextjs-turbopack-quickjs 156 0 0

✅ vercel-multi-region

App Passed Failed Skipped
✅ nextjs-turbopack 27 0 0

📋 View full workflow run

Copilot AI 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
File Description
packages/world/src/queue.ts Introduces RunDispatchContextSchema and adds runContext to WorkflowInvokePayloadSchema.
packages/core/src/runtime/suspension-handler.ts Stamps runContext on step-dispatch messages produced during suspension handling.
packages/core/src/runtime/suspension-handler.test.ts Asserts resilient publishes include the expected runContext.
packages/core/src/runtime/quickjs-entrypoint.ts Stamps runContext on QuickJS step-dispatch messages.
packages/core/src/runtime/helpers.ts Adds helpers to compute rootRunId and build runDispatchContext from a run row.
packages/core/src/runtime.ts Removes 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.ts Adds consumer tests verifying runs.get is skipped with runContext and still occurs for legacy messages.
.changeset/step-dispatch-run-context.md Declares 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
Member Author

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

br version TTLS p50 p75 p95 TTFS p50 skew p99
64 baseline beta.40 1384 1519 3715 856 526
64 this PR 1337 1429 1693 742 516
256 baseline beta.40 16608 19438 20109 3201 9602
256 this PR 16116 16872 19532 1667 9151

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

@VaguelySerious VaguelySerious 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 thread packages/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
Member Author

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 thread packages/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
Member Author

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
Member Author

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
Member Author

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
Member Author

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 main August 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
TooTallNate force-pushed the step-dispatch-run-context branch from f3d9a8e to 03bf52c Compare August 11, 2026 19:57
@github-actions

github-actions Bot 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

Metric Scenario Best (ms) P75 (ms) P90 (ms) P99 (ms) Samples
TTFS step 316 (-63%) 💚 1519 🔴 (+31%) 🔻 1551 🔴 (+29%) 🔻 4047 🔴 (+151%) 🔻 30
TTFS stream 316 (+13%) 1444 🔴 (+23%) 🔻 1487 🔴 (+23%) 🔻 1603 🔴 (+31%) 🔻 30
TTFS hook + stream 1511 (+11%) 1824 🔴 (+24%) 🔻 1847 🔴 (+23%) 🔻 1956 🔴 (+7.4%) 30
STSO 1020 steps (inline) 153 (+11%) 214 (-7.8%) 240 (-20%) 💚 344 (-51%) 💚 1019
WO 1020 steps 207278 (-10%) 207278 (-10%) 207278 (-10%) 207278 (-10%) 1
SL stream latency 120 (+17%) 🔻 193 🔴 (-24%) 💚 318 🔴 (+7.4%) 355 🔴 (-38%) 💚 30
SO stream overhead (text) 157 (+6.1%) 227 (-34%) 💚 244 (-54%) 💚 1856 🔴 (+104%) 🔻 30
SO stream 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

scenario outcome events virt replay violations
smoke-no-steps completed 3 0ms ok 0
smoke-one-step completed 6 0ms ok 0
hook-at-step-started completed 12 0ms ok 0
hook-at-step-completed completed 12 0ms ok 0
hook-at-hook-created completed 12 0ms ok 0
deadline-hook-wins completed 7 1.0h ok 0
deadline-expires completed 7 1.0h ok 0
long-sleep completed 11 30.0d ok 0
hook-never-arrives stalled 3 0ms skipped 0
step-retries-twice completed 10 2.0s ok 0
parallel-steps completed 9 0ms ok 0
hook-on-execution-state completed 12 0ms ok 0
peek-hook-before-branch completed 12 0ms ok 0
peek-hook-after-branch completed 12 0ms ok 0
peek-hook-at-registration completed 12 0ms ok 0
race-hook-before-probe completed 12 0ms ok 0
race-hook-after-probe completed 12 0ms ok 0
race-duplicate-delivery completed 13 0ms ok 0
attr-hook-before-step completed 11 0ms ok 0
attr-hook-after-step completed 11 0ms ok 0
attr-from-step-body completed 13 0ms ok 0
fork-hook-after-timeout completed 14 1.0m ok 0
fork-hook-before-timeout completed 14 1.0m ok 0
count-hook-after-timeout completed 17 1.0m ok 0
count-hook-before-timeout completed 20 1.0m ok 0
stale-read-step-count-fork completed 17 1.0m MISMATCH 1
stale-read-equal-step-counts completed 14 1.0m MISMATCH 1
step-vs-step-fork completed 12 0ms MISMATCH 1
step-vs-step-fork-fenced completed 12 0ms MISMATCH 1
fence-catches-benign-direction completed 12 5ms ok 0
in-flight-before-decision completed 17 1.0m MISMATCH 1
in-flight-before-decision-counted completed 20 1.0m ok 0
in-flight-after-decision failed 14 2.0m MISMATCH 1
stale-read-step-count-fork-fenced completed 20 1.0m ok 0
fork-hook-wins completed 13 1.0m ok 0
fork-timeout-wins completed 13 1.0m ok 0
unclaimed-payload-under-fork completed 17 1.0m ok 0
claimed-payload-under-fork completed 17 1.0m ok 0
writers-independent-step-bodies completed 12 0ms ok 0
writers-scripted-tempo completed 12 0ms ok 0
cancel-mid-step cancelled 7 0ms skipped 0

Full trace: world-sim-mint.txt

🟢 Append-only log — 0 fail of 41 total

log=append-only · fence=per-spec

scenario outcome events virt replay violations
smoke-no-steps completed 3 0ms ok 0
smoke-one-step completed 6 0ms ok 0
hook-at-step-started completed 12 0ms ok 0
hook-at-step-completed completed 12 0ms ok 0
hook-at-hook-created completed 12 0ms ok 0
deadline-hook-wins completed 7 1.0h ok 0
deadline-expires completed 7 1.0h ok 0
long-sleep completed 11 30.0d ok 0
hook-never-arrives stalled 3 0ms skipped 0
step-retries-twice completed 10 2.0s ok 0
parallel-steps completed 9 0ms ok 0
hook-on-execution-state completed 12 0ms ok 0
peek-hook-before-branch completed 12 0ms ok 0
peek-hook-after-branch completed 12 0ms ok 0
peek-hook-at-registration completed 12 0ms ok 0
race-hook-before-probe completed 12 0ms ok 0
race-hook-after-probe completed 12 0ms ok 0
race-duplicate-delivery completed 13 0ms ok 0
attr-hook-before-step completed 11 0ms ok 0
attr-hook-after-step completed 11 0ms ok 0
attr-from-step-body completed 13 0ms ok 0
fork-hook-after-timeout completed 14 1.0m ok 0
fork-hook-before-timeout completed 14 1.0m ok 0
count-hook-after-timeout completed 17 1.0m ok 0
count-hook-before-timeout completed 20 1.0m ok 0
stale-read-step-count-fork completed 20 1.0m ok 0
stale-read-equal-step-counts completed 14 1.0m ok 0
step-vs-step-fork completed 12 0ms ok 0
step-vs-step-fork-fenced completed 12 0ms ok 0
fence-catches-benign-direction completed 12 5ms ok 0
in-flight-before-decision completed 17 1.0m ok 0
in-flight-before-decision-counted completed 17 1.0m ok 0
in-flight-after-decision completed 19 2.0m ok 0
stale-read-step-count-fork-fenced completed 20 1.0m ok 0
fork-hook-wins completed 13 1.0m ok 0
fork-timeout-wins completed 13 1.0m ok 0
unclaimed-payload-under-fork completed 17 1.0m ok 0
claimed-payload-under-fork completed 17 1.0m ok 0
writers-independent-step-bodies completed 12 0ms ok 0
writers-scripted-tempo completed 12 0ms ok 0
cancel-mid-step cancelled 7 0ms skipped 0

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

Sign up for free to 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