perf(core): initialize lazy hook replay from hook_received stream - #3345

Merged
karthikscale3 merged 2 commits into
mainfrom
kk/hook-received-replay-preload
Aug 6, 2026
Merged

perf(core): initialize lazy hook replay from hook_received stream#3345
karthikscale3 merged 2 commits into
mainfrom
kk/hook-received-replay-preload

Conversation

@karthikscale3

@karthikscale3karthikscale3 commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

What

On a lazy hook queue delivery, the consumer's idempotent hook_received re-ensure is hoisted above run_started and asks the World to return the current replay log with the write (new advisory CreateEventParams.preloadEvents). When a complete preload comes back, the invocation initializes replay from that one request and skips both the run_started POST and the initial events.list; otherwise it falls back to the existing setup without re-posting the hook.

Before: run_started → hook_received re-ensure → events.list → replay
After: hook_received (returns replay log) → replay

Why

Each removed round trip sits directly on hook-resume latency (queue receipt → replay start). Folding the re-ensure and the replay load into one request cuts consumer startup/TTFS by roughly two request latencies on the normal lazy-resume path.

Notes

  • Worlds may ignore preloadEvents; a preload is trusted only when validated as complete (run + hasMore: false + maxEvents + lifecycle events + the matching resumeId). A terminal event in the preload consumes the delivery before engine dispatch.
  • Producer-side resumeHook() is unchanged and never sets preloadEvents.
  • Setup source is reported via workflow.resume_setup_source (hook_received_stream | hook_received_fallback).

🤖 Generated with Claude Code

@changeset-bot

changeset-botBot commented Aug 5, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 9ce3581

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/world-vercelPatch
@workflow/corePatch
@workflow/cliPatch
@workflow/vitestPatch
@workflow/web-sharedPatch
@workflow/webPatch
@workflow/world-localPatch
@workflow/world-postgresPatch
@workflow/world-testingPatch
@workflow/buildersPatch
@workflow/nextPatch
@workflow/nitroPatch
workflowPatch
@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

@vercel

vercelBot commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

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

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

@github-actions

github-actionsBot commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

🧪 E2E Test Results

All tests passed

E2E Test Summary

Summary
PassedFailedSkippedTotal
✅ ▲ Vercel Production293205003432
✅ 💻 Local Development315304353588
✅ 📦 Local Production329004543744
✅ 🐘 Local Postgres329004543744
✅ 🪟 Windows31200312
✅ 📋 Other206804282496
✅ vercel-multi-region270027
Total150720227117343
Details by Category

✅ ▲ Vercel Production

AppPassedFailedSkipped
✅ astro-node127029
✅ astro-quickjs127029
✅ example-node127029
✅ example-quickjs127029
✅ express-node127029
✅ express-quickjs127029
✅ fastify-node127029
✅ fastify-quickjs127029
✅ hono-node127029
✅ hono-quickjs127029
✅ nextjs-turbopack-node15204
✅ nextjs-turbopack-quickjs15204
✅ nextjs-webpack-node15204
✅ nextjs-webpack-quickjs15204
✅ nitro-node127029
✅ nitro-quickjs127029
✅ nuxt-node127029
✅ nuxt-quickjs127029
✅ sveltekit-node146010
✅ sveltekit-quickjs146010
✅ vite-node127029
✅ vite-quickjs127029

✅ 💻 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
✅ nextjs-turbopack-canary-node137019
✅ nextjs-turbopack-canary-quickjs137019
✅ nextjs-turbopack-stable-node15600
✅ nextjs-turbopack-stable-quickjs15600
✅ nextjs-webpack-canary-node137019
✅ 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
✅ 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
✅ 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
✅ 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
✅ 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
✅ vite-stable-node130026
✅ vite-stable-quickjs130026

✅ 🪟 Windows

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

✅ 📋 Other

AppPassedFailedSkipped
✅ e2e-local-dev-nest-stable-node130026
✅ e2e-local-dev-nest-stable-quickjs130026
✅ e2e-local-dev-tanstack-start-node130026
✅ e2e-local-dev-tanstack-start-quickjs130026
✅ e2e-local-postgres-nest-stable-node130026
✅ e2e-local-postgres-nest-stable-quickjs130026
✅ e2e-local-postgres-tanstack-start-node130026
✅ e2e-local-postgres-tanstack-start-quickjs130026
✅ e2e-local-prod-nest-stable-node130026
✅ e2e-local-prod-nest-stable-quickjs130026
✅ e2e-local-prod-tanstack-start-node130026
✅ e2e-local-prod-tanstack-start-quickjs130026
✅ e2e-vercel-prod-nest-node127029
✅ e2e-vercel-prod-nest-quickjs127029
✅ e2e-vercel-prod-tanstack-start-node127029
✅ e2e-vercel-prod-tanstack-start-quickjs127029

✅ vercel-multi-region

AppPassedFailedSkipped
✅ nextjs-turbopack2700

📋 View full workflow run

@github-actions

github-actionsBot commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

📊 Workflow Benchmarks

commit 9ce3581 · Wed, 05 Aug 2026 23:02:34 GMT · run logs

Backend: vercel · app: nextjs-turbopack

MetricScenarioBest (ms)P75 (ms)P90 (ms)P99 (ms)Samples
TTFSstep1316 (+71%) 🔻1441 🔴 (+48%) 🔻1473 🔴 (+39%) 🔻1839 🔴 (+9.7%)30
TTFSstream1293 (+42%) 🔻1403 🔴 (+44%) 🔻1465 🔴 (+47%) 🔻1516 🔴 (+17%) 🔻30
TTFShook + stream1508 (+74%) 🔻1678 🔴 (+35%) 🔻1700 🔴 (+35%) 🔻1784 🔴 (+20%) 🔻30
STSO1020 steps (inline)105 (+17%) 🔻142 (+8.4%)165 (+5.8%)295 (+19%) 🔻1018
STSO1020 steps (queue-hop)31983198319831981
WO1020 steps151538 (+17%) 🔻151538 (+17%) 🔻151538 (+17%) 🔻151538 (+17%) 🔻1
SLstream latency113 (+40%) 🔻171 🔴 (+38%) 🔻201 🔴 (+34%) 🔻467 🔴 (+14%)30
SOstream overhead (text)127 (+25%) 🔻183 (+17%) 🔻197 (+8.2%)308 (+55%) 🔻30
SOstream overhead (structured)120 (+17%) 🔻163 (-0.6%)189 (+6.2%)271 (-14%)30
📈 STSO distribution vs main (inline / queue-hop histograms)

1020 steps (inline)

Cumulative STSO time: main 128734ms → this run 146913ms (Δ +18179ms, +14%)

 50-100 ms ┃ main 4 this 0 -4
100-150 ms █████████████████████┃██ main 896 this 836 -60
150-200 ms ██░┃ main 93 this 140 +47
200-250 ms ┃ main 16 this 19 +3
250-300 ms ┃ main 3 this 16 +13
300-350 ms ┃ main 4 this 4 +0
350-400 ms ┃ main 1 this 0 -1
400-450 ms ┃ main 1 this 1 +0
500-550 ms ┃ main 1 this 0 -1
550-600 ms ┃ main 0 this 1 +1
5300-5350 ms ┃ main 0 this 1 +1

1020 steps (queue-hop)

Cumulative STSO time: 3198ms over 1 samples

No main baseline with raw samples yet — showing this run's distribution on its own; the diff appears once a run on main has recorded them.

3000-3500 ms ████████████████████████ steps 1
📜 Previous results (2)

2daae33

Wed, 05 Aug 2026 18:48:38 GMT · run logs

vercel / nextjs-turbopack

MetricScenarioBest (ms)P75 (ms)P90 (ms)P99 (ms)Samples
TTFSstep1329 (+201%) 🔻1453 🔴 (+28%) 🔻1487 🔴 (+2.3%)1819 🔴 (+18%) 🔻30
TTFSstream331 (+87%) 🔻1436 🔴 (+35%) 🔻1470 🔴 (+38%) 🔻1571 🔴 (+36%) 🔻30
TTFShook + stream1397 (+12%)1750 🔴 (+27%) 🔻1806 🔴 (+26%) 🔻1972 🔴 (+17%) 🔻30
STSO1020 steps (inline)98 (+15%) 🔻143 (+2.9%)164 (±0%)259 (-3.0%)1018
STSO1020 steps (queue-hop)3278 (+11%)3278 (+11%)3278 (+11%)3278 (+11%)1
WO1020 steps145014 (+5.1%)145014 (+5.1%)145014 (+5.1%)145014 (+5.1%)1
SLstream latency114 (+36%) 🔻169 🔴 (+50%) 🔻185 🔴 (+32%) 🔻292 🔴 (-20%) 💚30
SOstream overhead (text)142 (+33%) 🔻217 (+33%) 🔻272 (+47%) 🔻327 (-6.3%)30
SOstream overhead (structured)129 (+22%) 🔻185 (+22%) 🔻201 (+19%) 🔻277 (+25%) 🔻30

549d6f0

Wed, 05 Aug 2026 01:54:01 GMT · run logs

vercel / nextjs-turbopack

MetricScenarioBest (ms)P75 (ms)P90 (ms)P99 (ms)Samples
TTFSstep239 (-73%) 💚1381 🔴 (+33%) 🔻1534 🔴 (+16%) 🔻1833 🔴 (+24%) 🔻30
TTFSstream241 (-75%) 💚1342 🔴 (+31%) 🔻1372 🔴 (+32%) 🔻1544 🔴 (+33%) 🔻30
TTFShook + stream306 (-27%) 💚1548 🔴 (+19%) 🔻1585 🔴 (+3.3%)1720 🔴 (+0.6%)30
STSO1020 steps (inline)108 (+27%) 🔻151 (+7.9%)172 (+3.6%)255 (-41%) 💚1018
STSO1020 steps (queue-hop)2262 (-52%) 💚2262 (-52%) 💚2262 (-52%) 💚2262 (-52%) 💚1
WO1020 steps150841 (+0.8%)150841 (+0.8%)150841 (+0.8%)150841 (+0.8%)1
SLstream latency105 (+28%) 🔻171 🔴 (+58%) 🔻189 🔴 (+60%) 🔻222 🔴 (+31%) 🔻30
SOstream overhead (text)160 (+54%) 🔻252 🔴 (+49%) 🔻308 (+72%) 🔻769 (+114%) 🔻30
SOstream overhead (structured)152 (+46%) 🔻270 🔴 (+53%) 🔻354 (+52%) 🔻618 (+20%) 🔻30
ℹ️ 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.

@karthikscale3
karthikscale3 marked this pull request as ready for review August 5, 2026 01:42
@karthikscale3
karthikscale3 requested a review from a team as a code ownerAugust 5, 2026 01:42
On a lazy hook queue delivery, the consumer's idempotent hook_received
re-ensure is hoisted above run_started and doubles as the invocation's
setup request: it asks the World to return the current replay log with
the write (new advisory CreateEventParams.preloadEvents), so one HTTP
request yields the canonical event, the reconstructed run, and the
complete replay log — skipping both the run_started POST and the
initial events.list.
- world: optional `preloadEvents?: true` on CreateEventParams, the
hook_received dual of skipPreload; Worlds may ignore it
- world-vercel: createHookReceivedPreloadEventV4 sends the frame Accept
on eligible hook_received posts and decodes either response mode —
frames via the response decoder extracted from the LIST consumer
(GET behavior unchanged), CBOR via the shared materialized-response
mapping. The run is reconstructed from run_created/run_started (plus
attr_set folds), the canonical event found by x-wf-event-id, and
resumeId now survives frame decoding so the runtime can match it
- core: new fast path before the generic run-state setup, guarded on
hookInput.resumeId + payloadDigest; a validated COMPLETE preload
(hasMore false — this path has no cursor-continuation machinery)
initializes workflowRun/preloadedEvents/maxEventsLimit directly,
anything else falls back to the run_started setup without re-posting
the hook; error classification matches the existing re-ensure
(terminal → consume, transient → redeliver); setup source reported
via workflow.resume_setup_source (never
workflow.hook.resilient_resume_materialized, which stays a
recovery-only signal)
- producer resumeHook() is unchanged and never sets preloadEvents
Based directly on main (no dependency on #3124/#3191); pairs with
workflow-server's streamed hook_received replay-log response, which
deploys first — the SDK negotiates per request and falls back safely
against older servers.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@karthikscale3
karthikscale3force-pushed the kk/hook-received-replay-preload branch from 549d6f0 to 2daae33CompareAugust 5, 2026 18:28

@TooTallNateTooTallNate 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.

Reviewed at 549d6f0. Locally: build + typecheck green, core 1920 passed / 3 expected fail, world-vercel 339 — with the 12 consumer-preload tests covering every fallback and error branch I went looking for.

What held up under scrutiny:

  • The completeness validation is the right shape: run + startedAt, non-empty events, non-null cursor, hasMore === false, numeric maxEvents (this response plays run_started's role, so a missing ceiling would silently disable event-limit enforcement — good catch), both lifecycle events, and the hook_received matching thisresumeId. On the cursor requirement: I confirmed the backend synthesizes a cursor on the final page even for single-page logs, so the check doesn't dead-letter short runs — but that server behavior is now load-bearing for this fast path; a code comment noting the dependency would help the next person.
  • Error classification is byte-for-byte consistent with the existing re-ensure: HookNotFound/RunExpired consume the delivery; everything else (EntityConflict, truncated stream, transport) rethrows for redelivery and the (runId, resumeId) claim converges. And the deliberate omission of HookResilientResumeMaterialized (this path carries no recovery signal) keeps that metric honest.
  • The terminal-event check before engine dispatch correctly plugs the QuickJS gap (it dispatches before the node loop's terminal detection), and preloadedEventsComplete as an explicit attestation — rather than widening the first-invocation heuristic — is the safer design.
  • resumeId threading through buildEventFromV4 is essential and easy to miss: without it, frame-decoded events would silently fail the matching check and the fast path would never fire. The comment says exactly that.
  • remoteRefBehavior: 'resolve' override on the preload request is right (v4 has no refs endpoint to hydrate lazy descriptors mid-replay), and reconstructRunFromReplayEvents carries every field downstream consumers read — including deploymentId and specVersion, which the in-flight deployment-affinity and slot-identity work key off.

Three asks before merge:

  1. Rebase — the PR is currently CONFLICTING with main (a one-file test conflict in events.test.ts vs #3334).
  2. Coordinate with #2960 (deployment-affinity guard, also open). Its design places the guard ahead of the lazy-hook re-ensure with the explicit invariant "a misrouted resume writes nothing here" — this fast path hoists the hook_received write above where that guard will sit. I believe the combination is still safe (the write is idempotent, involves no key derivation, and the guard still precedes any replay/step execution), but whichever PR lands second must reconcile the placement and rewrite that comment — the "writes nothing" invariant will no longer be literally true, and it should be weakened deliberately rather than silently.
  3. Changeset bump: @workflow/world gains a new public interface field (preloadEvents) plus documented EventResult semantics — per the convention we've applied on recent PRs, new API surface on the world interface should be minor, not patch.

For the record, the paired backend PR's red trigger lane ran with main's SDK (no Accept header → this feature dormant), and its failures match the varied preview-lane flakiness other backend branches see — not this pair. The real proof of the active path will be this PR's own e2e once rebased, since the backend half is already deployable ahead.

Nice perf win with a genuinely safe fallback story. Approving.

@karthikscale3

Copy link
Copy Markdown
ContributorAuthor

CI run completed without any issues (Except one unrelated flake). Checked datadog and compared CI run span metrics pre server side merge vs post server side merge:

The p50 improvement appears across every framework cohort:

SvelteKit: 1,194 → 939 ms, −255 ms
Next.js Turbopack: 1,396 → 1,156 ms, −239 ms
Next.js webpack: 1,371 → 1,048 ms, −324 ms

Merging this now as all review comments are addressed and the confidence levels are high.

@karthikscale3
karthikscale3 merged commit 9c1b3c8 into mainAug 6, 2026
411 of 421 checks passed
@karthikscale3
karthikscale3 deleted the kk/hook-received-replay-preload branch August 6, 2026 02:24
@github-actions

Copy link
Copy Markdown
Contributor

No backport to stable for 9c1b3c8 (AI decision).

This is a latency optimization that adds new API surface — an advisory preloadEvents param on CreateEventParams (a minor bump to @workflow/world), a new world-vercel request/response mode (createHookReceivedPreloadEventV4), a new preloadedEventsComplete option on the QuickJS entrypoint, a widened retry opt-in, and a new workflow.resume_setup_source telemetry attribute. It fixes no user-visible defect on stable, and it is explicitly designed to pair with a server-side change that must deploy first, so it belongs on main only.

To override, re-run the Backport to stable workflow manually via workflow_dispatch and paste this commit SHA into the ref input:

9c1b3c86384181b673d41123d2eec0b987afc75a

@TooTallNate

Copy link
Copy Markdown
Member

Heads-up: the #2960#3345 ordering interaction flagged in my review above is now a red unit test on main.

runtime.test.ts > workflowEntrypoint replay guards > re-routes a misrouted lazy hook resume with its payload intact fails on an unmodified checkout of main (reproduced at 9c1b3c8): the test asserts a misrouted lazy resume writes nohook_received before re-routing, but the fast path added here runs before the deployment-affinity guard from #2960, so the hoisted hook_received re-ensure (and, on the fallback path, run_started) now land before the guard fires:

AssertionError: expected events to not contain { eventType: 'hook_received' }
Received: [ hook_received (hook_1), run_started ]

As discussed in the review, I believe the behavior is still safe — the write is idempotent, involves no key derivation, and the guard still runs before any replay or step execution — but #2960's "a misrouted resume writes nothing here" invariant is now false and its test enforces it, so main is red until one of:

  1. Weaken the invariant deliberately: update the test (and fix(core): re-route runs delivered to the wrong deployment #2960's guard comment) to accept the idempotent pre-guard write, asserting instead that no replay/step work happens and the re-route still carries the payload; or
  2. Restore the invariant cheaply: carry the run's pinned deploymentId on the lazy-hook queue message (hookInput), so the guard can check for a mismatch before the fast path's write without the run fetch the guard otherwise needs — keeping both the perf win and the writes-nothing property.

(Found while reviewing #3372, whose author independently hit the same failing test and correctly identified it as pre-existing.)

cc @karthikscale3@alangenfeld

karthikscale3 added a commit that referenced this pull request Aug 6, 2026
…#3374)
The lazy hook fast path (#3345) hoisted the consumer's hook_received
write above the deployment-affinity guard (#2960), so a misrouted lazy
resume wrote its event before the guard could re-route the delivery.
Stamp the run's pinned deployment on the resume message
(hookInput.deploymentId, from the producer's resume context) and, on
the consumer, compare it against the ambient deployment id immediately
before the fast path: a match continues with no run fetch, a mismatch
fetches the authoritative run and hands it to the existing guard —
which keeps sole ownership of re-route/fail policy and remains the
authoritative protection before replay and step execution. The
re-routed message preserves the complete hookInput (it may hold the
only copy of the resume payload). Older messages without the field, and
worlds without deployment affinity, are unchanged: they skip the
pre-check and rely on the authoritative guard, the pre-guard write
staying convergent per (runId, resumeId).
Fixes the misrouted-lazy-resume unit test broken by the #2960/#3345
ordering: a modern misrouted resume now re-routes with zero event
writes, asserted for both hook_received and run_started.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
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.

3 participants

@karthikscale3@TooTallNate@pranaygp
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content

perf(core): initialize lazy hook replay from hook_received stream - #3345

Merged
karthikscale3 merged 2 commits into
mainfrom
kk/hook-received-replay-preload
Aug 6, 2026
Merged

perf(core): initialize lazy hook replay from hook_received stream#3345
karthikscale3 merged 2 commits into
mainfrom
kk/hook-received-replay-preload

Conversation

@karthikscale3

@karthikscale3karthikscale3 commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

What

On a lazy hook queue delivery, the consumer's idempotent hook_received re-ensure is hoisted above run_started and asks the World to return the current replay log with the write (new advisory CreateEventParams.preloadEvents). When a complete preload comes back, the invocation initializes replay from that one request and skips both the run_started POST and the initial events.list; otherwise it falls back to the existing setup without re-posting the hook.

Before: run_started → hook_received re-ensure → events.list → replay
After: hook_received (returns replay log) → replay

Why

Each removed round trip sits directly on hook-resume latency (queue receipt → replay start). Folding the re-ensure and the replay load into one request cuts consumer startup/TTFS by roughly two request latencies on the normal lazy-resume path.

Notes

  • Worlds may ignore preloadEvents; a preload is trusted only when validated as complete (run + hasMore: false + maxEvents + lifecycle events + the matching resumeId). A terminal event in the preload consumes the delivery before engine dispatch.
  • Producer-side resumeHook() is unchanged and never sets preloadEvents.
  • Setup source is reported via workflow.resume_setup_source (hook_received_stream | hook_received_fallback).

🤖 Generated with Claude Code

@changeset-bot

changeset-botBot commented Aug 5, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 9ce3581

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/world-vercelPatch
@workflow/corePatch
@workflow/cliPatch
@workflow/vitestPatch
@workflow/web-sharedPatch
@workflow/webPatch
@workflow/world-localPatch
@workflow/world-postgresPatch
@workflow/world-testingPatch
@workflow/buildersPatch
@workflow/nextPatch
@workflow/nitroPatch
workflowPatch
@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

@vercel

vercelBot commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

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

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

@github-actions

github-actionsBot commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

🧪 E2E Test Results

All tests passed

E2E Test Summary

Summary
PassedFailedSkippedTotal
✅ ▲ Vercel Production293205003432
✅ 💻 Local Development315304353588
✅ 📦 Local Production329004543744
✅ 🐘 Local Postgres329004543744
✅ 🪟 Windows31200312
✅ 📋 Other206804282496
✅ vercel-multi-region270027
Total150720227117343
Details by Category

✅ ▲ Vercel Production

AppPassedFailedSkipped
✅ astro-node127029
✅ astro-quickjs127029
✅ example-node127029
✅ example-quickjs127029
✅ express-node127029
✅ express-quickjs127029
✅ fastify-node127029
✅ fastify-quickjs127029
✅ hono-node127029
✅ hono-quickjs127029
✅ nextjs-turbopack-node15204
✅ nextjs-turbopack-quickjs15204
✅ nextjs-webpack-node15204
✅ nextjs-webpack-quickjs15204
✅ nitro-node127029
✅ nitro-quickjs127029
✅ nuxt-node127029
✅ nuxt-quickjs127029
✅ sveltekit-node146010
✅ sveltekit-quickjs146010
✅ vite-node127029
✅ vite-quickjs127029

✅ 💻 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
✅ nextjs-turbopack-canary-node137019
✅ nextjs-turbopack-canary-quickjs137019
✅ nextjs-turbopack-stable-node15600
✅ nextjs-turbopack-stable-quickjs15600
✅ nextjs-webpack-canary-node137019
✅ 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
✅ 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
✅ 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
✅ 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
✅ 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
✅ vite-stable-node130026
✅ vite-stable-quickjs130026

✅ 🪟 Windows

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

✅ 📋 Other

AppPassedFailedSkipped
✅ e2e-local-dev-nest-stable-node130026
✅ e2e-local-dev-nest-stable-quickjs130026
✅ e2e-local-dev-tanstack-start-node130026
✅ e2e-local-dev-tanstack-start-quickjs130026
✅ e2e-local-postgres-nest-stable-node130026
✅ e2e-local-postgres-nest-stable-quickjs130026
✅ e2e-local-postgres-tanstack-start-node130026
✅ e2e-local-postgres-tanstack-start-quickjs130026
✅ e2e-local-prod-nest-stable-node130026
✅ e2e-local-prod-nest-stable-quickjs130026
✅ e2e-local-prod-tanstack-start-node130026
✅ e2e-local-prod-tanstack-start-quickjs130026
✅ e2e-vercel-prod-nest-node127029
✅ e2e-vercel-prod-nest-quickjs127029
✅ e2e-vercel-prod-tanstack-start-node127029
✅ e2e-vercel-prod-tanstack-start-quickjs127029

✅ vercel-multi-region

AppPassedFailedSkipped
✅ nextjs-turbopack2700

📋 View full workflow run

@github-actions

github-actionsBot commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

📊 Workflow Benchmarks

commit 9ce3581 · Wed, 05 Aug 2026 23:02:34 GMT · run logs

Backend: vercel · app: nextjs-turbopack

MetricScenarioBest (ms)P75 (ms)P90 (ms)P99 (ms)Samples
TTFSstep1316 (+71%) 🔻1441 🔴 (+48%) 🔻1473 🔴 (+39%) 🔻1839 🔴 (+9.7%)30
TTFSstream1293 (+42%) 🔻1403 🔴 (+44%) 🔻1465 🔴 (+47%) 🔻1516 🔴 (+17%) 🔻30
TTFShook + stream1508 (+74%) 🔻1678 🔴 (+35%) 🔻1700 🔴 (+35%) 🔻1784 🔴 (+20%) 🔻30
STSO1020 steps (inline)105 (+17%) 🔻142 (+8.4%)165 (+5.8%)295 (+19%) 🔻1018
STSO1020 steps (queue-hop)31983198319831981
WO1020 steps151538 (+17%) 🔻151538 (+17%) 🔻151538 (+17%) 🔻151538 (+17%) 🔻1
SLstream latency113 (+40%) 🔻171 🔴 (+38%) 🔻201 🔴 (+34%) 🔻467 🔴 (+14%)30
SOstream overhead (text)127 (+25%) 🔻183 (+17%) 🔻197 (+8.2%)308 (+55%) 🔻30
SOstream overhead (structured)120 (+17%) 🔻163 (-0.6%)189 (+6.2%)271 (-14%)30
📈 STSO distribution vs main (inline / queue-hop histograms)

1020 steps (inline)

Cumulative STSO time: main 128734ms → this run 146913ms (Δ +18179ms, +14%)

 50-100 ms ┃ main 4 this 0 -4
100-150 ms █████████████████████┃██ main 896 this 836 -60
150-200 ms ██░┃ main 93 this 140 +47
200-250 ms ┃ main 16 this 19 +3
250-300 ms ┃ main 3 this 16 +13
300-350 ms ┃ main 4 this 4 +0
350-400 ms ┃ main 1 this 0 -1
400-450 ms ┃ main 1 this 1 +0
500-550 ms ┃ main 1 this 0 -1
550-600 ms ┃ main 0 this 1 +1
5300-5350 ms ┃ main 0 this 1 +1

1020 steps (queue-hop)

Cumulative STSO time: 3198ms over 1 samples

No main baseline with raw samples yet — showing this run's distribution on its own; the diff appears once a run on main has recorded them.

3000-3500 ms ████████████████████████ steps 1
📜 Previous results (2)

2daae33

Wed, 05 Aug 2026 18:48:38 GMT · run logs

vercel / nextjs-turbopack

MetricScenarioBest (ms)P75 (ms)P90 (ms)P99 (ms)Samples
TTFSstep1329 (+201%) 🔻1453 🔴 (+28%) 🔻1487 🔴 (+2.3%)1819 🔴 (+18%) 🔻30
TTFSstream331 (+87%) 🔻1436 🔴 (+35%) 🔻1470 🔴 (+38%) 🔻1571 🔴 (+36%) 🔻30
TTFShook + stream1397 (+12%)1750 🔴 (+27%) 🔻1806 🔴 (+26%) 🔻1972 🔴 (+17%) 🔻30
STSO1020 steps (inline)98 (+15%) 🔻143 (+2.9%)164 (±0%)259 (-3.0%)1018
STSO1020 steps (queue-hop)3278 (+11%)3278 (+11%)3278 (+11%)3278 (+11%)1
WO1020 steps145014 (+5.1%)145014 (+5.1%)145014 (+5.1%)145014 (+5.1%)1
SLstream latency114 (+36%) 🔻169 🔴 (+50%) 🔻185 🔴 (+32%) 🔻292 🔴 (-20%) 💚30
SOstream overhead (text)142 (+33%) 🔻217 (+33%) 🔻272 (+47%) 🔻327 (-6.3%)30
SOstream overhead (structured)129 (+22%) 🔻185 (+22%) 🔻201 (+19%) 🔻277 (+25%) 🔻30

549d6f0

Wed, 05 Aug 2026 01:54:01 GMT · run logs

vercel / nextjs-turbopack

MetricScenarioBest (ms)P75 (ms)P90 (ms)P99 (ms)Samples
TTFSstep239 (-73%) 💚1381 🔴 (+33%) 🔻1534 🔴 (+16%) 🔻1833 🔴 (+24%) 🔻30
TTFSstream241 (-75%) 💚1342 🔴 (+31%) 🔻1372 🔴 (+32%) 🔻1544 🔴 (+33%) 🔻30
TTFShook + stream306 (-27%) 💚1548 🔴 (+19%) 🔻1585 🔴 (+3.3%)1720 🔴 (+0.6%)30
STSO1020 steps (inline)108 (+27%) 🔻151 (+7.9%)172 (+3.6%)255 (-41%) 💚1018
STSO1020 steps (queue-hop)2262 (-52%) 💚2262 (-52%) 💚2262 (-52%) 💚2262 (-52%) 💚1
WO1020 steps150841 (+0.8%)150841 (+0.8%)150841 (+0.8%)150841 (+0.8%)1
SLstream latency105 (+28%) 🔻171 🔴 (+58%) 🔻189 🔴 (+60%) 🔻222 🔴 (+31%) 🔻30
SOstream overhead (text)160 (+54%) 🔻252 🔴 (+49%) 🔻308 (+72%) 🔻769 (+114%) 🔻30
SOstream overhead (structured)152 (+46%) 🔻270 🔴 (+53%) 🔻354 (+52%) 🔻618 (+20%) 🔻30
ℹ️ 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.

@karthikscale3
karthikscale3 marked this pull request as ready for review August 5, 2026 01:42
@karthikscale3
karthikscale3 requested a review from a team as a code ownerAugust 5, 2026 01:42
On a lazy hook queue delivery, the consumer's idempotent hook_received
re-ensure is hoisted above run_started and doubles as the invocation's
setup request: it asks the World to return the current replay log with
the write (new advisory CreateEventParams.preloadEvents), so one HTTP
request yields the canonical event, the reconstructed run, and the
complete replay log — skipping both the run_started POST and the
initial events.list.
- world: optional `preloadEvents?: true` on CreateEventParams, the
hook_received dual of skipPreload; Worlds may ignore it
- world-vercel: createHookReceivedPreloadEventV4 sends the frame Accept
on eligible hook_received posts and decodes either response mode —
frames via the response decoder extracted from the LIST consumer
(GET behavior unchanged), CBOR via the shared materialized-response
mapping. The run is reconstructed from run_created/run_started (plus
attr_set folds), the canonical event found by x-wf-event-id, and
resumeId now survives frame decoding so the runtime can match it
- core: new fast path before the generic run-state setup, guarded on
hookInput.resumeId + payloadDigest; a validated COMPLETE preload
(hasMore false — this path has no cursor-continuation machinery)
initializes workflowRun/preloadedEvents/maxEventsLimit directly,
anything else falls back to the run_started setup without re-posting
the hook; error classification matches the existing re-ensure
(terminal → consume, transient → redeliver); setup source reported
via workflow.resume_setup_source (never
workflow.hook.resilient_resume_materialized, which stays a
recovery-only signal)
- producer resumeHook() is unchanged and never sets preloadEvents
Based directly on main (no dependency on #3124/#3191); pairs with
workflow-server's streamed hook_received replay-log response, which
deploys first — the SDK negotiates per request and falls back safely
against older servers.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@karthikscale3
karthikscale3force-pushed the kk/hook-received-replay-preload branch from 549d6f0 to 2daae33CompareAugust 5, 2026 18:28

@TooTallNateTooTallNate 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.

Reviewed at 549d6f0. Locally: build + typecheck green, core 1920 passed / 3 expected fail, world-vercel 339 — with the 12 consumer-preload tests covering every fallback and error branch I went looking for.

What held up under scrutiny:

  • The completeness validation is the right shape: run + startedAt, non-empty events, non-null cursor, hasMore === false, numeric maxEvents (this response plays run_started's role, so a missing ceiling would silently disable event-limit enforcement — good catch), both lifecycle events, and the hook_received matching thisresumeId. On the cursor requirement: I confirmed the backend synthesizes a cursor on the final page even for single-page logs, so the check doesn't dead-letter short runs — but that server behavior is now load-bearing for this fast path; a code comment noting the dependency would help the next person.
  • Error classification is byte-for-byte consistent with the existing re-ensure: HookNotFound/RunExpired consume the delivery; everything else (EntityConflict, truncated stream, transport) rethrows for redelivery and the (runId, resumeId) claim converges. And the deliberate omission of HookResilientResumeMaterialized (this path carries no recovery signal) keeps that metric honest.
  • The terminal-event check before engine dispatch correctly plugs the QuickJS gap (it dispatches before the node loop's terminal detection), and preloadedEventsComplete as an explicit attestation — rather than widening the first-invocation heuristic — is the safer design.
  • resumeId threading through buildEventFromV4 is essential and easy to miss: without it, frame-decoded events would silently fail the matching check and the fast path would never fire. The comment says exactly that.
  • remoteRefBehavior: 'resolve' override on the preload request is right (v4 has no refs endpoint to hydrate lazy descriptors mid-replay), and reconstructRunFromReplayEvents carries every field downstream consumers read — including deploymentId and specVersion, which the in-flight deployment-affinity and slot-identity work key off.

Three asks before merge:

  1. Rebase — the PR is currently CONFLICTING with main (a one-file test conflict in events.test.ts vs #3334).
  2. Coordinate with #2960 (deployment-affinity guard, also open). Its design places the guard ahead of the lazy-hook re-ensure with the explicit invariant "a misrouted resume writes nothing here" — this fast path hoists the hook_received write above where that guard will sit. I believe the combination is still safe (the write is idempotent, involves no key derivation, and the guard still precedes any replay/step execution), but whichever PR lands second must reconcile the placement and rewrite that comment — the "writes nothing" invariant will no longer be literally true, and it should be weakened deliberately rather than silently.
  3. Changeset bump: @workflow/world gains a new public interface field (preloadEvents) plus documented EventResult semantics — per the convention we've applied on recent PRs, new API surface on the world interface should be minor, not patch.

For the record, the paired backend PR's red trigger lane ran with main's SDK (no Accept header → this feature dormant), and its failures match the varied preview-lane flakiness other backend branches see — not this pair. The real proof of the active path will be this PR's own e2e once rebased, since the backend half is already deployable ahead.

Nice perf win with a genuinely safe fallback story. Approving.

@karthikscale3

Copy link
Copy Markdown
ContributorAuthor

CI run completed without any issues (Except one unrelated flake). Checked datadog and compared CI run span metrics pre server side merge vs post server side merge:

The p50 improvement appears across every framework cohort:

SvelteKit: 1,194 → 939 ms, −255 ms
Next.js Turbopack: 1,396 → 1,156 ms, −239 ms
Next.js webpack: 1,371 → 1,048 ms, −324 ms

Merging this now as all review comments are addressed and the confidence levels are high.

@karthikscale3
karthikscale3 merged commit 9c1b3c8 into mainAug 6, 2026
411 of 421 checks passed
@karthikscale3
karthikscale3 deleted the kk/hook-received-replay-preload branch August 6, 2026 02:24
@github-actions

Copy link
Copy Markdown
Contributor

No backport to stable for 9c1b3c8 (AI decision).

This is a latency optimization that adds new API surface — an advisory preloadEvents param on CreateEventParams (a minor bump to @workflow/world), a new world-vercel request/response mode (createHookReceivedPreloadEventV4), a new preloadedEventsComplete option on the QuickJS entrypoint, a widened retry opt-in, and a new workflow.resume_setup_source telemetry attribute. It fixes no user-visible defect on stable, and it is explicitly designed to pair with a server-side change that must deploy first, so it belongs on main only.

To override, re-run the Backport to stable workflow manually via workflow_dispatch and paste this commit SHA into the ref input:

9c1b3c86384181b673d41123d2eec0b987afc75a

@TooTallNate

Copy link
Copy Markdown
Member

Heads-up: the #2960#3345 ordering interaction flagged in my review above is now a red unit test on main.

runtime.test.ts > workflowEntrypoint replay guards > re-routes a misrouted lazy hook resume with its payload intact fails on an unmodified checkout of main (reproduced at 9c1b3c8): the test asserts a misrouted lazy resume writes nohook_received before re-routing, but the fast path added here runs before the deployment-affinity guard from #2960, so the hoisted hook_received re-ensure (and, on the fallback path, run_started) now land before the guard fires:

AssertionError: expected events to not contain { eventType: 'hook_received' }
Received: [ hook_received (hook_1), run_started ]

As discussed in the review, I believe the behavior is still safe — the write is idempotent, involves no key derivation, and the guard still runs before any replay or step execution — but #2960's "a misrouted resume writes nothing here" invariant is now false and its test enforces it, so main is red until one of:

  1. Weaken the invariant deliberately: update the test (and fix(core): re-route runs delivered to the wrong deployment #2960's guard comment) to accept the idempotent pre-guard write, asserting instead that no replay/step work happens and the re-route still carries the payload; or
  2. Restore the invariant cheaply: carry the run's pinned deploymentId on the lazy-hook queue message (hookInput), so the guard can check for a mismatch before the fast path's write without the run fetch the guard otherwise needs — keeping both the perf win and the writes-nothing property.

(Found while reviewing #3372, whose author independently hit the same failing test and correctly identified it as pre-existing.)

cc @karthikscale3@alangenfeld

karthikscale3 added a commit that referenced this pull request Aug 6, 2026
…#3374)
The lazy hook fast path (#3345) hoisted the consumer's hook_received
write above the deployment-affinity guard (#2960), so a misrouted lazy
resume wrote its event before the guard could re-route the delivery.
Stamp the run's pinned deployment on the resume message
(hookInput.deploymentId, from the producer's resume context) and, on
the consumer, compare it against the ambient deployment id immediately
before the fast path: a match continues with no run fetch, a mismatch
fetches the authoritative run and hands it to the existing guard —
which keeps sole ownership of re-route/fail policy and remains the
authoritative protection before replay and step execution. The
re-routed message preserves the complete hookInput (it may hold the
only copy of the resume payload). Older messages without the field, and
worlds without deployment affinity, are unchanged: they skip the
pre-check and rely on the authoritative guard, the pre-guard write
staying convergent per (runId, resumeId).
Fixes the misrouted-lazy-resume unit test broken by the #2960/#3345
ordering: a modern misrouted resume now re-routes with zero event
writes, asserted for both hook_received and run_started.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
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.

3 participants

@karthikscale3@TooTallNate@pranaygp
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

perf(core): initialize lazy hook replay from hook_received stream - #3345

Merged
karthikscale3 merged 2 commits into
mainfrom
kk/hook-received-replay-preload
Aug 6, 2026
Merged

perf(core): initialize lazy hook replay from hook_received stream#3345
karthikscale3 merged 2 commits into
mainfrom
kk/hook-received-replay-preload

Conversation

@karthikscale3

@karthikscale3karthikscale3 commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

What

On a lazy hook queue delivery, the consumer's idempotent hook_received re-ensure is hoisted above run_started and asks the World to return the current replay log with the write (new advisory CreateEventParams.preloadEvents). When a complete preload comes back, the invocation initializes replay from that one request and skips both the run_started POST and the initial events.list; otherwise it falls back to the existing setup without re-posting the hook.

Before: run_started → hook_received re-ensure → events.list → replay
After: hook_received (returns replay log) → replay

Why

Each removed round trip sits directly on hook-resume latency (queue receipt → replay start). Folding the re-ensure and the replay load into one request cuts consumer startup/TTFS by roughly two request latencies on the normal lazy-resume path.

Notes

  • Worlds may ignore preloadEvents; a preload is trusted only when validated as complete (run + hasMore: false + maxEvents + lifecycle events + the matching resumeId). A terminal event in the preload consumes the delivery before engine dispatch.
  • Producer-side resumeHook() is unchanged and never sets preloadEvents.
  • Setup source is reported via workflow.resume_setup_source (hook_received_stream | hook_received_fallback).

🤖 Generated with Claude Code

@changeset-bot

changeset-botBot commented Aug 5, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 9ce3581

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/world-vercelPatch
@workflow/corePatch
@workflow/cliPatch
@workflow/vitestPatch
@workflow/web-sharedPatch
@workflow/webPatch
@workflow/world-localPatch
@workflow/world-postgresPatch
@workflow/world-testingPatch
@workflow/buildersPatch
@workflow/nextPatch
@workflow/nitroPatch
workflowPatch
@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

@vercel

vercelBot commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

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

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

@github-actions

github-actionsBot commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

🧪 E2E Test Results

All tests passed

E2E Test Summary

Summary
PassedFailedSkippedTotal
✅ ▲ Vercel Production293205003432
✅ 💻 Local Development315304353588
✅ 📦 Local Production329004543744
✅ 🐘 Local Postgres329004543744
✅ 🪟 Windows31200312
✅ 📋 Other206804282496
✅ vercel-multi-region270027
Total150720227117343
Details by Category

✅ ▲ Vercel Production

AppPassedFailedSkipped
✅ astro-node127029
✅ astro-quickjs127029
✅ example-node127029
✅ example-quickjs127029
✅ express-node127029
✅ express-quickjs127029
✅ fastify-node127029
✅ fastify-quickjs127029
✅ hono-node127029
✅ hono-quickjs127029
✅ nextjs-turbopack-node15204
✅ nextjs-turbopack-quickjs15204
✅ nextjs-webpack-node15204
✅ nextjs-webpack-quickjs15204
✅ nitro-node127029
✅ nitro-quickjs127029
✅ nuxt-node127029
✅ nuxt-quickjs127029
✅ sveltekit-node146010
✅ sveltekit-quickjs146010
✅ vite-node127029
✅ vite-quickjs127029

✅ 💻 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
✅ nextjs-turbopack-canary-node137019
✅ nextjs-turbopack-canary-quickjs137019
✅ nextjs-turbopack-stable-node15600
✅ nextjs-turbopack-stable-quickjs15600
✅ nextjs-webpack-canary-node137019
✅ 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
✅ 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
✅ 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
✅ 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
✅ 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
✅ vite-stable-node130026
✅ vite-stable-quickjs130026

✅ 🪟 Windows

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

✅ 📋 Other

AppPassedFailedSkipped
✅ e2e-local-dev-nest-stable-node130026
✅ e2e-local-dev-nest-stable-quickjs130026
✅ e2e-local-dev-tanstack-start-node130026
✅ e2e-local-dev-tanstack-start-quickjs130026
✅ e2e-local-postgres-nest-stable-node130026
✅ e2e-local-postgres-nest-stable-quickjs130026
✅ e2e-local-postgres-tanstack-start-node130026
✅ e2e-local-postgres-tanstack-start-quickjs130026
✅ e2e-local-prod-nest-stable-node130026
✅ e2e-local-prod-nest-stable-quickjs130026
✅ e2e-local-prod-tanstack-start-node130026
✅ e2e-local-prod-tanstack-start-quickjs130026
✅ e2e-vercel-prod-nest-node127029
✅ e2e-vercel-prod-nest-quickjs127029
✅ e2e-vercel-prod-tanstack-start-node127029
✅ e2e-vercel-prod-tanstack-start-quickjs127029

✅ vercel-multi-region

AppPassedFailedSkipped
✅ nextjs-turbopack2700

📋 View full workflow run

@github-actions

github-actionsBot commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

📊 Workflow Benchmarks

commit 9ce3581 · Wed, 05 Aug 2026 23:02:34 GMT · run logs

Backend: vercel · app: nextjs-turbopack

MetricScenarioBest (ms)P75 (ms)P90 (ms)P99 (ms)Samples
TTFSstep1316 (+71%) 🔻1441 🔴 (+48%) 🔻1473 🔴 (+39%) 🔻1839 🔴 (+9.7%)30
TTFSstream1293 (+42%) 🔻1403 🔴 (+44%) 🔻1465 🔴 (+47%) 🔻1516 🔴 (+17%) 🔻30
TTFShook + stream1508 (+74%) 🔻1678 🔴 (+35%) 🔻1700 🔴 (+35%) 🔻1784 🔴 (+20%) 🔻30
STSO1020 steps (inline)105 (+17%) 🔻142 (+8.4%)165 (+5.8%)295 (+19%) 🔻1018
STSO1020 steps (queue-hop)31983198319831981
WO1020 steps151538 (+17%) 🔻151538 (+17%) 🔻151538 (+17%) 🔻151538 (+17%) 🔻1
SLstream latency113 (+40%) 🔻171 🔴 (+38%) 🔻201 🔴 (+34%) 🔻467 🔴 (+14%)30
SOstream overhead (text)127 (+25%) 🔻183 (+17%) 🔻197 (+8.2%)308 (+55%) 🔻30
SOstream overhead (structured)120 (+17%) 🔻163 (-0.6%)189 (+6.2%)271 (-14%)30
📈 STSO distribution vs main (inline / queue-hop histograms)

1020 steps (inline)

Cumulative STSO time: main 128734ms → this run 146913ms (Δ +18179ms, +14%)

 50-100 ms ┃ main 4 this 0 -4
100-150 ms █████████████████████┃██ main 896 this 836 -60
150-200 ms ██░┃ main 93 this 140 +47
200-250 ms ┃ main 16 this 19 +3
250-300 ms ┃ main 3 this 16 +13
300-350 ms ┃ main 4 this 4 +0
350-400 ms ┃ main 1 this 0 -1
400-450 ms ┃ main 1 this 1 +0
500-550 ms ┃ main 1 this 0 -1
550-600 ms ┃ main 0 this 1 +1
5300-5350 ms ┃ main 0 this 1 +1

1020 steps (queue-hop)

Cumulative STSO time: 3198ms over 1 samples

No main baseline with raw samples yet — showing this run's distribution on its own; the diff appears once a run on main has recorded them.

3000-3500 ms ████████████████████████ steps 1
📜 Previous results (2)

2daae33

Wed, 05 Aug 2026 18:48:38 GMT · run logs

vercel / nextjs-turbopack

MetricScenarioBest (ms)P75 (ms)P90 (ms)P99 (ms)Samples
TTFSstep1329 (+201%) 🔻1453 🔴 (+28%) 🔻1487 🔴 (+2.3%)1819 🔴 (+18%) 🔻30
TTFSstream331 (+87%) 🔻1436 🔴 (+35%) 🔻1470 🔴 (+38%) 🔻1571 🔴 (+36%) 🔻30
TTFShook + stream1397 (+12%)1750 🔴 (+27%) 🔻1806 🔴 (+26%) 🔻1972 🔴 (+17%) 🔻30
STSO1020 steps (inline)98 (+15%) 🔻143 (+2.9%)164 (±0%)259 (-3.0%)1018
STSO1020 steps (queue-hop)3278 (+11%)3278 (+11%)3278 (+11%)3278 (+11%)1
WO1020 steps145014 (+5.1%)145014 (+5.1%)145014 (+5.1%)145014 (+5.1%)1
SLstream latency114 (+36%) 🔻169 🔴 (+50%) 🔻185 🔴 (+32%) 🔻292 🔴 (-20%) 💚30
SOstream overhead (text)142 (+33%) 🔻217 (+33%) 🔻272 (+47%) 🔻327 (-6.3%)30
SOstream overhead (structured)129 (+22%) 🔻185 (+22%) 🔻201 (+19%) 🔻277 (+25%) 🔻30

549d6f0

Wed, 05 Aug 2026 01:54:01 GMT · run logs

vercel / nextjs-turbopack

MetricScenarioBest (ms)P75 (ms)P90 (ms)P99 (ms)Samples
TTFSstep239 (-73%) 💚1381 🔴 (+33%) 🔻1534 🔴 (+16%) 🔻1833 🔴 (+24%) 🔻30
TTFSstream241 (-75%) 💚1342 🔴 (+31%) 🔻1372 🔴 (+32%) 🔻1544 🔴 (+33%) 🔻30
TTFShook + stream306 (-27%) 💚1548 🔴 (+19%) 🔻1585 🔴 (+3.3%)1720 🔴 (+0.6%)30
STSO1020 steps (inline)108 (+27%) 🔻151 (+7.9%)172 (+3.6%)255 (-41%) 💚1018
STSO1020 steps (queue-hop)2262 (-52%) 💚2262 (-52%) 💚2262 (-52%) 💚2262 (-52%) 💚1
WO1020 steps150841 (+0.8%)150841 (+0.8%)150841 (+0.8%)150841 (+0.8%)1
SLstream latency105 (+28%) 🔻171 🔴 (+58%) 🔻189 🔴 (+60%) 🔻222 🔴 (+31%) 🔻30
SOstream overhead (text)160 (+54%) 🔻252 🔴 (+49%) 🔻308 (+72%) 🔻769 (+114%) 🔻30
SOstream overhead (structured)152 (+46%) 🔻270 🔴 (+53%) 🔻354 (+52%) 🔻618 (+20%) 🔻30
ℹ️ 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.

@karthikscale3
karthikscale3 marked this pull request as ready for review August 5, 2026 01:42
@karthikscale3
karthikscale3 requested a review from a team as a code ownerAugust 5, 2026 01:42
On a lazy hook queue delivery, the consumer's idempotent hook_received
re-ensure is hoisted above run_started and doubles as the invocation's
setup request: it asks the World to return the current replay log with
the write (new advisory CreateEventParams.preloadEvents), so one HTTP
request yields the canonical event, the reconstructed run, and the
complete replay log — skipping both the run_started POST and the
initial events.list.
- world: optional `preloadEvents?: true` on CreateEventParams, the
hook_received dual of skipPreload; Worlds may ignore it
- world-vercel: createHookReceivedPreloadEventV4 sends the frame Accept
on eligible hook_received posts and decodes either response mode —
frames via the response decoder extracted from the LIST consumer
(GET behavior unchanged), CBOR via the shared materialized-response
mapping. The run is reconstructed from run_created/run_started (plus
attr_set folds), the canonical event found by x-wf-event-id, and
resumeId now survives frame decoding so the runtime can match it
- core: new fast path before the generic run-state setup, guarded on
hookInput.resumeId + payloadDigest; a validated COMPLETE preload
(hasMore false — this path has no cursor-continuation machinery)
initializes workflowRun/preloadedEvents/maxEventsLimit directly,
anything else falls back to the run_started setup without re-posting
the hook; error classification matches the existing re-ensure
(terminal → consume, transient → redeliver); setup source reported
via workflow.resume_setup_source (never
workflow.hook.resilient_resume_materialized, which stays a
recovery-only signal)
- producer resumeHook() is unchanged and never sets preloadEvents
Based directly on main (no dependency on #3124/#3191); pairs with
workflow-server's streamed hook_received replay-log response, which
deploys first — the SDK negotiates per request and falls back safely
against older servers.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@karthikscale3
karthikscale3force-pushed the kk/hook-received-replay-preload branch from 549d6f0 to 2daae33CompareAugust 5, 2026 18:28

@TooTallNateTooTallNate 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.

Reviewed at 549d6f0. Locally: build + typecheck green, core 1920 passed / 3 expected fail, world-vercel 339 — with the 12 consumer-preload tests covering every fallback and error branch I went looking for.

What held up under scrutiny:

  • The completeness validation is the right shape: run + startedAt, non-empty events, non-null cursor, hasMore === false, numeric maxEvents (this response plays run_started's role, so a missing ceiling would silently disable event-limit enforcement — good catch), both lifecycle events, and the hook_received matching thisresumeId. On the cursor requirement: I confirmed the backend synthesizes a cursor on the final page even for single-page logs, so the check doesn't dead-letter short runs — but that server behavior is now load-bearing for this fast path; a code comment noting the dependency would help the next person.
  • Error classification is byte-for-byte consistent with the existing re-ensure: HookNotFound/RunExpired consume the delivery; everything else (EntityConflict, truncated stream, transport) rethrows for redelivery and the (runId, resumeId) claim converges. And the deliberate omission of HookResilientResumeMaterialized (this path carries no recovery signal) keeps that metric honest.
  • The terminal-event check before engine dispatch correctly plugs the QuickJS gap (it dispatches before the node loop's terminal detection), and preloadedEventsComplete as an explicit attestation — rather than widening the first-invocation heuristic — is the safer design.
  • resumeId threading through buildEventFromV4 is essential and easy to miss: without it, frame-decoded events would silently fail the matching check and the fast path would never fire. The comment says exactly that.
  • remoteRefBehavior: 'resolve' override on the preload request is right (v4 has no refs endpoint to hydrate lazy descriptors mid-replay), and reconstructRunFromReplayEvents carries every field downstream consumers read — including deploymentId and specVersion, which the in-flight deployment-affinity and slot-identity work key off.

Three asks before merge:

  1. Rebase — the PR is currently CONFLICTING with main (a one-file test conflict in events.test.ts vs #3334).
  2. Coordinate with #2960 (deployment-affinity guard, also open). Its design places the guard ahead of the lazy-hook re-ensure with the explicit invariant "a misrouted resume writes nothing here" — this fast path hoists the hook_received write above where that guard will sit. I believe the combination is still safe (the write is idempotent, involves no key derivation, and the guard still precedes any replay/step execution), but whichever PR lands second must reconcile the placement and rewrite that comment — the "writes nothing" invariant will no longer be literally true, and it should be weakened deliberately rather than silently.
  3. Changeset bump: @workflow/world gains a new public interface field (preloadEvents) plus documented EventResult semantics — per the convention we've applied on recent PRs, new API surface on the world interface should be minor, not patch.

For the record, the paired backend PR's red trigger lane ran with main's SDK (no Accept header → this feature dormant), and its failures match the varied preview-lane flakiness other backend branches see — not this pair. The real proof of the active path will be this PR's own e2e once rebased, since the backend half is already deployable ahead.

Nice perf win with a genuinely safe fallback story. Approving.

@karthikscale3

Copy link
Copy Markdown
ContributorAuthor

CI run completed without any issues (Except one unrelated flake). Checked datadog and compared CI run span metrics pre server side merge vs post server side merge:

The p50 improvement appears across every framework cohort:

SvelteKit: 1,194 → 939 ms, −255 ms
Next.js Turbopack: 1,396 → 1,156 ms, −239 ms
Next.js webpack: 1,371 → 1,048 ms, −324 ms

Merging this now as all review comments are addressed and the confidence levels are high.

@karthikscale3
karthikscale3 merged commit 9c1b3c8 into mainAug 6, 2026
411 of 421 checks passed
@karthikscale3
karthikscale3 deleted the kk/hook-received-replay-preload branch August 6, 2026 02:24
@github-actions

Copy link
Copy Markdown
Contributor

No backport to stable for 9c1b3c8 (AI decision).

This is a latency optimization that adds new API surface — an advisory preloadEvents param on CreateEventParams (a minor bump to @workflow/world), a new world-vercel request/response mode (createHookReceivedPreloadEventV4), a new preloadedEventsComplete option on the QuickJS entrypoint, a widened retry opt-in, and a new workflow.resume_setup_source telemetry attribute. It fixes no user-visible defect on stable, and it is explicitly designed to pair with a server-side change that must deploy first, so it belongs on main only.

To override, re-run the Backport to stable workflow manually via workflow_dispatch and paste this commit SHA into the ref input:

9c1b3c86384181b673d41123d2eec0b987afc75a

@TooTallNate

Copy link
Copy Markdown
Member

Heads-up: the #2960#3345 ordering interaction flagged in my review above is now a red unit test on main.

runtime.test.ts > workflowEntrypoint replay guards > re-routes a misrouted lazy hook resume with its payload intact fails on an unmodified checkout of main (reproduced at 9c1b3c8): the test asserts a misrouted lazy resume writes nohook_received before re-routing, but the fast path added here runs before the deployment-affinity guard from #2960, so the hoisted hook_received re-ensure (and, on the fallback path, run_started) now land before the guard fires:

AssertionError: expected events to not contain { eventType: 'hook_received' }
Received: [ hook_received (hook_1), run_started ]

As discussed in the review, I believe the behavior is still safe — the write is idempotent, involves no key derivation, and the guard still runs before any replay or step execution — but #2960's "a misrouted resume writes nothing here" invariant is now false and its test enforces it, so main is red until one of:

  1. Weaken the invariant deliberately: update the test (and fix(core): re-route runs delivered to the wrong deployment #2960's guard comment) to accept the idempotent pre-guard write, asserting instead that no replay/step work happens and the re-route still carries the payload; or
  2. Restore the invariant cheaply: carry the run's pinned deploymentId on the lazy-hook queue message (hookInput), so the guard can check for a mismatch before the fast path's write without the run fetch the guard otherwise needs — keeping both the perf win and the writes-nothing property.

(Found while reviewing #3372, whose author independently hit the same failing test and correctly identified it as pre-existing.)

cc @karthikscale3@alangenfeld

karthikscale3 added a commit that referenced this pull request Aug 6, 2026
…#3374)
The lazy hook fast path (#3345) hoisted the consumer's hook_received
write above the deployment-affinity guard (#2960), so a misrouted lazy
resume wrote its event before the guard could re-route the delivery.
Stamp the run's pinned deployment on the resume message
(hookInput.deploymentId, from the producer's resume context) and, on
the consumer, compare it against the ambient deployment id immediately
before the fast path: a match continues with no run fetch, a mismatch
fetches the authoritative run and hands it to the existing guard —
which keeps sole ownership of re-route/fail policy and remains the
authoritative protection before replay and step execution. The
re-routed message preserves the complete hookInput (it may hold the
only copy of the resume payload). Older messages without the field, and
worlds without deployment affinity, are unchanged: they skip the
pre-check and rely on the authoritative guard, the pre-guard write
staying convergent per (runId, resumeId).
Fixes the misrouted-lazy-resume unit test broken by the #2960/#3345
ordering: a modern misrouted resume now re-routes with zero event
writes, asserted for both hook_received and run_started.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
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.

3 participants

@karthikscale3@TooTallNate@pranaygp
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

perf(core): initialize lazy hook replay from hook_received stream - #3345

Merged
karthikscale3 merged 2 commits into
mainfrom
kk/hook-received-replay-preload
Aug 6, 2026
Merged

perf(core): initialize lazy hook replay from hook_received stream#3345
karthikscale3 merged 2 commits into
mainfrom
kk/hook-received-replay-preload

Conversation

@karthikscale3

@karthikscale3karthikscale3 commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

What

On a lazy hook queue delivery, the consumer's idempotent hook_received re-ensure is hoisted above run_started and asks the World to return the current replay log with the write (new advisory CreateEventParams.preloadEvents). When a complete preload comes back, the invocation initializes replay from that one request and skips both the run_started POST and the initial events.list; otherwise it falls back to the existing setup without re-posting the hook.

Before: run_started → hook_received re-ensure → events.list → replay
After: hook_received (returns replay log) → replay

Why

Each removed round trip sits directly on hook-resume latency (queue receipt → replay start). Folding the re-ensure and the replay load into one request cuts consumer startup/TTFS by roughly two request latencies on the normal lazy-resume path.

Notes

  • Worlds may ignore preloadEvents; a preload is trusted only when validated as complete (run + hasMore: false + maxEvents + lifecycle events + the matching resumeId). A terminal event in the preload consumes the delivery before engine dispatch.
  • Producer-side resumeHook() is unchanged and never sets preloadEvents.
  • Setup source is reported via workflow.resume_setup_source (hook_received_stream | hook_received_fallback).

🤖 Generated with Claude Code

@changeset-bot

changeset-botBot commented Aug 5, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 9ce3581

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/world-vercelPatch
@workflow/corePatch
@workflow/cliPatch
@workflow/vitestPatch
@workflow/web-sharedPatch
@workflow/webPatch
@workflow/world-localPatch
@workflow/world-postgresPatch
@workflow/world-testingPatch
@workflow/buildersPatch
@workflow/nextPatch
@workflow/nitroPatch
workflowPatch
@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

@vercel

vercelBot commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

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

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

@github-actions

github-actionsBot commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

🧪 E2E Test Results

All tests passed

E2E Test Summary

Summary
PassedFailedSkippedTotal
✅ ▲ Vercel Production293205003432
✅ 💻 Local Development315304353588
✅ 📦 Local Production329004543744
✅ 🐘 Local Postgres329004543744
✅ 🪟 Windows31200312
✅ 📋 Other206804282496
✅ vercel-multi-region270027
Total150720227117343
Details by Category

✅ ▲ Vercel Production

AppPassedFailedSkipped
✅ astro-node127029
✅ astro-quickjs127029
✅ example-node127029
✅ example-quickjs127029
✅ express-node127029
✅ express-quickjs127029
✅ fastify-node127029
✅ fastify-quickjs127029
✅ hono-node127029
✅ hono-quickjs127029
✅ nextjs-turbopack-node15204
✅ nextjs-turbopack-quickjs15204
✅ nextjs-webpack-node15204
✅ nextjs-webpack-quickjs15204
✅ nitro-node127029
✅ nitro-quickjs127029
✅ nuxt-node127029
✅ nuxt-quickjs127029
✅ sveltekit-node146010
✅ sveltekit-quickjs146010
✅ vite-node127029
✅ vite-quickjs127029

✅ 💻 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
✅ nextjs-turbopack-canary-node137019
✅ nextjs-turbopack-canary-quickjs137019
✅ nextjs-turbopack-stable-node15600
✅ nextjs-turbopack-stable-quickjs15600
✅ nextjs-webpack-canary-node137019
✅ 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
✅ 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
✅ 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
✅ 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
✅ 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
✅ vite-stable-node130026
✅ vite-stable-quickjs130026

✅ 🪟 Windows

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

✅ 📋 Other

AppPassedFailedSkipped
✅ e2e-local-dev-nest-stable-node130026
✅ e2e-local-dev-nest-stable-quickjs130026
✅ e2e-local-dev-tanstack-start-node130026
✅ e2e-local-dev-tanstack-start-quickjs130026
✅ e2e-local-postgres-nest-stable-node130026
✅ e2e-local-postgres-nest-stable-quickjs130026
✅ e2e-local-postgres-tanstack-start-node130026
✅ e2e-local-postgres-tanstack-start-quickjs130026
✅ e2e-local-prod-nest-stable-node130026
✅ e2e-local-prod-nest-stable-quickjs130026
✅ e2e-local-prod-tanstack-start-node130026
✅ e2e-local-prod-tanstack-start-quickjs130026
✅ e2e-vercel-prod-nest-node127029
✅ e2e-vercel-prod-nest-quickjs127029
✅ e2e-vercel-prod-tanstack-start-node127029
✅ e2e-vercel-prod-tanstack-start-quickjs127029

✅ vercel-multi-region

AppPassedFailedSkipped
✅ nextjs-turbopack2700

📋 View full workflow run

@github-actions

github-actionsBot commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

📊 Workflow Benchmarks

commit 9ce3581 · Wed, 05 Aug 2026 23:02:34 GMT · run logs

Backend: vercel · app: nextjs-turbopack

MetricScenarioBest (ms)P75 (ms)P90 (ms)P99 (ms)Samples
TTFSstep1316 (+71%) 🔻1441 🔴 (+48%) 🔻1473 🔴 (+39%) 🔻1839 🔴 (+9.7%)30
TTFSstream1293 (+42%) 🔻1403 🔴 (+44%) 🔻1465 🔴 (+47%) 🔻1516 🔴 (+17%) 🔻30
TTFShook + stream1508 (+74%) 🔻1678 🔴 (+35%) 🔻1700 🔴 (+35%) 🔻1784 🔴 (+20%) 🔻30
STSO1020 steps (inline)105 (+17%) 🔻142 (+8.4%)165 (+5.8%)295 (+19%) 🔻1018
STSO1020 steps (queue-hop)31983198319831981
WO1020 steps151538 (+17%) 🔻151538 (+17%) 🔻151538 (+17%) 🔻151538 (+17%) 🔻1
SLstream latency113 (+40%) 🔻171 🔴 (+38%) 🔻201 🔴 (+34%) 🔻467 🔴 (+14%)30
SOstream overhead (text)127 (+25%) 🔻183 (+17%) 🔻197 (+8.2%)308 (+55%) 🔻30
SOstream overhead (structured)120 (+17%) 🔻163 (-0.6%)189 (+6.2%)271 (-14%)30
📈 STSO distribution vs main (inline / queue-hop histograms)

1020 steps (inline)

Cumulative STSO time: main 128734ms → this run 146913ms (Δ +18179ms, +14%)

 50-100 ms ┃ main 4 this 0 -4
100-150 ms █████████████████████┃██ main 896 this 836 -60
150-200 ms ██░┃ main 93 this 140 +47
200-250 ms ┃ main 16 this 19 +3
250-300 ms ┃ main 3 this 16 +13
300-350 ms ┃ main 4 this 4 +0
350-400 ms ┃ main 1 this 0 -1
400-450 ms ┃ main 1 this 1 +0
500-550 ms ┃ main 1 this 0 -1
550-600 ms ┃ main 0 this 1 +1
5300-5350 ms ┃ main 0 this 1 +1

1020 steps (queue-hop)

Cumulative STSO time: 3198ms over 1 samples

No main baseline with raw samples yet — showing this run's distribution on its own; the diff appears once a run on main has recorded them.

3000-3500 ms ████████████████████████ steps 1
📜 Previous results (2)

2daae33

Wed, 05 Aug 2026 18:48:38 GMT · run logs

vercel / nextjs-turbopack

MetricScenarioBest (ms)P75 (ms)P90 (ms)P99 (ms)Samples
TTFSstep1329 (+201%) 🔻1453 🔴 (+28%) 🔻1487 🔴 (+2.3%)1819 🔴 (+18%) 🔻30
TTFSstream331 (+87%) 🔻1436 🔴 (+35%) 🔻1470 🔴 (+38%) 🔻1571 🔴 (+36%) 🔻30
TTFShook + stream1397 (+12%)1750 🔴 (+27%) 🔻1806 🔴 (+26%) 🔻1972 🔴 (+17%) 🔻30
STSO1020 steps (inline)98 (+15%) 🔻143 (+2.9%)164 (±0%)259 (-3.0%)1018
STSO1020 steps (queue-hop)3278 (+11%)3278 (+11%)3278 (+11%)3278 (+11%)1
WO1020 steps145014 (+5.1%)145014 (+5.1%)145014 (+5.1%)145014 (+5.1%)1
SLstream latency114 (+36%) 🔻169 🔴 (+50%) 🔻185 🔴 (+32%) 🔻292 🔴 (-20%) 💚30
SOstream overhead (text)142 (+33%) 🔻217 (+33%) 🔻272 (+47%) 🔻327 (-6.3%)30
SOstream overhead (structured)129 (+22%) 🔻185 (+22%) 🔻201 (+19%) 🔻277 (+25%) 🔻30

549d6f0

Wed, 05 Aug 2026 01:54:01 GMT · run logs

vercel / nextjs-turbopack

MetricScenarioBest (ms)P75 (ms)P90 (ms)P99 (ms)Samples
TTFSstep239 (-73%) 💚1381 🔴 (+33%) 🔻1534 🔴 (+16%) 🔻1833 🔴 (+24%) 🔻30
TTFSstream241 (-75%) 💚1342 🔴 (+31%) 🔻1372 🔴 (+32%) 🔻1544 🔴 (+33%) 🔻30
TTFShook + stream306 (-27%) 💚1548 🔴 (+19%) 🔻1585 🔴 (+3.3%)1720 🔴 (+0.6%)30
STSO1020 steps (inline)108 (+27%) 🔻151 (+7.9%)172 (+3.6%)255 (-41%) 💚1018
STSO1020 steps (queue-hop)2262 (-52%) 💚2262 (-52%) 💚2262 (-52%) 💚2262 (-52%) 💚1
WO1020 steps150841 (+0.8%)150841 (+0.8%)150841 (+0.8%)150841 (+0.8%)1
SLstream latency105 (+28%) 🔻171 🔴 (+58%) 🔻189 🔴 (+60%) 🔻222 🔴 (+31%) 🔻30
SOstream overhead (text)160 (+54%) 🔻252 🔴 (+49%) 🔻308 (+72%) 🔻769 (+114%) 🔻30
SOstream overhead (structured)152 (+46%) 🔻270 🔴 (+53%) 🔻354 (+52%) 🔻618 (+20%) 🔻30
ℹ️ 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.

@karthikscale3
karthikscale3 marked this pull request as ready for review August 5, 2026 01:42
@karthikscale3
karthikscale3 requested a review from a team as a code ownerAugust 5, 2026 01:42
On a lazy hook queue delivery, the consumer's idempotent hook_received
re-ensure is hoisted above run_started and doubles as the invocation's
setup request: it asks the World to return the current replay log with
the write (new advisory CreateEventParams.preloadEvents), so one HTTP
request yields the canonical event, the reconstructed run, and the
complete replay log — skipping both the run_started POST and the
initial events.list.
- world: optional `preloadEvents?: true` on CreateEventParams, the
hook_received dual of skipPreload; Worlds may ignore it
- world-vercel: createHookReceivedPreloadEventV4 sends the frame Accept
on eligible hook_received posts and decodes either response mode —
frames via the response decoder extracted from the LIST consumer
(GET behavior unchanged), CBOR via the shared materialized-response
mapping. The run is reconstructed from run_created/run_started (plus
attr_set folds), the canonical event found by x-wf-event-id, and
resumeId now survives frame decoding so the runtime can match it
- core: new fast path before the generic run-state setup, guarded on
hookInput.resumeId + payloadDigest; a validated COMPLETE preload
(hasMore false — this path has no cursor-continuation machinery)
initializes workflowRun/preloadedEvents/maxEventsLimit directly,
anything else falls back to the run_started setup without re-posting
the hook; error classification matches the existing re-ensure
(terminal → consume, transient → redeliver); setup source reported
via workflow.resume_setup_source (never
workflow.hook.resilient_resume_materialized, which stays a
recovery-only signal)
- producer resumeHook() is unchanged and never sets preloadEvents
Based directly on main (no dependency on #3124/#3191); pairs with
workflow-server's streamed hook_received replay-log response, which
deploys first — the SDK negotiates per request and falls back safely
against older servers.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@karthikscale3
karthikscale3force-pushed the kk/hook-received-replay-preload branch from 549d6f0 to 2daae33CompareAugust 5, 2026 18:28

@TooTallNateTooTallNate 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.

Reviewed at 549d6f0. Locally: build + typecheck green, core 1920 passed / 3 expected fail, world-vercel 339 — with the 12 consumer-preload tests covering every fallback and error branch I went looking for.

What held up under scrutiny:

  • The completeness validation is the right shape: run + startedAt, non-empty events, non-null cursor, hasMore === false, numeric maxEvents (this response plays run_started's role, so a missing ceiling would silently disable event-limit enforcement — good catch), both lifecycle events, and the hook_received matching thisresumeId. On the cursor requirement: I confirmed the backend synthesizes a cursor on the final page even for single-page logs, so the check doesn't dead-letter short runs — but that server behavior is now load-bearing for this fast path; a code comment noting the dependency would help the next person.
  • Error classification is byte-for-byte consistent with the existing re-ensure: HookNotFound/RunExpired consume the delivery; everything else (EntityConflict, truncated stream, transport) rethrows for redelivery and the (runId, resumeId) claim converges. And the deliberate omission of HookResilientResumeMaterialized (this path carries no recovery signal) keeps that metric honest.
  • The terminal-event check before engine dispatch correctly plugs the QuickJS gap (it dispatches before the node loop's terminal detection), and preloadedEventsComplete as an explicit attestation — rather than widening the first-invocation heuristic — is the safer design.
  • resumeId threading through buildEventFromV4 is essential and easy to miss: without it, frame-decoded events would silently fail the matching check and the fast path would never fire. The comment says exactly that.
  • remoteRefBehavior: 'resolve' override on the preload request is right (v4 has no refs endpoint to hydrate lazy descriptors mid-replay), and reconstructRunFromReplayEvents carries every field downstream consumers read — including deploymentId and specVersion, which the in-flight deployment-affinity and slot-identity work key off.

Three asks before merge:

  1. Rebase — the PR is currently CONFLICTING with main (a one-file test conflict in events.test.ts vs #3334).
  2. Coordinate with #2960 (deployment-affinity guard, also open). Its design places the guard ahead of the lazy-hook re-ensure with the explicit invariant "a misrouted resume writes nothing here" — this fast path hoists the hook_received write above where that guard will sit. I believe the combination is still safe (the write is idempotent, involves no key derivation, and the guard still precedes any replay/step execution), but whichever PR lands second must reconcile the placement and rewrite that comment — the "writes nothing" invariant will no longer be literally true, and it should be weakened deliberately rather than silently.
  3. Changeset bump: @workflow/world gains a new public interface field (preloadEvents) plus documented EventResult semantics — per the convention we've applied on recent PRs, new API surface on the world interface should be minor, not patch.

For the record, the paired backend PR's red trigger lane ran with main's SDK (no Accept header → this feature dormant), and its failures match the varied preview-lane flakiness other backend branches see — not this pair. The real proof of the active path will be this PR's own e2e once rebased, since the backend half is already deployable ahead.

Nice perf win with a genuinely safe fallback story. Approving.

@karthikscale3

Copy link
Copy Markdown
ContributorAuthor

CI run completed without any issues (Except one unrelated flake). Checked datadog and compared CI run span metrics pre server side merge vs post server side merge:

The p50 improvement appears across every framework cohort:

SvelteKit: 1,194 → 939 ms, −255 ms
Next.js Turbopack: 1,396 → 1,156 ms, −239 ms
Next.js webpack: 1,371 → 1,048 ms, −324 ms

Merging this now as all review comments are addressed and the confidence levels are high.

@karthikscale3
karthikscale3 merged commit 9c1b3c8 into mainAug 6, 2026
411 of 421 checks passed
@karthikscale3
karthikscale3 deleted the kk/hook-received-replay-preload branch August 6, 2026 02:24
@github-actions

Copy link
Copy Markdown
Contributor

No backport to stable for 9c1b3c8 (AI decision).

This is a latency optimization that adds new API surface — an advisory preloadEvents param on CreateEventParams (a minor bump to @workflow/world), a new world-vercel request/response mode (createHookReceivedPreloadEventV4), a new preloadedEventsComplete option on the QuickJS entrypoint, a widened retry opt-in, and a new workflow.resume_setup_source telemetry attribute. It fixes no user-visible defect on stable, and it is explicitly designed to pair with a server-side change that must deploy first, so it belongs on main only.

To override, re-run the Backport to stable workflow manually via workflow_dispatch and paste this commit SHA into the ref input:

9c1b3c86384181b673d41123d2eec0b987afc75a

@TooTallNate

Copy link
Copy Markdown
Member

Heads-up: the #2960#3345 ordering interaction flagged in my review above is now a red unit test on main.

runtime.test.ts > workflowEntrypoint replay guards > re-routes a misrouted lazy hook resume with its payload intact fails on an unmodified checkout of main (reproduced at 9c1b3c8): the test asserts a misrouted lazy resume writes nohook_received before re-routing, but the fast path added here runs before the deployment-affinity guard from #2960, so the hoisted hook_received re-ensure (and, on the fallback path, run_started) now land before the guard fires:

AssertionError: expected events to not contain { eventType: 'hook_received' }
Received: [ hook_received (hook_1), run_started ]

As discussed in the review, I believe the behavior is still safe — the write is idempotent, involves no key derivation, and the guard still runs before any replay or step execution — but #2960's "a misrouted resume writes nothing here" invariant is now false and its test enforces it, so main is red until one of:

  1. Weaken the invariant deliberately: update the test (and fix(core): re-route runs delivered to the wrong deployment #2960's guard comment) to accept the idempotent pre-guard write, asserting instead that no replay/step work happens and the re-route still carries the payload; or
  2. Restore the invariant cheaply: carry the run's pinned deploymentId on the lazy-hook queue message (hookInput), so the guard can check for a mismatch before the fast path's write without the run fetch the guard otherwise needs — keeping both the perf win and the writes-nothing property.

(Found while reviewing #3372, whose author independently hit the same failing test and correctly identified it as pre-existing.)

cc @karthikscale3@alangenfeld

karthikscale3 added a commit that referenced this pull request Aug 6, 2026
…#3374)
The lazy hook fast path (#3345) hoisted the consumer's hook_received
write above the deployment-affinity guard (#2960), so a misrouted lazy
resume wrote its event before the guard could re-route the delivery.
Stamp the run's pinned deployment on the resume message
(hookInput.deploymentId, from the producer's resume context) and, on
the consumer, compare it against the ambient deployment id immediately
before the fast path: a match continues with no run fetch, a mismatch
fetches the authoritative run and hands it to the existing guard —
which keeps sole ownership of re-route/fail policy and remains the
authoritative protection before replay and step execution. The
re-routed message preserves the complete hookInput (it may hold the
only copy of the resume payload). Older messages without the field, and
worlds without deployment affinity, are unchanged: they skip the
pre-check and rely on the authoritative guard, the pre-guard write
staying convergent per (runId, resumeId).
Fixes the misrouted-lazy-resume unit test broken by the #2960/#3345
ordering: a modern misrouted resume now re-routes with zero event
writes, asserted for both hook_received and run_started.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
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.

3 participants

@karthikscale3@TooTallNate@pranaygp
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content

perf(core): initialize lazy hook replay from hook_received stream - #3345

Merged
karthikscale3 merged 2 commits into
mainfrom
kk/hook-received-replay-preload
Aug 6, 2026
Merged

perf(core): initialize lazy hook replay from hook_received stream#3345
karthikscale3 merged 2 commits into
mainfrom
kk/hook-received-replay-preload

Conversation

@karthikscale3

@karthikscale3karthikscale3 commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

What

On a lazy hook queue delivery, the consumer's idempotent hook_received re-ensure is hoisted above run_started and asks the World to return the current replay log with the write (new advisory CreateEventParams.preloadEvents). When a complete preload comes back, the invocation initializes replay from that one request and skips both the run_started POST and the initial events.list; otherwise it falls back to the existing setup without re-posting the hook.

Before: run_started → hook_received re-ensure → events.list → replay
After: hook_received (returns replay log) → replay

Why

Each removed round trip sits directly on hook-resume latency (queue receipt → replay start). Folding the re-ensure and the replay load into one request cuts consumer startup/TTFS by roughly two request latencies on the normal lazy-resume path.

Notes

  • Worlds may ignore preloadEvents; a preload is trusted only when validated as complete (run + hasMore: false + maxEvents + lifecycle events + the matching resumeId). A terminal event in the preload consumes the delivery before engine dispatch.
  • Producer-side resumeHook() is unchanged and never sets preloadEvents.
  • Setup source is reported via workflow.resume_setup_source (hook_received_stream | hook_received_fallback).

🤖 Generated with Claude Code

@changeset-bot

changeset-botBot commented Aug 5, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 9ce3581

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/world-vercelPatch
@workflow/corePatch
@workflow/cliPatch
@workflow/vitestPatch
@workflow/web-sharedPatch
@workflow/webPatch
@workflow/world-localPatch
@workflow/world-postgresPatch
@workflow/world-testingPatch
@workflow/buildersPatch
@workflow/nextPatch
@workflow/nitroPatch
workflowPatch
@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

@vercel

vercelBot commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

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

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

@github-actions

github-actionsBot commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

🧪 E2E Test Results

All tests passed

E2E Test Summary

Summary
PassedFailedSkippedTotal
✅ ▲ Vercel Production293205003432
✅ 💻 Local Development315304353588
✅ 📦 Local Production329004543744
✅ 🐘 Local Postgres329004543744
✅ 🪟 Windows31200312
✅ 📋 Other206804282496
✅ vercel-multi-region270027
Total150720227117343
Details by Category

✅ ▲ Vercel Production

AppPassedFailedSkipped
✅ astro-node127029
✅ astro-quickjs127029
✅ example-node127029
✅ example-quickjs127029
✅ express-node127029
✅ express-quickjs127029
✅ fastify-node127029
✅ fastify-quickjs127029
✅ hono-node127029
✅ hono-quickjs127029
✅ nextjs-turbopack-node15204
✅ nextjs-turbopack-quickjs15204
✅ nextjs-webpack-node15204
✅ nextjs-webpack-quickjs15204
✅ nitro-node127029
✅ nitro-quickjs127029
✅ nuxt-node127029
✅ nuxt-quickjs127029
✅ sveltekit-node146010
✅ sveltekit-quickjs146010
✅ vite-node127029
✅ vite-quickjs127029

✅ 💻 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
✅ nextjs-turbopack-canary-node137019
✅ nextjs-turbopack-canary-quickjs137019
✅ nextjs-turbopack-stable-node15600
✅ nextjs-turbopack-stable-quickjs15600
✅ nextjs-webpack-canary-node137019
✅ 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
✅ 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
✅ 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
✅ 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
✅ 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
✅ vite-stable-node130026
✅ vite-stable-quickjs130026

✅ 🪟 Windows

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

✅ 📋 Other

AppPassedFailedSkipped
✅ e2e-local-dev-nest-stable-node130026
✅ e2e-local-dev-nest-stable-quickjs130026
✅ e2e-local-dev-tanstack-start-node130026
✅ e2e-local-dev-tanstack-start-quickjs130026
✅ e2e-local-postgres-nest-stable-node130026
✅ e2e-local-postgres-nest-stable-quickjs130026
✅ e2e-local-postgres-tanstack-start-node130026
✅ e2e-local-postgres-tanstack-start-quickjs130026
✅ e2e-local-prod-nest-stable-node130026
✅ e2e-local-prod-nest-stable-quickjs130026
✅ e2e-local-prod-tanstack-start-node130026
✅ e2e-local-prod-tanstack-start-quickjs130026
✅ e2e-vercel-prod-nest-node127029
✅ e2e-vercel-prod-nest-quickjs127029
✅ e2e-vercel-prod-tanstack-start-node127029
✅ e2e-vercel-prod-tanstack-start-quickjs127029

✅ vercel-multi-region

AppPassedFailedSkipped
✅ nextjs-turbopack2700

📋 View full workflow run

@github-actions

github-actionsBot commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

📊 Workflow Benchmarks

commit 9ce3581 · Wed, 05 Aug 2026 23:02:34 GMT · run logs

Backend: vercel · app: nextjs-turbopack

MetricScenarioBest (ms)P75 (ms)P90 (ms)P99 (ms)Samples
TTFSstep1316 (+71%) 🔻1441 🔴 (+48%) 🔻1473 🔴 (+39%) 🔻1839 🔴 (+9.7%)30
TTFSstream1293 (+42%) 🔻1403 🔴 (+44%) 🔻1465 🔴 (+47%) 🔻1516 🔴 (+17%) 🔻30
TTFShook + stream1508 (+74%) 🔻1678 🔴 (+35%) 🔻1700 🔴 (+35%) 🔻1784 🔴 (+20%) 🔻30
STSO1020 steps (inline)105 (+17%) 🔻142 (+8.4%)165 (+5.8%)295 (+19%) 🔻1018
STSO1020 steps (queue-hop)31983198319831981
WO1020 steps151538 (+17%) 🔻151538 (+17%) 🔻151538 (+17%) 🔻151538 (+17%) 🔻1
SLstream latency113 (+40%) 🔻171 🔴 (+38%) 🔻201 🔴 (+34%) 🔻467 🔴 (+14%)30
SOstream overhead (text)127 (+25%) 🔻183 (+17%) 🔻197 (+8.2%)308 (+55%) 🔻30
SOstream overhead (structured)120 (+17%) 🔻163 (-0.6%)189 (+6.2%)271 (-14%)30
📈 STSO distribution vs main (inline / queue-hop histograms)

1020 steps (inline)

Cumulative STSO time: main 128734ms → this run 146913ms (Δ +18179ms, +14%)

 50-100 ms ┃ main 4 this 0 -4
100-150 ms █████████████████████┃██ main 896 this 836 -60
150-200 ms ██░┃ main 93 this 140 +47
200-250 ms ┃ main 16 this 19 +3
250-300 ms ┃ main 3 this 16 +13
300-350 ms ┃ main 4 this 4 +0
350-400 ms ┃ main 1 this 0 -1
400-450 ms ┃ main 1 this 1 +0
500-550 ms ┃ main 1 this 0 -1
550-600 ms ┃ main 0 this 1 +1
5300-5350 ms ┃ main 0 this 1 +1

1020 steps (queue-hop)

Cumulative STSO time: 3198ms over 1 samples

No main baseline with raw samples yet — showing this run's distribution on its own; the diff appears once a run on main has recorded them.

3000-3500 ms ████████████████████████ steps 1
📜 Previous results (2)

2daae33

Wed, 05 Aug 2026 18:48:38 GMT · run logs

vercel / nextjs-turbopack

MetricScenarioBest (ms)P75 (ms)P90 (ms)P99 (ms)Samples
TTFSstep1329 (+201%) 🔻1453 🔴 (+28%) 🔻1487 🔴 (+2.3%)1819 🔴 (+18%) 🔻30
TTFSstream331 (+87%) 🔻1436 🔴 (+35%) 🔻1470 🔴 (+38%) 🔻1571 🔴 (+36%) 🔻30
TTFShook + stream1397 (+12%)1750 🔴 (+27%) 🔻1806 🔴 (+26%) 🔻1972 🔴 (+17%) 🔻30
STSO1020 steps (inline)98 (+15%) 🔻143 (+2.9%)164 (±0%)259 (-3.0%)1018
STSO1020 steps (queue-hop)3278 (+11%)3278 (+11%)3278 (+11%)3278 (+11%)1
WO1020 steps145014 (+5.1%)145014 (+5.1%)145014 (+5.1%)145014 (+5.1%)1
SLstream latency114 (+36%) 🔻169 🔴 (+50%) 🔻185 🔴 (+32%) 🔻292 🔴 (-20%) 💚30
SOstream overhead (text)142 (+33%) 🔻217 (+33%) 🔻272 (+47%) 🔻327 (-6.3%)30
SOstream overhead (structured)129 (+22%) 🔻185 (+22%) 🔻201 (+19%) 🔻277 (+25%) 🔻30

549d6f0

Wed, 05 Aug 2026 01:54:01 GMT · run logs

vercel / nextjs-turbopack

MetricScenarioBest (ms)P75 (ms)P90 (ms)P99 (ms)Samples
TTFSstep239 (-73%) 💚1381 🔴 (+33%) 🔻1534 🔴 (+16%) 🔻1833 🔴 (+24%) 🔻30
TTFSstream241 (-75%) 💚1342 🔴 (+31%) 🔻1372 🔴 (+32%) 🔻1544 🔴 (+33%) 🔻30
TTFShook + stream306 (-27%) 💚1548 🔴 (+19%) 🔻1585 🔴 (+3.3%)1720 🔴 (+0.6%)30
STSO1020 steps (inline)108 (+27%) 🔻151 (+7.9%)172 (+3.6%)255 (-41%) 💚1018
STSO1020 steps (queue-hop)2262 (-52%) 💚2262 (-52%) 💚2262 (-52%) 💚2262 (-52%) 💚1
WO1020 steps150841 (+0.8%)150841 (+0.8%)150841 (+0.8%)150841 (+0.8%)1
SLstream latency105 (+28%) 🔻171 🔴 (+58%) 🔻189 🔴 (+60%) 🔻222 🔴 (+31%) 🔻30
SOstream overhead (text)160 (+54%) 🔻252 🔴 (+49%) 🔻308 (+72%) 🔻769 (+114%) 🔻30
SOstream overhead (structured)152 (+46%) 🔻270 🔴 (+53%) 🔻354 (+52%) 🔻618 (+20%) 🔻30
ℹ️ 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.

@karthikscale3
karthikscale3 marked this pull request as ready for review August 5, 2026 01:42
@karthikscale3
karthikscale3 requested a review from a team as a code ownerAugust 5, 2026 01:42
On a lazy hook queue delivery, the consumer's idempotent hook_received
re-ensure is hoisted above run_started and doubles as the invocation's
setup request: it asks the World to return the current replay log with
the write (new advisory CreateEventParams.preloadEvents), so one HTTP
request yields the canonical event, the reconstructed run, and the
complete replay log — skipping both the run_started POST and the
initial events.list.
- world: optional `preloadEvents?: true` on CreateEventParams, the
hook_received dual of skipPreload; Worlds may ignore it
- world-vercel: createHookReceivedPreloadEventV4 sends the frame Accept
on eligible hook_received posts and decodes either response mode —
frames via the response decoder extracted from the LIST consumer
(GET behavior unchanged), CBOR via the shared materialized-response
mapping. The run is reconstructed from run_created/run_started (plus
attr_set folds), the canonical event found by x-wf-event-id, and
resumeId now survives frame decoding so the runtime can match it
- core: new fast path before the generic run-state setup, guarded on
hookInput.resumeId + payloadDigest; a validated COMPLETE preload
(hasMore false — this path has no cursor-continuation machinery)
initializes workflowRun/preloadedEvents/maxEventsLimit directly,
anything else falls back to the run_started setup without re-posting
the hook; error classification matches the existing re-ensure
(terminal → consume, transient → redeliver); setup source reported
via workflow.resume_setup_source (never
workflow.hook.resilient_resume_materialized, which stays a
recovery-only signal)
- producer resumeHook() is unchanged and never sets preloadEvents
Based directly on main (no dependency on #3124/#3191); pairs with
workflow-server's streamed hook_received replay-log response, which
deploys first — the SDK negotiates per request and falls back safely
against older servers.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@karthikscale3
karthikscale3force-pushed the kk/hook-received-replay-preload branch from 549d6f0 to 2daae33CompareAugust 5, 2026 18:28

@TooTallNateTooTallNate 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.

Reviewed at 549d6f0. Locally: build + typecheck green, core 1920 passed / 3 expected fail, world-vercel 339 — with the 12 consumer-preload tests covering every fallback and error branch I went looking for.

What held up under scrutiny:

  • The completeness validation is the right shape: run + startedAt, non-empty events, non-null cursor, hasMore === false, numeric maxEvents (this response plays run_started's role, so a missing ceiling would silently disable event-limit enforcement — good catch), both lifecycle events, and the hook_received matching thisresumeId. On the cursor requirement: I confirmed the backend synthesizes a cursor on the final page even for single-page logs, so the check doesn't dead-letter short runs — but that server behavior is now load-bearing for this fast path; a code comment noting the dependency would help the next person.
  • Error classification is byte-for-byte consistent with the existing re-ensure: HookNotFound/RunExpired consume the delivery; everything else (EntityConflict, truncated stream, transport) rethrows for redelivery and the (runId, resumeId) claim converges. And the deliberate omission of HookResilientResumeMaterialized (this path carries no recovery signal) keeps that metric honest.
  • The terminal-event check before engine dispatch correctly plugs the QuickJS gap (it dispatches before the node loop's terminal detection), and preloadedEventsComplete as an explicit attestation — rather than widening the first-invocation heuristic — is the safer design.
  • resumeId threading through buildEventFromV4 is essential and easy to miss: without it, frame-decoded events would silently fail the matching check and the fast path would never fire. The comment says exactly that.
  • remoteRefBehavior: 'resolve' override on the preload request is right (v4 has no refs endpoint to hydrate lazy descriptors mid-replay), and reconstructRunFromReplayEvents carries every field downstream consumers read — including deploymentId and specVersion, which the in-flight deployment-affinity and slot-identity work key off.

Three asks before merge:

  1. Rebase — the PR is currently CONFLICTING with main (a one-file test conflict in events.test.ts vs #3334).
  2. Coordinate with #2960 (deployment-affinity guard, also open). Its design places the guard ahead of the lazy-hook re-ensure with the explicit invariant "a misrouted resume writes nothing here" — this fast path hoists the hook_received write above where that guard will sit. I believe the combination is still safe (the write is idempotent, involves no key derivation, and the guard still precedes any replay/step execution), but whichever PR lands second must reconcile the placement and rewrite that comment — the "writes nothing" invariant will no longer be literally true, and it should be weakened deliberately rather than silently.
  3. Changeset bump: @workflow/world gains a new public interface field (preloadEvents) plus documented EventResult semantics — per the convention we've applied on recent PRs, new API surface on the world interface should be minor, not patch.

For the record, the paired backend PR's red trigger lane ran with main's SDK (no Accept header → this feature dormant), and its failures match the varied preview-lane flakiness other backend branches see — not this pair. The real proof of the active path will be this PR's own e2e once rebased, since the backend half is already deployable ahead.

Nice perf win with a genuinely safe fallback story. Approving.

@karthikscale3

Copy link
Copy Markdown
ContributorAuthor

CI run completed without any issues (Except one unrelated flake). Checked datadog and compared CI run span metrics pre server side merge vs post server side merge:

The p50 improvement appears across every framework cohort:

SvelteKit: 1,194 → 939 ms, −255 ms
Next.js Turbopack: 1,396 → 1,156 ms, −239 ms
Next.js webpack: 1,371 → 1,048 ms, −324 ms

Merging this now as all review comments are addressed and the confidence levels are high.

@karthikscale3
karthikscale3 merged commit 9c1b3c8 into mainAug 6, 2026
411 of 421 checks passed
@karthikscale3
karthikscale3 deleted the kk/hook-received-replay-preload branch August 6, 2026 02:24
@github-actions

Copy link
Copy Markdown
Contributor

No backport to stable for 9c1b3c8 (AI decision).

This is a latency optimization that adds new API surface — an advisory preloadEvents param on CreateEventParams (a minor bump to @workflow/world), a new world-vercel request/response mode (createHookReceivedPreloadEventV4), a new preloadedEventsComplete option on the QuickJS entrypoint, a widened retry opt-in, and a new workflow.resume_setup_source telemetry attribute. It fixes no user-visible defect on stable, and it is explicitly designed to pair with a server-side change that must deploy first, so it belongs on main only.

To override, re-run the Backport to stable workflow manually via workflow_dispatch and paste this commit SHA into the ref input:

9c1b3c86384181b673d41123d2eec0b987afc75a

@TooTallNate

Copy link
Copy Markdown
Member

Heads-up: the #2960#3345 ordering interaction flagged in my review above is now a red unit test on main.

runtime.test.ts > workflowEntrypoint replay guards > re-routes a misrouted lazy hook resume with its payload intact fails on an unmodified checkout of main (reproduced at 9c1b3c8): the test asserts a misrouted lazy resume writes nohook_received before re-routing, but the fast path added here runs before the deployment-affinity guard from #2960, so the hoisted hook_received re-ensure (and, on the fallback path, run_started) now land before the guard fires:

AssertionError: expected events to not contain { eventType: 'hook_received' }
Received: [ hook_received (hook_1), run_started ]

As discussed in the review, I believe the behavior is still safe — the write is idempotent, involves no key derivation, and the guard still runs before any replay or step execution — but #2960's "a misrouted resume writes nothing here" invariant is now false and its test enforces it, so main is red until one of:

  1. Weaken the invariant deliberately: update the test (and fix(core): re-route runs delivered to the wrong deployment #2960's guard comment) to accept the idempotent pre-guard write, asserting instead that no replay/step work happens and the re-route still carries the payload; or
  2. Restore the invariant cheaply: carry the run's pinned deploymentId on the lazy-hook queue message (hookInput), so the guard can check for a mismatch before the fast path's write without the run fetch the guard otherwise needs — keeping both the perf win and the writes-nothing property.

(Found while reviewing #3372, whose author independently hit the same failing test and correctly identified it as pre-existing.)

cc @karthikscale3@alangenfeld

karthikscale3 added a commit that referenced this pull request Aug 6, 2026
…#3374)
The lazy hook fast path (#3345) hoisted the consumer's hook_received
write above the deployment-affinity guard (#2960), so a misrouted lazy
resume wrote its event before the guard could re-route the delivery.
Stamp the run's pinned deployment on the resume message
(hookInput.deploymentId, from the producer's resume context) and, on
the consumer, compare it against the ambient deployment id immediately
before the fast path: a match continues with no run fetch, a mismatch
fetches the authoritative run and hands it to the existing guard —
which keeps sole ownership of re-route/fail policy and remains the
authoritative protection before replay and step execution. The
re-routed message preserves the complete hookInput (it may hold the
only copy of the resume payload). Older messages without the field, and
worlds without deployment affinity, are unchanged: they skip the
pre-check and rely on the authoritative guard, the pre-guard write
staying convergent per (runId, resumeId).
Fixes the misrouted-lazy-resume unit test broken by the #2960/#3345
ordering: a modern misrouted resume now re-routes with zero event
writes, asserted for both hook_received and run_started.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
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.

3 participants

@karthikscale3@TooTallNate@pranaygp
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

perf(core): initialize lazy hook replay from hook_received stream - #3345

Merged
karthikscale3 merged 2 commits into
mainfrom
kk/hook-received-replay-preload
Aug 6, 2026
Merged

perf(core): initialize lazy hook replay from hook_received stream#3345
karthikscale3 merged 2 commits into
mainfrom
kk/hook-received-replay-preload

Conversation

@karthikscale3

@karthikscale3karthikscale3 commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

What

On a lazy hook queue delivery, the consumer's idempotent hook_received re-ensure is hoisted above run_started and asks the World to return the current replay log with the write (new advisory CreateEventParams.preloadEvents). When a complete preload comes back, the invocation initializes replay from that one request and skips both the run_started POST and the initial events.list; otherwise it falls back to the existing setup without re-posting the hook.

Before: run_started → hook_received re-ensure → events.list → replay
After: hook_received (returns replay log) → replay

Why

Each removed round trip sits directly on hook-resume latency (queue receipt → replay start). Folding the re-ensure and the replay load into one request cuts consumer startup/TTFS by roughly two request latencies on the normal lazy-resume path.

Notes

  • Worlds may ignore preloadEvents; a preload is trusted only when validated as complete (run + hasMore: false + maxEvents + lifecycle events + the matching resumeId). A terminal event in the preload consumes the delivery before engine dispatch.
  • Producer-side resumeHook() is unchanged and never sets preloadEvents.
  • Setup source is reported via workflow.resume_setup_source (hook_received_stream | hook_received_fallback).

🤖 Generated with Claude Code

@changeset-bot

changeset-botBot commented Aug 5, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 9ce3581

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/world-vercelPatch
@workflow/corePatch
@workflow/cliPatch
@workflow/vitestPatch
@workflow/web-sharedPatch
@workflow/webPatch
@workflow/world-localPatch
@workflow/world-postgresPatch
@workflow/world-testingPatch
@workflow/buildersPatch
@workflow/nextPatch
@workflow/nitroPatch
workflowPatch
@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

@vercel

vercelBot commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

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

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

@github-actions

github-actionsBot commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

🧪 E2E Test Results

All tests passed

E2E Test Summary

Summary
PassedFailedSkippedTotal
✅ ▲ Vercel Production293205003432
✅ 💻 Local Development315304353588
✅ 📦 Local Production329004543744
✅ 🐘 Local Postgres329004543744
✅ 🪟 Windows31200312
✅ 📋 Other206804282496
✅ vercel-multi-region270027
Total150720227117343
Details by Category

✅ ▲ Vercel Production

AppPassedFailedSkipped
✅ astro-node127029
✅ astro-quickjs127029
✅ example-node127029
✅ example-quickjs127029
✅ express-node127029
✅ express-quickjs127029
✅ fastify-node127029
✅ fastify-quickjs127029
✅ hono-node127029
✅ hono-quickjs127029
✅ nextjs-turbopack-node15204
✅ nextjs-turbopack-quickjs15204
✅ nextjs-webpack-node15204
✅ nextjs-webpack-quickjs15204
✅ nitro-node127029
✅ nitro-quickjs127029
✅ nuxt-node127029
✅ nuxt-quickjs127029
✅ sveltekit-node146010
✅ sveltekit-quickjs146010
✅ vite-node127029
✅ vite-quickjs127029

✅ 💻 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
✅ nextjs-turbopack-canary-node137019
✅ nextjs-turbopack-canary-quickjs137019
✅ nextjs-turbopack-stable-node15600
✅ nextjs-turbopack-stable-quickjs15600
✅ nextjs-webpack-canary-node137019
✅ 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
✅ 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
✅ 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
✅ 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
✅ 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
✅ vite-stable-node130026
✅ vite-stable-quickjs130026

✅ 🪟 Windows

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

✅ 📋 Other

AppPassedFailedSkipped
✅ e2e-local-dev-nest-stable-node130026
✅ e2e-local-dev-nest-stable-quickjs130026
✅ e2e-local-dev-tanstack-start-node130026
✅ e2e-local-dev-tanstack-start-quickjs130026
✅ e2e-local-postgres-nest-stable-node130026
✅ e2e-local-postgres-nest-stable-quickjs130026
✅ e2e-local-postgres-tanstack-start-node130026
✅ e2e-local-postgres-tanstack-start-quickjs130026
✅ e2e-local-prod-nest-stable-node130026
✅ e2e-local-prod-nest-stable-quickjs130026
✅ e2e-local-prod-tanstack-start-node130026
✅ e2e-local-prod-tanstack-start-quickjs130026
✅ e2e-vercel-prod-nest-node127029
✅ e2e-vercel-prod-nest-quickjs127029
✅ e2e-vercel-prod-tanstack-start-node127029
✅ e2e-vercel-prod-tanstack-start-quickjs127029

✅ vercel-multi-region

AppPassedFailedSkipped
✅ nextjs-turbopack2700

📋 View full workflow run

@github-actions

github-actionsBot commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

📊 Workflow Benchmarks

commit 9ce3581 · Wed, 05 Aug 2026 23:02:34 GMT · run logs

Backend: vercel · app: nextjs-turbopack

MetricScenarioBest (ms)P75 (ms)P90 (ms)P99 (ms)Samples
TTFSstep1316 (+71%) 🔻1441 🔴 (+48%) 🔻1473 🔴 (+39%) 🔻1839 🔴 (+9.7%)30
TTFSstream1293 (+42%) 🔻1403 🔴 (+44%) 🔻1465 🔴 (+47%) 🔻1516 🔴 (+17%) 🔻30
TTFShook + stream1508 (+74%) 🔻1678 🔴 (+35%) 🔻1700 🔴 (+35%) 🔻1784 🔴 (+20%) 🔻30
STSO1020 steps (inline)105 (+17%) 🔻142 (+8.4%)165 (+5.8%)295 (+19%) 🔻1018
STSO1020 steps (queue-hop)31983198319831981
WO1020 steps151538 (+17%) 🔻151538 (+17%) 🔻151538 (+17%) 🔻151538 (+17%) 🔻1
SLstream latency113 (+40%) 🔻171 🔴 (+38%) 🔻201 🔴 (+34%) 🔻467 🔴 (+14%)30
SOstream overhead (text)127 (+25%) 🔻183 (+17%) 🔻197 (+8.2%)308 (+55%) 🔻30
SOstream overhead (structured)120 (+17%) 🔻163 (-0.6%)189 (+6.2%)271 (-14%)30
📈 STSO distribution vs main (inline / queue-hop histograms)

1020 steps (inline)

Cumulative STSO time: main 128734ms → this run 146913ms (Δ +18179ms, +14%)

 50-100 ms ┃ main 4 this 0 -4
100-150 ms █████████████████████┃██ main 896 this 836 -60
150-200 ms ██░┃ main 93 this 140 +47
200-250 ms ┃ main 16 this 19 +3
250-300 ms ┃ main 3 this 16 +13
300-350 ms ┃ main 4 this 4 +0
350-400 ms ┃ main 1 this 0 -1
400-450 ms ┃ main 1 this 1 +0
500-550 ms ┃ main 1 this 0 -1
550-600 ms ┃ main 0 this 1 +1
5300-5350 ms ┃ main 0 this 1 +1

1020 steps (queue-hop)

Cumulative STSO time: 3198ms over 1 samples

No main baseline with raw samples yet — showing this run's distribution on its own; the diff appears once a run on main has recorded them.

3000-3500 ms ████████████████████████ steps 1
📜 Previous results (2)

2daae33

Wed, 05 Aug 2026 18:48:38 GMT · run logs

vercel / nextjs-turbopack

MetricScenarioBest (ms)P75 (ms)P90 (ms)P99 (ms)Samples
TTFSstep1329 (+201%) 🔻1453 🔴 (+28%) 🔻1487 🔴 (+2.3%)1819 🔴 (+18%) 🔻30
TTFSstream331 (+87%) 🔻1436 🔴 (+35%) 🔻1470 🔴 (+38%) 🔻1571 🔴 (+36%) 🔻30
TTFShook + stream1397 (+12%)1750 🔴 (+27%) 🔻1806 🔴 (+26%) 🔻1972 🔴 (+17%) 🔻30
STSO1020 steps (inline)98 (+15%) 🔻143 (+2.9%)164 (±0%)259 (-3.0%)1018
STSO1020 steps (queue-hop)3278 (+11%)3278 (+11%)3278 (+11%)3278 (+11%)1
WO1020 steps145014 (+5.1%)145014 (+5.1%)145014 (+5.1%)145014 (+5.1%)1
SLstream latency114 (+36%) 🔻169 🔴 (+50%) 🔻185 🔴 (+32%) 🔻292 🔴 (-20%) 💚30
SOstream overhead (text)142 (+33%) 🔻217 (+33%) 🔻272 (+47%) 🔻327 (-6.3%)30
SOstream overhead (structured)129 (+22%) 🔻185 (+22%) 🔻201 (+19%) 🔻277 (+25%) 🔻30

549d6f0

Wed, 05 Aug 2026 01:54:01 GMT · run logs

vercel / nextjs-turbopack

MetricScenarioBest (ms)P75 (ms)P90 (ms)P99 (ms)Samples
TTFSstep239 (-73%) 💚1381 🔴 (+33%) 🔻1534 🔴 (+16%) 🔻1833 🔴 (+24%) 🔻30
TTFSstream241 (-75%) 💚1342 🔴 (+31%) 🔻1372 🔴 (+32%) 🔻1544 🔴 (+33%) 🔻30
TTFShook + stream306 (-27%) 💚1548 🔴 (+19%) 🔻1585 🔴 (+3.3%)1720 🔴 (+0.6%)30
STSO1020 steps (inline)108 (+27%) 🔻151 (+7.9%)172 (+3.6%)255 (-41%) 💚1018
STSO1020 steps (queue-hop)2262 (-52%) 💚2262 (-52%) 💚2262 (-52%) 💚2262 (-52%) 💚1
WO1020 steps150841 (+0.8%)150841 (+0.8%)150841 (+0.8%)150841 (+0.8%)1
SLstream latency105 (+28%) 🔻171 🔴 (+58%) 🔻189 🔴 (+60%) 🔻222 🔴 (+31%) 🔻30
SOstream overhead (text)160 (+54%) 🔻252 🔴 (+49%) 🔻308 (+72%) 🔻769 (+114%) 🔻30
SOstream overhead (structured)152 (+46%) 🔻270 🔴 (+53%) 🔻354 (+52%) 🔻618 (+20%) 🔻30
ℹ️ 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.

@karthikscale3
karthikscale3 marked this pull request as ready for review August 5, 2026 01:42
@karthikscale3
karthikscale3 requested a review from a team as a code ownerAugust 5, 2026 01:42
On a lazy hook queue delivery, the consumer's idempotent hook_received
re-ensure is hoisted above run_started and doubles as the invocation's
setup request: it asks the World to return the current replay log with
the write (new advisory CreateEventParams.preloadEvents), so one HTTP
request yields the canonical event, the reconstructed run, and the
complete replay log — skipping both the run_started POST and the
initial events.list.
- world: optional `preloadEvents?: true` on CreateEventParams, the
hook_received dual of skipPreload; Worlds may ignore it
- world-vercel: createHookReceivedPreloadEventV4 sends the frame Accept
on eligible hook_received posts and decodes either response mode —
frames via the response decoder extracted from the LIST consumer
(GET behavior unchanged), CBOR via the shared materialized-response
mapping. The run is reconstructed from run_created/run_started (plus
attr_set folds), the canonical event found by x-wf-event-id, and
resumeId now survives frame decoding so the runtime can match it
- core: new fast path before the generic run-state setup, guarded on
hookInput.resumeId + payloadDigest; a validated COMPLETE preload
(hasMore false — this path has no cursor-continuation machinery)
initializes workflowRun/preloadedEvents/maxEventsLimit directly,
anything else falls back to the run_started setup without re-posting
the hook; error classification matches the existing re-ensure
(terminal → consume, transient → redeliver); setup source reported
via workflow.resume_setup_source (never
workflow.hook.resilient_resume_materialized, which stays a
recovery-only signal)
- producer resumeHook() is unchanged and never sets preloadEvents
Based directly on main (no dependency on #3124/#3191); pairs with
workflow-server's streamed hook_received replay-log response, which
deploys first — the SDK negotiates per request and falls back safely
against older servers.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@karthikscale3
karthikscale3force-pushed the kk/hook-received-replay-preload branch from 549d6f0 to 2daae33CompareAugust 5, 2026 18:28

@TooTallNateTooTallNate 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.

Reviewed at 549d6f0. Locally: build + typecheck green, core 1920 passed / 3 expected fail, world-vercel 339 — with the 12 consumer-preload tests covering every fallback and error branch I went looking for.

What held up under scrutiny:

  • The completeness validation is the right shape: run + startedAt, non-empty events, non-null cursor, hasMore === false, numeric maxEvents (this response plays run_started's role, so a missing ceiling would silently disable event-limit enforcement — good catch), both lifecycle events, and the hook_received matching thisresumeId. On the cursor requirement: I confirmed the backend synthesizes a cursor on the final page even for single-page logs, so the check doesn't dead-letter short runs — but that server behavior is now load-bearing for this fast path; a code comment noting the dependency would help the next person.
  • Error classification is byte-for-byte consistent with the existing re-ensure: HookNotFound/RunExpired consume the delivery; everything else (EntityConflict, truncated stream, transport) rethrows for redelivery and the (runId, resumeId) claim converges. And the deliberate omission of HookResilientResumeMaterialized (this path carries no recovery signal) keeps that metric honest.
  • The terminal-event check before engine dispatch correctly plugs the QuickJS gap (it dispatches before the node loop's terminal detection), and preloadedEventsComplete as an explicit attestation — rather than widening the first-invocation heuristic — is the safer design.
  • resumeId threading through buildEventFromV4 is essential and easy to miss: without it, frame-decoded events would silently fail the matching check and the fast path would never fire. The comment says exactly that.
  • remoteRefBehavior: 'resolve' override on the preload request is right (v4 has no refs endpoint to hydrate lazy descriptors mid-replay), and reconstructRunFromReplayEvents carries every field downstream consumers read — including deploymentId and specVersion, which the in-flight deployment-affinity and slot-identity work key off.

Three asks before merge:

  1. Rebase — the PR is currently CONFLICTING with main (a one-file test conflict in events.test.ts vs #3334).
  2. Coordinate with #2960 (deployment-affinity guard, also open). Its design places the guard ahead of the lazy-hook re-ensure with the explicit invariant "a misrouted resume writes nothing here" — this fast path hoists the hook_received write above where that guard will sit. I believe the combination is still safe (the write is idempotent, involves no key derivation, and the guard still precedes any replay/step execution), but whichever PR lands second must reconcile the placement and rewrite that comment — the "writes nothing" invariant will no longer be literally true, and it should be weakened deliberately rather than silently.
  3. Changeset bump: @workflow/world gains a new public interface field (preloadEvents) plus documented EventResult semantics — per the convention we've applied on recent PRs, new API surface on the world interface should be minor, not patch.

For the record, the paired backend PR's red trigger lane ran with main's SDK (no Accept header → this feature dormant), and its failures match the varied preview-lane flakiness other backend branches see — not this pair. The real proof of the active path will be this PR's own e2e once rebased, since the backend half is already deployable ahead.

Nice perf win with a genuinely safe fallback story. Approving.

@karthikscale3

Copy link
Copy Markdown
ContributorAuthor

CI run completed without any issues (Except one unrelated flake). Checked datadog and compared CI run span metrics pre server side merge vs post server side merge:

The p50 improvement appears across every framework cohort:

SvelteKit: 1,194 → 939 ms, −255 ms
Next.js Turbopack: 1,396 → 1,156 ms, −239 ms
Next.js webpack: 1,371 → 1,048 ms, −324 ms

Merging this now as all review comments are addressed and the confidence levels are high.

@karthikscale3
karthikscale3 merged commit 9c1b3c8 into mainAug 6, 2026
411 of 421 checks passed
@karthikscale3
karthikscale3 deleted the kk/hook-received-replay-preload branch August 6, 2026 02:24
@github-actions

Copy link
Copy Markdown
Contributor

No backport to stable for 9c1b3c8 (AI decision).

This is a latency optimization that adds new API surface — an advisory preloadEvents param on CreateEventParams (a minor bump to @workflow/world), a new world-vercel request/response mode (createHookReceivedPreloadEventV4), a new preloadedEventsComplete option on the QuickJS entrypoint, a widened retry opt-in, and a new workflow.resume_setup_source telemetry attribute. It fixes no user-visible defect on stable, and it is explicitly designed to pair with a server-side change that must deploy first, so it belongs on main only.

To override, re-run the Backport to stable workflow manually via workflow_dispatch and paste this commit SHA into the ref input:

9c1b3c86384181b673d41123d2eec0b987afc75a

@TooTallNate

Copy link
Copy Markdown
Member

Heads-up: the #2960#3345 ordering interaction flagged in my review above is now a red unit test on main.

runtime.test.ts > workflowEntrypoint replay guards > re-routes a misrouted lazy hook resume with its payload intact fails on an unmodified checkout of main (reproduced at 9c1b3c8): the test asserts a misrouted lazy resume writes nohook_received before re-routing, but the fast path added here runs before the deployment-affinity guard from #2960, so the hoisted hook_received re-ensure (and, on the fallback path, run_started) now land before the guard fires:

AssertionError: expected events to not contain { eventType: 'hook_received' }
Received: [ hook_received (hook_1), run_started ]

As discussed in the review, I believe the behavior is still safe — the write is idempotent, involves no key derivation, and the guard still runs before any replay or step execution — but #2960's "a misrouted resume writes nothing here" invariant is now false and its test enforces it, so main is red until one of:

  1. Weaken the invariant deliberately: update the test (and fix(core): re-route runs delivered to the wrong deployment #2960's guard comment) to accept the idempotent pre-guard write, asserting instead that no replay/step work happens and the re-route still carries the payload; or
  2. Restore the invariant cheaply: carry the run's pinned deploymentId on the lazy-hook queue message (hookInput), so the guard can check for a mismatch before the fast path's write without the run fetch the guard otherwise needs — keeping both the perf win and the writes-nothing property.

(Found while reviewing #3372, whose author independently hit the same failing test and correctly identified it as pre-existing.)

cc @karthikscale3@alangenfeld

karthikscale3 added a commit that referenced this pull request Aug 6, 2026
…#3374)
The lazy hook fast path (#3345) hoisted the consumer's hook_received
write above the deployment-affinity guard (#2960), so a misrouted lazy
resume wrote its event before the guard could re-route the delivery.
Stamp the run's pinned deployment on the resume message
(hookInput.deploymentId, from the producer's resume context) and, on
the consumer, compare it against the ambient deployment id immediately
before the fast path: a match continues with no run fetch, a mismatch
fetches the authoritative run and hands it to the existing guard —
which keeps sole ownership of re-route/fail policy and remains the
authoritative protection before replay and step execution. The
re-routed message preserves the complete hookInput (it may hold the
only copy of the resume payload). Older messages without the field, and
worlds without deployment affinity, are unchanged: they skip the
pre-check and rely on the authoritative guard, the pre-guard write
staying convergent per (runId, resumeId).
Fixes the misrouted-lazy-resume unit test broken by the #2960/#3345
ordering: a modern misrouted resume now re-routes with zero event
writes, asserted for both hook_received and run_started.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
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.

3 participants

@karthikscale3@TooTallNate@pranaygp
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

perf(core): initialize lazy hook replay from hook_received stream - #3345

Merged
karthikscale3 merged 2 commits into
mainfrom
kk/hook-received-replay-preload
Aug 6, 2026
Merged

perf(core): initialize lazy hook replay from hook_received stream#3345
karthikscale3 merged 2 commits into
mainfrom
kk/hook-received-replay-preload

Conversation

@karthikscale3

@karthikscale3karthikscale3 commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

What

On a lazy hook queue delivery, the consumer's idempotent hook_received re-ensure is hoisted above run_started and asks the World to return the current replay log with the write (new advisory CreateEventParams.preloadEvents). When a complete preload comes back, the invocation initializes replay from that one request and skips both the run_started POST and the initial events.list; otherwise it falls back to the existing setup without re-posting the hook.

Before: run_started → hook_received re-ensure → events.list → replay
After: hook_received (returns replay log) → replay

Why

Each removed round trip sits directly on hook-resume latency (queue receipt → replay start). Folding the re-ensure and the replay load into one request cuts consumer startup/TTFS by roughly two request latencies on the normal lazy-resume path.

Notes

  • Worlds may ignore preloadEvents; a preload is trusted only when validated as complete (run + hasMore: false + maxEvents + lifecycle events + the matching resumeId). A terminal event in the preload consumes the delivery before engine dispatch.
  • Producer-side resumeHook() is unchanged and never sets preloadEvents.
  • Setup source is reported via workflow.resume_setup_source (hook_received_stream | hook_received_fallback).

🤖 Generated with Claude Code

@changeset-bot

changeset-botBot commented Aug 5, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 9ce3581

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/world-vercelPatch
@workflow/corePatch
@workflow/cliPatch
@workflow/vitestPatch
@workflow/web-sharedPatch
@workflow/webPatch
@workflow/world-localPatch
@workflow/world-postgresPatch
@workflow/world-testingPatch
@workflow/buildersPatch
@workflow/nextPatch
@workflow/nitroPatch
workflowPatch
@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

@vercel

vercelBot commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

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

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

@github-actions

github-actionsBot commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

🧪 E2E Test Results

All tests passed

E2E Test Summary

Summary
PassedFailedSkippedTotal
✅ ▲ Vercel Production293205003432
✅ 💻 Local Development315304353588
✅ 📦 Local Production329004543744
✅ 🐘 Local Postgres329004543744
✅ 🪟 Windows31200312
✅ 📋 Other206804282496
✅ vercel-multi-region270027
Total150720227117343
Details by Category

✅ ▲ Vercel Production

AppPassedFailedSkipped
✅ astro-node127029
✅ astro-quickjs127029
✅ example-node127029
✅ example-quickjs127029
✅ express-node127029
✅ express-quickjs127029
✅ fastify-node127029
✅ fastify-quickjs127029
✅ hono-node127029
✅ hono-quickjs127029
✅ nextjs-turbopack-node15204
✅ nextjs-turbopack-quickjs15204
✅ nextjs-webpack-node15204
✅ nextjs-webpack-quickjs15204
✅ nitro-node127029
✅ nitro-quickjs127029
✅ nuxt-node127029
✅ nuxt-quickjs127029
✅ sveltekit-node146010
✅ sveltekit-quickjs146010
✅ vite-node127029
✅ vite-quickjs127029

✅ 💻 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
✅ nextjs-turbopack-canary-node137019
✅ nextjs-turbopack-canary-quickjs137019
✅ nextjs-turbopack-stable-node15600
✅ nextjs-turbopack-stable-quickjs15600
✅ nextjs-webpack-canary-node137019
✅ 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
✅ 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
✅ 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
✅ 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
✅ 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
✅ vite-stable-node130026
✅ vite-stable-quickjs130026

✅ 🪟 Windows

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

✅ 📋 Other

AppPassedFailedSkipped
✅ e2e-local-dev-nest-stable-node130026
✅ e2e-local-dev-nest-stable-quickjs130026
✅ e2e-local-dev-tanstack-start-node130026
✅ e2e-local-dev-tanstack-start-quickjs130026
✅ e2e-local-postgres-nest-stable-node130026
✅ e2e-local-postgres-nest-stable-quickjs130026
✅ e2e-local-postgres-tanstack-start-node130026
✅ e2e-local-postgres-tanstack-start-quickjs130026
✅ e2e-local-prod-nest-stable-node130026
✅ e2e-local-prod-nest-stable-quickjs130026
✅ e2e-local-prod-tanstack-start-node130026
✅ e2e-local-prod-tanstack-start-quickjs130026
✅ e2e-vercel-prod-nest-node127029
✅ e2e-vercel-prod-nest-quickjs127029
✅ e2e-vercel-prod-tanstack-start-node127029
✅ e2e-vercel-prod-tanstack-start-quickjs127029

✅ vercel-multi-region

AppPassedFailedSkipped
✅ nextjs-turbopack2700

📋 View full workflow run

@github-actions

github-actionsBot commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

📊 Workflow Benchmarks

commit 9ce3581 · Wed, 05 Aug 2026 23:02:34 GMT · run logs

Backend: vercel · app: nextjs-turbopack

MetricScenarioBest (ms)P75 (ms)P90 (ms)P99 (ms)Samples
TTFSstep1316 (+71%) 🔻1441 🔴 (+48%) 🔻1473 🔴 (+39%) 🔻1839 🔴 (+9.7%)30
TTFSstream1293 (+42%) 🔻1403 🔴 (+44%) 🔻1465 🔴 (+47%) 🔻1516 🔴 (+17%) 🔻30
TTFShook + stream1508 (+74%) 🔻1678 🔴 (+35%) 🔻1700 🔴 (+35%) 🔻1784 🔴 (+20%) 🔻30
STSO1020 steps (inline)105 (+17%) 🔻142 (+8.4%)165 (+5.8%)295 (+19%) 🔻1018
STSO1020 steps (queue-hop)31983198319831981
WO1020 steps151538 (+17%) 🔻151538 (+17%) 🔻151538 (+17%) 🔻151538 (+17%) 🔻1
SLstream latency113 (+40%) 🔻171 🔴 (+38%) 🔻201 🔴 (+34%) 🔻467 🔴 (+14%)30
SOstream overhead (text)127 (+25%) 🔻183 (+17%) 🔻197 (+8.2%)308 (+55%) 🔻30
SOstream overhead (structured)120 (+17%) 🔻163 (-0.6%)189 (+6.2%)271 (-14%)30
📈 STSO distribution vs main (inline / queue-hop histograms)

1020 steps (inline)

Cumulative STSO time: main 128734ms → this run 146913ms (Δ +18179ms, +14%)

 50-100 ms ┃ main 4 this 0 -4
100-150 ms █████████████████████┃██ main 896 this 836 -60
150-200 ms ██░┃ main 93 this 140 +47
200-250 ms ┃ main 16 this 19 +3
250-300 ms ┃ main 3 this 16 +13
300-350 ms ┃ main 4 this 4 +0
350-400 ms ┃ main 1 this 0 -1
400-450 ms ┃ main 1 this 1 +0
500-550 ms ┃ main 1 this 0 -1
550-600 ms ┃ main 0 this 1 +1
5300-5350 ms ┃ main 0 this 1 +1

1020 steps (queue-hop)

Cumulative STSO time: 3198ms over 1 samples

No main baseline with raw samples yet — showing this run's distribution on its own; the diff appears once a run on main has recorded them.

3000-3500 ms ████████████████████████ steps 1
📜 Previous results (2)

2daae33

Wed, 05 Aug 2026 18:48:38 GMT · run logs

vercel / nextjs-turbopack

MetricScenarioBest (ms)P75 (ms)P90 (ms)P99 (ms)Samples
TTFSstep1329 (+201%) 🔻1453 🔴 (+28%) 🔻1487 🔴 (+2.3%)1819 🔴 (+18%) 🔻30
TTFSstream331 (+87%) 🔻1436 🔴 (+35%) 🔻1470 🔴 (+38%) 🔻1571 🔴 (+36%) 🔻30
TTFShook + stream1397 (+12%)1750 🔴 (+27%) 🔻1806 🔴 (+26%) 🔻1972 🔴 (+17%) 🔻30
STSO1020 steps (inline)98 (+15%) 🔻143 (+2.9%)164 (±0%)259 (-3.0%)1018
STSO1020 steps (queue-hop)3278 (+11%)3278 (+11%)3278 (+11%)3278 (+11%)1
WO1020 steps145014 (+5.1%)145014 (+5.1%)145014 (+5.1%)145014 (+5.1%)1
SLstream latency114 (+36%) 🔻169 🔴 (+50%) 🔻185 🔴 (+32%) 🔻292 🔴 (-20%) 💚30
SOstream overhead (text)142 (+33%) 🔻217 (+33%) 🔻272 (+47%) 🔻327 (-6.3%)30
SOstream overhead (structured)129 (+22%) 🔻185 (+22%) 🔻201 (+19%) 🔻277 (+25%) 🔻30

549d6f0

Wed, 05 Aug 2026 01:54:01 GMT · run logs

vercel / nextjs-turbopack

MetricScenarioBest (ms)P75 (ms)P90 (ms)P99 (ms)Samples
TTFSstep239 (-73%) 💚1381 🔴 (+33%) 🔻1534 🔴 (+16%) 🔻1833 🔴 (+24%) 🔻30
TTFSstream241 (-75%) 💚1342 🔴 (+31%) 🔻1372 🔴 (+32%) 🔻1544 🔴 (+33%) 🔻30
TTFShook + stream306 (-27%) 💚1548 🔴 (+19%) 🔻1585 🔴 (+3.3%)1720 🔴 (+0.6%)30
STSO1020 steps (inline)108 (+27%) 🔻151 (+7.9%)172 (+3.6%)255 (-41%) 💚1018
STSO1020 steps (queue-hop)2262 (-52%) 💚2262 (-52%) 💚2262 (-52%) 💚2262 (-52%) 💚1
WO1020 steps150841 (+0.8%)150841 (+0.8%)150841 (+0.8%)150841 (+0.8%)1
SLstream latency105 (+28%) 🔻171 🔴 (+58%) 🔻189 🔴 (+60%) 🔻222 🔴 (+31%) 🔻30
SOstream overhead (text)160 (+54%) 🔻252 🔴 (+49%) 🔻308 (+72%) 🔻769 (+114%) 🔻30
SOstream overhead (structured)152 (+46%) 🔻270 🔴 (+53%) 🔻354 (+52%) 🔻618 (+20%) 🔻30
ℹ️ 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.

@karthikscale3
karthikscale3 marked this pull request as ready for review August 5, 2026 01:42
@karthikscale3
karthikscale3 requested a review from a team as a code ownerAugust 5, 2026 01:42
On a lazy hook queue delivery, the consumer's idempotent hook_received
re-ensure is hoisted above run_started and doubles as the invocation's
setup request: it asks the World to return the current replay log with
the write (new advisory CreateEventParams.preloadEvents), so one HTTP
request yields the canonical event, the reconstructed run, and the
complete replay log — skipping both the run_started POST and the
initial events.list.
- world: optional `preloadEvents?: true` on CreateEventParams, the
hook_received dual of skipPreload; Worlds may ignore it
- world-vercel: createHookReceivedPreloadEventV4 sends the frame Accept
on eligible hook_received posts and decodes either response mode —
frames via the response decoder extracted from the LIST consumer
(GET behavior unchanged), CBOR via the shared materialized-response
mapping. The run is reconstructed from run_created/run_started (plus
attr_set folds), the canonical event found by x-wf-event-id, and
resumeId now survives frame decoding so the runtime can match it
- core: new fast path before the generic run-state setup, guarded on
hookInput.resumeId + payloadDigest; a validated COMPLETE preload
(hasMore false — this path has no cursor-continuation machinery)
initializes workflowRun/preloadedEvents/maxEventsLimit directly,
anything else falls back to the run_started setup without re-posting
the hook; error classification matches the existing re-ensure
(terminal → consume, transient → redeliver); setup source reported
via workflow.resume_setup_source (never
workflow.hook.resilient_resume_materialized, which stays a
recovery-only signal)
- producer resumeHook() is unchanged and never sets preloadEvents
Based directly on main (no dependency on #3124/#3191); pairs with
workflow-server's streamed hook_received replay-log response, which
deploys first — the SDK negotiates per request and falls back safely
against older servers.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@karthikscale3
karthikscale3force-pushed the kk/hook-received-replay-preload branch from 549d6f0 to 2daae33CompareAugust 5, 2026 18:28

@TooTallNateTooTallNate 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.

Reviewed at 549d6f0. Locally: build + typecheck green, core 1920 passed / 3 expected fail, world-vercel 339 — with the 12 consumer-preload tests covering every fallback and error branch I went looking for.

What held up under scrutiny:

  • The completeness validation is the right shape: run + startedAt, non-empty events, non-null cursor, hasMore === false, numeric maxEvents (this response plays run_started's role, so a missing ceiling would silently disable event-limit enforcement — good catch), both lifecycle events, and the hook_received matching thisresumeId. On the cursor requirement: I confirmed the backend synthesizes a cursor on the final page even for single-page logs, so the check doesn't dead-letter short runs — but that server behavior is now load-bearing for this fast path; a code comment noting the dependency would help the next person.
  • Error classification is byte-for-byte consistent with the existing re-ensure: HookNotFound/RunExpired consume the delivery; everything else (EntityConflict, truncated stream, transport) rethrows for redelivery and the (runId, resumeId) claim converges. And the deliberate omission of HookResilientResumeMaterialized (this path carries no recovery signal) keeps that metric honest.
  • The terminal-event check before engine dispatch correctly plugs the QuickJS gap (it dispatches before the node loop's terminal detection), and preloadedEventsComplete as an explicit attestation — rather than widening the first-invocation heuristic — is the safer design.
  • resumeId threading through buildEventFromV4 is essential and easy to miss: without it, frame-decoded events would silently fail the matching check and the fast path would never fire. The comment says exactly that.
  • remoteRefBehavior: 'resolve' override on the preload request is right (v4 has no refs endpoint to hydrate lazy descriptors mid-replay), and reconstructRunFromReplayEvents carries every field downstream consumers read — including deploymentId and specVersion, which the in-flight deployment-affinity and slot-identity work key off.

Three asks before merge:

  1. Rebase — the PR is currently CONFLICTING with main (a one-file test conflict in events.test.ts vs #3334).
  2. Coordinate with #2960 (deployment-affinity guard, also open). Its design places the guard ahead of the lazy-hook re-ensure with the explicit invariant "a misrouted resume writes nothing here" — this fast path hoists the hook_received write above where that guard will sit. I believe the combination is still safe (the write is idempotent, involves no key derivation, and the guard still precedes any replay/step execution), but whichever PR lands second must reconcile the placement and rewrite that comment — the "writes nothing" invariant will no longer be literally true, and it should be weakened deliberately rather than silently.
  3. Changeset bump: @workflow/world gains a new public interface field (preloadEvents) plus documented EventResult semantics — per the convention we've applied on recent PRs, new API surface on the world interface should be minor, not patch.

For the record, the paired backend PR's red trigger lane ran with main's SDK (no Accept header → this feature dormant), and its failures match the varied preview-lane flakiness other backend branches see — not this pair. The real proof of the active path will be this PR's own e2e once rebased, since the backend half is already deployable ahead.

Nice perf win with a genuinely safe fallback story. Approving.

@karthikscale3

Copy link
Copy Markdown
ContributorAuthor

CI run completed without any issues (Except one unrelated flake). Checked datadog and compared CI run span metrics pre server side merge vs post server side merge:

The p50 improvement appears across every framework cohort:

SvelteKit: 1,194 → 939 ms, −255 ms
Next.js Turbopack: 1,396 → 1,156 ms, −239 ms
Next.js webpack: 1,371 → 1,048 ms, −324 ms

Merging this now as all review comments are addressed and the confidence levels are high.

@karthikscale3
karthikscale3 merged commit 9c1b3c8 into mainAug 6, 2026
411 of 421 checks passed
@karthikscale3
karthikscale3 deleted the kk/hook-received-replay-preload branch August 6, 2026 02:24
@github-actions

Copy link
Copy Markdown
Contributor

No backport to stable for 9c1b3c8 (AI decision).

This is a latency optimization that adds new API surface — an advisory preloadEvents param on CreateEventParams (a minor bump to @workflow/world), a new world-vercel request/response mode (createHookReceivedPreloadEventV4), a new preloadedEventsComplete option on the QuickJS entrypoint, a widened retry opt-in, and a new workflow.resume_setup_source telemetry attribute. It fixes no user-visible defect on stable, and it is explicitly designed to pair with a server-side change that must deploy first, so it belongs on main only.

To override, re-run the Backport to stable workflow manually via workflow_dispatch and paste this commit SHA into the ref input:

9c1b3c86384181b673d41123d2eec0b987afc75a

@TooTallNate

Copy link
Copy Markdown
Member

Heads-up: the #2960#3345 ordering interaction flagged in my review above is now a red unit test on main.

runtime.test.ts > workflowEntrypoint replay guards > re-routes a misrouted lazy hook resume with its payload intact fails on an unmodified checkout of main (reproduced at 9c1b3c8): the test asserts a misrouted lazy resume writes nohook_received before re-routing, but the fast path added here runs before the deployment-affinity guard from #2960, so the hoisted hook_received re-ensure (and, on the fallback path, run_started) now land before the guard fires:

AssertionError: expected events to not contain { eventType: 'hook_received' }
Received: [ hook_received (hook_1), run_started ]

As discussed in the review, I believe the behavior is still safe — the write is idempotent, involves no key derivation, and the guard still runs before any replay or step execution — but #2960's "a misrouted resume writes nothing here" invariant is now false and its test enforces it, so main is red until one of:

  1. Weaken the invariant deliberately: update the test (and fix(core): re-route runs delivered to the wrong deployment #2960's guard comment) to accept the idempotent pre-guard write, asserting instead that no replay/step work happens and the re-route still carries the payload; or
  2. Restore the invariant cheaply: carry the run's pinned deploymentId on the lazy-hook queue message (hookInput), so the guard can check for a mismatch before the fast path's write without the run fetch the guard otherwise needs — keeping both the perf win and the writes-nothing property.

(Found while reviewing #3372, whose author independently hit the same failing test and correctly identified it as pre-existing.)

cc @karthikscale3@alangenfeld

karthikscale3 added a commit that referenced this pull request Aug 6, 2026
…#3374)
The lazy hook fast path (#3345) hoisted the consumer's hook_received
write above the deployment-affinity guard (#2960), so a misrouted lazy
resume wrote its event before the guard could re-route the delivery.
Stamp the run's pinned deployment on the resume message
(hookInput.deploymentId, from the producer's resume context) and, on
the consumer, compare it against the ambient deployment id immediately
before the fast path: a match continues with no run fetch, a mismatch
fetches the authoritative run and hands it to the existing guard —
which keeps sole ownership of re-route/fail policy and remains the
authoritative protection before replay and step execution. The
re-routed message preserves the complete hookInput (it may hold the
only copy of the resume payload). Older messages without the field, and
worlds without deployment affinity, are unchanged: they skip the
pre-check and rely on the authoritative guard, the pre-guard write
staying convergent per (runId, resumeId).
Fixes the misrouted-lazy-resume unit test broken by the #2960/#3345
ordering: a modern misrouted resume now re-routes with zero event
writes, asserted for both hook_received and run_started.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
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.

3 participants

@karthikscale3@TooTallNate@pranaygp
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content

perf(core): initialize lazy hook replay from hook_received stream - #3345

Merged
karthikscale3 merged 2 commits into
mainfrom
kk/hook-received-replay-preload
Aug 6, 2026
Merged

perf(core): initialize lazy hook replay from hook_received stream#3345
karthikscale3 merged 2 commits into
mainfrom
kk/hook-received-replay-preload

Conversation

@karthikscale3

@karthikscale3karthikscale3 commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

What

On a lazy hook queue delivery, the consumer's idempotent hook_received re-ensure is hoisted above run_started and asks the World to return the current replay log with the write (new advisory CreateEventParams.preloadEvents). When a complete preload comes back, the invocation initializes replay from that one request and skips both the run_started POST and the initial events.list; otherwise it falls back to the existing setup without re-posting the hook.

Before: run_started → hook_received re-ensure → events.list → replay
After: hook_received (returns replay log) → replay

Why

Each removed round trip sits directly on hook-resume latency (queue receipt → replay start). Folding the re-ensure and the replay load into one request cuts consumer startup/TTFS by roughly two request latencies on the normal lazy-resume path.

Notes

  • Worlds may ignore preloadEvents; a preload is trusted only when validated as complete (run + hasMore: false + maxEvents + lifecycle events + the matching resumeId). A terminal event in the preload consumes the delivery before engine dispatch.
  • Producer-side resumeHook() is unchanged and never sets preloadEvents.
  • Setup source is reported via workflow.resume_setup_source (hook_received_stream | hook_received_fallback).

🤖 Generated with Claude Code

@changeset-bot

changeset-botBot commented Aug 5, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 9ce3581

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/world-vercelPatch
@workflow/corePatch
@workflow/cliPatch
@workflow/vitestPatch
@workflow/web-sharedPatch
@workflow/webPatch
@workflow/world-localPatch
@workflow/world-postgresPatch
@workflow/world-testingPatch
@workflow/buildersPatch
@workflow/nextPatch
@workflow/nitroPatch
workflowPatch
@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

@vercel

vercelBot commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

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

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

@github-actions

github-actionsBot commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

🧪 E2E Test Results

All tests passed

E2E Test Summary

Summary
PassedFailedSkippedTotal
✅ ▲ Vercel Production293205003432
✅ 💻 Local Development315304353588
✅ 📦 Local Production329004543744
✅ 🐘 Local Postgres329004543744
✅ 🪟 Windows31200312
✅ 📋 Other206804282496
✅ vercel-multi-region270027
Total150720227117343
Details by Category

✅ ▲ Vercel Production

AppPassedFailedSkipped
✅ astro-node127029
✅ astro-quickjs127029
✅ example-node127029
✅ example-quickjs127029
✅ express-node127029
✅ express-quickjs127029
✅ fastify-node127029
✅ fastify-quickjs127029
✅ hono-node127029
✅ hono-quickjs127029
✅ nextjs-turbopack-node15204
✅ nextjs-turbopack-quickjs15204
✅ nextjs-webpack-node15204
✅ nextjs-webpack-quickjs15204
✅ nitro-node127029
✅ nitro-quickjs127029
✅ nuxt-node127029
✅ nuxt-quickjs127029
✅ sveltekit-node146010
✅ sveltekit-quickjs146010
✅ vite-node127029
✅ vite-quickjs127029

✅ 💻 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
✅ nextjs-turbopack-canary-node137019
✅ nextjs-turbopack-canary-quickjs137019
✅ nextjs-turbopack-stable-node15600
✅ nextjs-turbopack-stable-quickjs15600
✅ nextjs-webpack-canary-node137019
✅ 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
✅ 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
✅ 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
✅ 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
✅ 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
✅ vite-stable-node130026
✅ vite-stable-quickjs130026

✅ 🪟 Windows

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

✅ 📋 Other

AppPassedFailedSkipped
✅ e2e-local-dev-nest-stable-node130026
✅ e2e-local-dev-nest-stable-quickjs130026
✅ e2e-local-dev-tanstack-start-node130026
✅ e2e-local-dev-tanstack-start-quickjs130026
✅ e2e-local-postgres-nest-stable-node130026
✅ e2e-local-postgres-nest-stable-quickjs130026
✅ e2e-local-postgres-tanstack-start-node130026
✅ e2e-local-postgres-tanstack-start-quickjs130026
✅ e2e-local-prod-nest-stable-node130026
✅ e2e-local-prod-nest-stable-quickjs130026
✅ e2e-local-prod-tanstack-start-node130026
✅ e2e-local-prod-tanstack-start-quickjs130026
✅ e2e-vercel-prod-nest-node127029
✅ e2e-vercel-prod-nest-quickjs127029
✅ e2e-vercel-prod-tanstack-start-node127029
✅ e2e-vercel-prod-tanstack-start-quickjs127029

✅ vercel-multi-region

AppPassedFailedSkipped
✅ nextjs-turbopack2700

📋 View full workflow run

@github-actions

github-actionsBot commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

📊 Workflow Benchmarks

commit 9ce3581 · Wed, 05 Aug 2026 23:02:34 GMT · run logs

Backend: vercel · app: nextjs-turbopack

MetricScenarioBest (ms)P75 (ms)P90 (ms)P99 (ms)Samples
TTFSstep1316 (+71%) 🔻1441 🔴 (+48%) 🔻1473 🔴 (+39%) 🔻1839 🔴 (+9.7%)30
TTFSstream1293 (+42%) 🔻1403 🔴 (+44%) 🔻1465 🔴 (+47%) 🔻1516 🔴 (+17%) 🔻30
TTFShook + stream1508 (+74%) 🔻1678 🔴 (+35%) 🔻1700 🔴 (+35%) 🔻1784 🔴 (+20%) 🔻30
STSO1020 steps (inline)105 (+17%) 🔻142 (+8.4%)165 (+5.8%)295 (+19%) 🔻1018
STSO1020 steps (queue-hop)31983198319831981
WO1020 steps151538 (+17%) 🔻151538 (+17%) 🔻151538 (+17%) 🔻151538 (+17%) 🔻1
SLstream latency113 (+40%) 🔻171 🔴 (+38%) 🔻201 🔴 (+34%) 🔻467 🔴 (+14%)30
SOstream overhead (text)127 (+25%) 🔻183 (+17%) 🔻197 (+8.2%)308 (+55%) 🔻30
SOstream overhead (structured)120 (+17%) 🔻163 (-0.6%)189 (+6.2%)271 (-14%)30
📈 STSO distribution vs main (inline / queue-hop histograms)

1020 steps (inline)

Cumulative STSO time: main 128734ms → this run 146913ms (Δ +18179ms, +14%)

 50-100 ms ┃ main 4 this 0 -4
100-150 ms █████████████████████┃██ main 896 this 836 -60
150-200 ms ██░┃ main 93 this 140 +47
200-250 ms ┃ main 16 this 19 +3
250-300 ms ┃ main 3 this 16 +13
300-350 ms ┃ main 4 this 4 +0
350-400 ms ┃ main 1 this 0 -1
400-450 ms ┃ main 1 this 1 +0
500-550 ms ┃ main 1 this 0 -1
550-600 ms ┃ main 0 this 1 +1
5300-5350 ms ┃ main 0 this 1 +1

1020 steps (queue-hop)

Cumulative STSO time: 3198ms over 1 samples

No main baseline with raw samples yet — showing this run's distribution on its own; the diff appears once a run on main has recorded them.

3000-3500 ms ████████████████████████ steps 1
📜 Previous results (2)

2daae33

Wed, 05 Aug 2026 18:48:38 GMT · run logs

vercel / nextjs-turbopack

MetricScenarioBest (ms)P75 (ms)P90 (ms)P99 (ms)Samples
TTFSstep1329 (+201%) 🔻1453 🔴 (+28%) 🔻1487 🔴 (+2.3%)1819 🔴 (+18%) 🔻30
TTFSstream331 (+87%) 🔻1436 🔴 (+35%) 🔻1470 🔴 (+38%) 🔻1571 🔴 (+36%) 🔻30
TTFShook + stream1397 (+12%)1750 🔴 (+27%) 🔻1806 🔴 (+26%) 🔻1972 🔴 (+17%) 🔻30
STSO1020 steps (inline)98 (+15%) 🔻143 (+2.9%)164 (±0%)259 (-3.0%)1018
STSO1020 steps (queue-hop)3278 (+11%)3278 (+11%)3278 (+11%)3278 (+11%)1
WO1020 steps145014 (+5.1%)145014 (+5.1%)145014 (+5.1%)145014 (+5.1%)1
SLstream latency114 (+36%) 🔻169 🔴 (+50%) 🔻185 🔴 (+32%) 🔻292 🔴 (-20%) 💚30
SOstream overhead (text)142 (+33%) 🔻217 (+33%) 🔻272 (+47%) 🔻327 (-6.3%)30
SOstream overhead (structured)129 (+22%) 🔻185 (+22%) 🔻201 (+19%) 🔻277 (+25%) 🔻30

549d6f0

Wed, 05 Aug 2026 01:54:01 GMT · run logs

vercel / nextjs-turbopack

MetricScenarioBest (ms)P75 (ms)P90 (ms)P99 (ms)Samples
TTFSstep239 (-73%) 💚1381 🔴 (+33%) 🔻1534 🔴 (+16%) 🔻1833 🔴 (+24%) 🔻30
TTFSstream241 (-75%) 💚1342 🔴 (+31%) 🔻1372 🔴 (+32%) 🔻1544 🔴 (+33%) 🔻30
TTFShook + stream306 (-27%) 💚1548 🔴 (+19%) 🔻1585 🔴 (+3.3%)1720 🔴 (+0.6%)30
STSO1020 steps (inline)108 (+27%) 🔻151 (+7.9%)172 (+3.6%)255 (-41%) 💚1018
STSO1020 steps (queue-hop)2262 (-52%) 💚2262 (-52%) 💚2262 (-52%) 💚2262 (-52%) 💚1
WO1020 steps150841 (+0.8%)150841 (+0.8%)150841 (+0.8%)150841 (+0.8%)1
SLstream latency105 (+28%) 🔻171 🔴 (+58%) 🔻189 🔴 (+60%) 🔻222 🔴 (+31%) 🔻30
SOstream overhead (text)160 (+54%) 🔻252 🔴 (+49%) 🔻308 (+72%) 🔻769 (+114%) 🔻30
SOstream overhead (structured)152 (+46%) 🔻270 🔴 (+53%) 🔻354 (+52%) 🔻618 (+20%) 🔻30
ℹ️ 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.

@karthikscale3
karthikscale3 marked this pull request as ready for review August 5, 2026 01:42
@karthikscale3
karthikscale3 requested a review from a team as a code ownerAugust 5, 2026 01:42
On a lazy hook queue delivery, the consumer's idempotent hook_received
re-ensure is hoisted above run_started and doubles as the invocation's
setup request: it asks the World to return the current replay log with
the write (new advisory CreateEventParams.preloadEvents), so one HTTP
request yields the canonical event, the reconstructed run, and the
complete replay log — skipping both the run_started POST and the
initial events.list.
- world: optional `preloadEvents?: true` on CreateEventParams, the
hook_received dual of skipPreload; Worlds may ignore it
- world-vercel: createHookReceivedPreloadEventV4 sends the frame Accept
on eligible hook_received posts and decodes either response mode —
frames via the response decoder extracted from the LIST consumer
(GET behavior unchanged), CBOR via the shared materialized-response
mapping. The run is reconstructed from run_created/run_started (plus
attr_set folds), the canonical event found by x-wf-event-id, and
resumeId now survives frame decoding so the runtime can match it
- core: new fast path before the generic run-state setup, guarded on
hookInput.resumeId + payloadDigest; a validated COMPLETE preload
(hasMore false — this path has no cursor-continuation machinery)
initializes workflowRun/preloadedEvents/maxEventsLimit directly,
anything else falls back to the run_started setup without re-posting
the hook; error classification matches the existing re-ensure
(terminal → consume, transient → redeliver); setup source reported
via workflow.resume_setup_source (never
workflow.hook.resilient_resume_materialized, which stays a
recovery-only signal)
- producer resumeHook() is unchanged and never sets preloadEvents
Based directly on main (no dependency on #3124/#3191); pairs with
workflow-server's streamed hook_received replay-log response, which
deploys first — the SDK negotiates per request and falls back safely
against older servers.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@karthikscale3
karthikscale3force-pushed the kk/hook-received-replay-preload branch from 549d6f0 to 2daae33CompareAugust 5, 2026 18:28

@TooTallNateTooTallNate 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.

Reviewed at 549d6f0. Locally: build + typecheck green, core 1920 passed / 3 expected fail, world-vercel 339 — with the 12 consumer-preload tests covering every fallback and error branch I went looking for.

What held up under scrutiny:

  • The completeness validation is the right shape: run + startedAt, non-empty events, non-null cursor, hasMore === false, numeric maxEvents (this response plays run_started's role, so a missing ceiling would silently disable event-limit enforcement — good catch), both lifecycle events, and the hook_received matching thisresumeId. On the cursor requirement: I confirmed the backend synthesizes a cursor on the final page even for single-page logs, so the check doesn't dead-letter short runs — but that server behavior is now load-bearing for this fast path; a code comment noting the dependency would help the next person.
  • Error classification is byte-for-byte consistent with the existing re-ensure: HookNotFound/RunExpired consume the delivery; everything else (EntityConflict, truncated stream, transport) rethrows for redelivery and the (runId, resumeId) claim converges. And the deliberate omission of HookResilientResumeMaterialized (this path carries no recovery signal) keeps that metric honest.
  • The terminal-event check before engine dispatch correctly plugs the QuickJS gap (it dispatches before the node loop's terminal detection), and preloadedEventsComplete as an explicit attestation — rather than widening the first-invocation heuristic — is the safer design.
  • resumeId threading through buildEventFromV4 is essential and easy to miss: without it, frame-decoded events would silently fail the matching check and the fast path would never fire. The comment says exactly that.
  • remoteRefBehavior: 'resolve' override on the preload request is right (v4 has no refs endpoint to hydrate lazy descriptors mid-replay), and reconstructRunFromReplayEvents carries every field downstream consumers read — including deploymentId and specVersion, which the in-flight deployment-affinity and slot-identity work key off.

Three asks before merge:

  1. Rebase — the PR is currently CONFLICTING with main (a one-file test conflict in events.test.ts vs #3334).
  2. Coordinate with #2960 (deployment-affinity guard, also open). Its design places the guard ahead of the lazy-hook re-ensure with the explicit invariant "a misrouted resume writes nothing here" — this fast path hoists the hook_received write above where that guard will sit. I believe the combination is still safe (the write is idempotent, involves no key derivation, and the guard still precedes any replay/step execution), but whichever PR lands second must reconcile the placement and rewrite that comment — the "writes nothing" invariant will no longer be literally true, and it should be weakened deliberately rather than silently.
  3. Changeset bump: @workflow/world gains a new public interface field (preloadEvents) plus documented EventResult semantics — per the convention we've applied on recent PRs, new API surface on the world interface should be minor, not patch.

For the record, the paired backend PR's red trigger lane ran with main's SDK (no Accept header → this feature dormant), and its failures match the varied preview-lane flakiness other backend branches see — not this pair. The real proof of the active path will be this PR's own e2e once rebased, since the backend half is already deployable ahead.

Nice perf win with a genuinely safe fallback story. Approving.

@karthikscale3

Copy link
Copy Markdown
ContributorAuthor

CI run completed without any issues (Except one unrelated flake). Checked datadog and compared CI run span metrics pre server side merge vs post server side merge:

The p50 improvement appears across every framework cohort:

SvelteKit: 1,194 → 939 ms, −255 ms
Next.js Turbopack: 1,396 → 1,156 ms, −239 ms
Next.js webpack: 1,371 → 1,048 ms, −324 ms

Merging this now as all review comments are addressed and the confidence levels are high.

@karthikscale3
karthikscale3 merged commit 9c1b3c8 into mainAug 6, 2026
411 of 421 checks passed
@karthikscale3
karthikscale3 deleted the kk/hook-received-replay-preload branch August 6, 2026 02:24
@github-actions

Copy link
Copy Markdown
Contributor

No backport to stable for 9c1b3c8 (AI decision).

This is a latency optimization that adds new API surface — an advisory preloadEvents param on CreateEventParams (a minor bump to @workflow/world), a new world-vercel request/response mode (createHookReceivedPreloadEventV4), a new preloadedEventsComplete option on the QuickJS entrypoint, a widened retry opt-in, and a new workflow.resume_setup_source telemetry attribute. It fixes no user-visible defect on stable, and it is explicitly designed to pair with a server-side change that must deploy first, so it belongs on main only.

To override, re-run the Backport to stable workflow manually via workflow_dispatch and paste this commit SHA into the ref input:

9c1b3c86384181b673d41123d2eec0b987afc75a

@TooTallNate

Copy link
Copy Markdown
Member

Heads-up: the #2960#3345 ordering interaction flagged in my review above is now a red unit test on main.

runtime.test.ts > workflowEntrypoint replay guards > re-routes a misrouted lazy hook resume with its payload intact fails on an unmodified checkout of main (reproduced at 9c1b3c8): the test asserts a misrouted lazy resume writes nohook_received before re-routing, but the fast path added here runs before the deployment-affinity guard from #2960, so the hoisted hook_received re-ensure (and, on the fallback path, run_started) now land before the guard fires:

AssertionError: expected events to not contain { eventType: 'hook_received' }
Received: [ hook_received (hook_1), run_started ]

As discussed in the review, I believe the behavior is still safe — the write is idempotent, involves no key derivation, and the guard still runs before any replay or step execution — but #2960's "a misrouted resume writes nothing here" invariant is now false and its test enforces it, so main is red until one of:

  1. Weaken the invariant deliberately: update the test (and fix(core): re-route runs delivered to the wrong deployment #2960's guard comment) to accept the idempotent pre-guard write, asserting instead that no replay/step work happens and the re-route still carries the payload; or
  2. Restore the invariant cheaply: carry the run's pinned deploymentId on the lazy-hook queue message (hookInput), so the guard can check for a mismatch before the fast path's write without the run fetch the guard otherwise needs — keeping both the perf win and the writes-nothing property.

(Found while reviewing #3372, whose author independently hit the same failing test and correctly identified it as pre-existing.)

cc @karthikscale3@alangenfeld

karthikscale3 added a commit that referenced this pull request Aug 6, 2026
…#3374)
The lazy hook fast path (#3345) hoisted the consumer's hook_received
write above the deployment-affinity guard (#2960), so a misrouted lazy
resume wrote its event before the guard could re-route the delivery.
Stamp the run's pinned deployment on the resume message
(hookInput.deploymentId, from the producer's resume context) and, on
the consumer, compare it against the ambient deployment id immediately
before the fast path: a match continues with no run fetch, a mismatch
fetches the authoritative run and hands it to the existing guard —
which keeps sole ownership of re-route/fail policy and remains the
authoritative protection before replay and step execution. The
re-routed message preserves the complete hookInput (it may hold the
only copy of the resume payload). Older messages without the field, and
worlds without deployment affinity, are unchanged: they skip the
pre-check and rely on the authoritative guard, the pre-guard write
staying convergent per (runId, resumeId).
Fixes the misrouted-lazy-resume unit test broken by the #2960/#3345
ordering: a modern misrouted resume now re-routes with zero event
writes, asserted for both hook_received and run_started.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
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.

3 participants

@karthikscale3@TooTallNate@pranaygp