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

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

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

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

@vercel

vercel Bot commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

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

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

@github-actions

github-actions Bot commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

🧪 E2E Test Results

All tests passed

E2E Test Summary

Summary
Passed Failed Skipped Total
✅ ▲ Vercel Production 2932 0 500 3432
✅ 💻 Local Development 3153 0 435 3588
✅ 📦 Local Production 3290 0 454 3744
✅ 🐘 Local Postgres 3290 0 454 3744
✅ 🪟 Windows 312 0 0 312
✅ 📋 Other 2068 0 428 2496
✅ vercel-multi-region 27 0 0 27
Total 15072 0 2271 17343
Details by Category

✅ ▲ Vercel Production

App Passed Failed Skipped
✅ astro-node 127 0 29
✅ astro-quickjs 127 0 29
✅ example-node 127 0 29
✅ example-quickjs 127 0 29
✅ express-node 127 0 29
✅ express-quickjs 127 0 29
✅ fastify-node 127 0 29
✅ fastify-quickjs 127 0 29
✅ hono-node 127 0 29
✅ hono-quickjs 127 0 29
✅ nextjs-turbopack-node 152 0 4
✅ nextjs-turbopack-quickjs 152 0 4
✅ nextjs-webpack-node 152 0 4
✅ nextjs-webpack-quickjs 152 0 4
✅ nitro-node 127 0 29
✅ nitro-quickjs 127 0 29
✅ nuxt-node 127 0 29
✅ nuxt-quickjs 127 0 29
✅ sveltekit-node 146 0 10
✅ sveltekit-quickjs 146 0 10
✅ vite-node 127 0 29
✅ vite-quickjs 127 0 29

✅ 💻 Local Development

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

✅ 📦 Local Production

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

✅ 🐘 Local Postgres

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

✅ 🪟 Windows

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

✅ 📋 Other

App Passed Failed Skipped
✅ e2e-local-dev-nest-stable-node 130 0 26
✅ e2e-local-dev-nest-stable-quickjs 130 0 26
✅ e2e-local-dev-tanstack-start-node 130 0 26
✅ e2e-local-dev-tanstack-start-quickjs 130 0 26
✅ e2e-local-postgres-nest-stable-node 130 0 26
✅ e2e-local-postgres-nest-stable-quickjs 130 0 26
✅ e2e-local-postgres-tanstack-start-node 130 0 26
✅ e2e-local-postgres-tanstack-start-quickjs 130 0 26
✅ e2e-local-prod-nest-stable-node 130 0 26
✅ e2e-local-prod-nest-stable-quickjs 130 0 26
✅ e2e-local-prod-tanstack-start-node 130 0 26
✅ e2e-local-prod-tanstack-start-quickjs 130 0 26
✅ e2e-vercel-prod-nest-node 127 0 29
✅ e2e-vercel-prod-nest-quickjs 127 0 29
✅ e2e-vercel-prod-tanstack-start-node 127 0 29
✅ e2e-vercel-prod-tanstack-start-quickjs 127 0 29

✅ vercel-multi-region

App Passed Failed Skipped
✅ nextjs-turbopack 27 0 0

📋 View full workflow run

@github-actions

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

Metric Scenario Best (ms) P75 (ms) P90 (ms) P99 (ms) Samples
TTFS step 1316 (+71%) 🔻 1441 🔴 (+48%) 🔻 1473 🔴 (+39%) 🔻 1839 🔴 (+9.7%) 30
TTFS stream 1293 (+42%) 🔻 1403 🔴 (+44%) 🔻 1465 🔴 (+47%) 🔻 1516 🔴 (+17%) 🔻 30
TTFS hook + stream 1508 (+74%) 🔻 1678 🔴 (+35%) 🔻 1700 🔴 (+35%) 🔻 1784 🔴 (+20%) 🔻 30
STSO 1020 steps (inline) 105 (+17%) 🔻 142 (+8.4%) 165 (+5.8%) 295 (+19%) 🔻 1018
STSO 1020 steps (queue-hop) 3198 3198 3198 3198 1
WO 1020 steps 151538 (+17%) 🔻 151538 (+17%) 🔻 151538 (+17%) 🔻 151538 (+17%) 🔻 1
SL stream latency 113 (+40%) 🔻 171 🔴 (+38%) 🔻 201 🔴 (+34%) 🔻 467 🔴 (+14%) 30
SO stream overhead (text) 127 (+25%) 🔻 183 (+17%) 🔻 197 (+8.2%) 308 (+55%) 🔻 30
SO stream 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

Metric Scenario Best (ms) P75 (ms) P90 (ms) P99 (ms) Samples
TTFS step 1329 (+201%) 🔻 1453 🔴 (+28%) 🔻 1487 🔴 (+2.3%) 1819 🔴 (+18%) 🔻 30
TTFS stream 331 (+87%) 🔻 1436 🔴 (+35%) 🔻 1470 🔴 (+38%) 🔻 1571 🔴 (+36%) 🔻 30
TTFS hook + stream 1397 (+12%) 1750 🔴 (+27%) 🔻 1806 🔴 (+26%) 🔻 1972 🔴 (+17%) 🔻 30
STSO 1020 steps (inline) 98 (+15%) 🔻 143 (+2.9%) 164 (±0%) 259 (-3.0%) 1018
STSO 1020 steps (queue-hop) 3278 (+11%) 3278 (+11%) 3278 (+11%) 3278 (+11%) 1
WO 1020 steps 145014 (+5.1%) 145014 (+5.1%) 145014 (+5.1%) 145014 (+5.1%) 1
SL stream latency 114 (+36%) 🔻 169 🔴 (+50%) 🔻 185 🔴 (+32%) 🔻 292 🔴 (-20%) 💚 30
SO stream overhead (text) 142 (+33%) 🔻 217 (+33%) 🔻 272 (+47%) 🔻 327 (-6.3%) 30
SO stream 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

Metric Scenario Best (ms) P75 (ms) P90 (ms) P99 (ms) Samples
TTFS step 239 (-73%) 💚 1381 🔴 (+33%) 🔻 1534 🔴 (+16%) 🔻 1833 🔴 (+24%) 🔻 30
TTFS stream 241 (-75%) 💚 1342 🔴 (+31%) 🔻 1372 🔴 (+32%) 🔻 1544 🔴 (+33%) 🔻 30
TTFS hook + stream 306 (-27%) 💚 1548 🔴 (+19%) 🔻 1585 🔴 (+3.3%) 1720 🔴 (+0.6%) 30
STSO 1020 steps (inline) 108 (+27%) 🔻 151 (+7.9%) 172 (+3.6%) 255 (-41%) 💚 1018
STSO 1020 steps (queue-hop) 2262 (-52%) 💚 2262 (-52%) 💚 2262 (-52%) 💚 2262 (-52%) 💚 1
WO 1020 steps 150841 (+0.8%) 150841 (+0.8%) 150841 (+0.8%) 150841 (+0.8%) 1
SL stream latency 105 (+28%) 🔻 171 🔴 (+58%) 🔻 189 🔴 (+60%) 🔻 222 🔴 (+31%) 🔻 30
SO stream overhead (text) 160 (+54%) 🔻 252 🔴 (+49%) 🔻 308 (+72%) 🔻 769 (+114%) 🔻 30
SO stream 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 owner August 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
karthikscale3 force-pushed the kk/hook-received-replay-preload branch from 549d6f0 to 2daae33 Compare August 5, 2026 18:28

@TooTallNate TooTallNate 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 this resumeId. 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
karthikscale3 requested a review from pranaygp August 5, 2026 22:49
@karthikscale3

Copy link
Copy Markdown
Contributor Author

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 main Aug 6, 2026
411 of 421 checks passed
@karthikscale3
karthikscale3 deleted the kk/hook-received-replay-preload branch August 6, 2026 02:24
@github-actions

github-actions Bot commented Aug 6, 2026

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 no hook_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 free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants