Skip to content

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

Open
karthikscale3 wants to merge 1 commit into
mainfrom
kk/hook-received-replay-preload
Open

perf(core): initialize lazy hook replay from hook_received stream#3345
karthikscale3 wants to merge 1 commit 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: 2daae33

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 Patch
@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 6:33pm
example-nextjs-workflow-webpack Ready Ready Preview Aug 5, 2026 6:33pm
example-workflow Ready Ready Preview Aug 5, 2026 6:33pm
workbench-astro-workflow Ready Ready Preview Aug 5, 2026 6:33pm
workbench-express-workflow Ready Ready Preview Aug 5, 2026 6:33pm
workbench-fastify-workflow Ready Ready Preview Aug 5, 2026 6:33pm
workbench-hono-workflow Ready Ready Preview Aug 5, 2026 6:33pm
workbench-nestjs-workflow Ready Ready Preview Aug 5, 2026 6:33pm
workbench-nitro-workflow Ready Ready Preview Aug 5, 2026 6:33pm
workbench-nuxt-workflow Ready Ready Preview Aug 5, 2026 6:33pm
workbench-sveltekit-workflow Ready Ready Preview Aug 5, 2026 6:33pm
workbench-tanstack-start-workflow Ready Ready Preview Aug 5, 2026 6:33pm
workbench-vite-workflow Ready Ready Preview Aug 5, 2026 6:33pm
workflow-docs Ready Ready Preview, v0 Aug 5, 2026 6:33pm
workflow-swc-playground Ready Ready Preview Aug 5, 2026 6:33pm
workflow-tarballs Ready Ready Preview Aug 5, 2026 6:33pm
workflow-web Ready Ready Preview Aug 5, 2026 6:33pm

@github-actions

github-actions Bot commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

🧪 E2E Test Results

Some tests failed

❌ Failed E2E Tests

📋 Other (1 failed)

e2e-local-prod-nest-stable-node (1 failed):

  • webhookWorkflow | wrun_41KZ9K61310GNTHVXDKNWSKXP1

E2E Test Summary

Summary
Passed Failed Skipped Total
✅ ▲ Vercel Production 2932 0 500 3432
✅ 💻 Local Development 3290 0 454 3744
✅ 📦 Local Production 3290 0 454 3744
✅ 🐘 Local Postgres 3290 0 454 3744
✅ 🪟 Windows 312 0 0 312
❌ 📋 Other 2067 1 428 2496
✅ vercel-multi-region 27 0 0 27
Total 15208 1 2290 17499
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-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 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 129 1 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 2daae33 · Wed, 05 Aug 2026 18:48:38 GMT · run logs

Backend: vercel · app: 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
📈 STSO distribution vs main (inline / queue-hop histograms)

1020 steps (inline)

Cumulative STSO time: main 133947ms → this run 140317ms (Δ +6370ms, +5%)

 50-100 ms  ┃                         main  15  this   2   -13
100-150 ms  ███████████████████████┃  main 827  this 829    +2
150-200 ms  ███┃                      main 137  this 154   +17
200-250 ms  ┃                         main  23  this  21    -2
250-300 ms  ┃                         main  10  this   7    -3
300-350 ms  ┃                         main   3  this   1    -2
350-400 ms  ┃                         main   2  this   3    +1
500-550 ms  ┃                         main   1  this   0    -1
750-800 ms  ┃                         main   0  this   1    +1

1020 steps (queue-hop)

Cumulative STSO time: main 2956ms → this run 3278ms (Δ +322ms, +11%)

2500-3000 ms  ┃███████████████████████  main 1  this 0  -1
3000-3500 ms  ░░░░░░░░░░░░░░░░░░░░░░░┃  main 0  this 1  +1
📜 Previous results (1)

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.

@pranaygp pranaygp left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Reviewed together with vercel/workflow-server#706 across correctness, perf, compat, observability, and docs. The core design checks out: the preload validation is sufficient against truncation and staleness (the decoder hard-requires the _end sentinel; the completeness checks cover the rest), ordering matches events.list by construction (same server query, same decode, no client re-sort), the fallback never re-posts the hook, and the compat matrix (old server, server rollback, preload-unaware worlds, Accept-header-only opt-in) verifies clean. Inline comments for the specifics.

One process ask: the streamed path is never exercised end-to-end pre-merge — the server PR's trigger tests SDK main (dormant CBOR path only) and this PR's e2e ran against production without #706 (fallback only). Suggest merging + deploying #706 first, then re-running this PR's Vercel Prod e2e lanes before merge so frames → validation → replay-init runs against the real server at least once.

Non-blocking: docs/content/docs/v5/changelog/resilient-resume.mdx describes this flow and could take a one-paragraph update for the consumer fast path.

);
hookEnsured = true;
// Note: unlike the re-ensure below, this hoisted write
// does NOT set HookResilientResumeMaterialized — it

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Telemetry regression: with this hoisted write never setting HookResilientResumeMaterialized, and hookEnsured = true making the re-ensure block's set site (~L1925, the only remaining one) unreachable for every resumeId+digest delivery, workflow.hook.resilient_resume_materialized stops being emitted fleet-wide once this ships — on both the stream and fallback outcomes, so old servers are affected too.

The producer-side pair workflow.hook.resilient_resume still fires, so anything pairing recovery-begin with recovery-complete will read as 100% never-completing, and workflow.resume_setup_source can't stand in (it can't distinguish a consumer-materialized recovery from ordinary convergence — the v4 response doesn't expose eventWasCreated to the SDK).

Worth either plumbing an equivalent signal through the preload response, or explicitly accepting the loss and updating the attribute's doc in semantic-conventions.ts (it still documents materialized as the completion of the recovery path).

// step path already loaded the run.
let hookEnsured = false;
if (
!workflowRun &&

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This gate checks only the message shape — no world/server preload-capability check — so the hoisted write runs on every lazy resume even where nothing can honor preloadEvents (old or rolled-back servers; world-local — world-postgres never takes the lazy path). Two consequences, neither blocking:

  1. On those backends the path becomes hook_received + run_started + events.list — one more request than main, because main's Option A skip (re-ensure elided when the producer's write is already visible in the run_started preload) is now unreachable for resumeId-carrying deliveries. The surviving Option A block below is effectively dead code for this shape — worth removing or re-gating.
  2. Against a pre-Friendlier and actionable error messages #706 v4 server, the preload attempt sends remoteRefBehavior: 'resolve' (world-vercel events.ts:754), so the old server S3-resolves and echoes the full hook payload in a CBOR response the consumer discards — potentially large payloads per resume during a rollout window or rollback.

// the delivery here, before any engine runs. Same
// outcome as the run_started path's non-running
// status check.
if (hasRecordedTerminalRunEvent(result.events, runId)) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Nit: this early return happens before the span?.setAttributes below, so deliveries consumed against an already-terminal run carry neither WorkflowRunStatus nor workflow.resume_setup_source. The baseline path recorded the terminal status on the span before its equivalent skip — queries segmenting wasted deliveries by run status lose these spans on the fast path; only the log line remains.

* the same event-frame sequence LIST uses, ending with the `_end` sentinel.
* A truncated stream (EOF without the sentinel) throws; the write is
* deduplicated by the server's `(runId, resumeId)` constraint, so retrying
* the whole request is safe and converges on the same canonical event.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This docstring is right that the (runId, resumeId) constraint makes retrying safe — but withEventPostRetry still classifies hook_received as retryable: false ("no server guard → a retry duplicates the row", event-retry.ts:137), which is now stale for the digest+resumeId shape. Since this single POST now carries the whole invocation setup, one transient ECONNRESET costs a full queue redelivery + cold invocation + full re-stream, where a ~200ms inline retry (as run_started gets today) would recover. Cheap follow-up: mark the digest+resumeId shape inline-retryable.

* deduplicated by the server's `(runId, resumeId)` constraint, so retrying
* the whole request is safe and converges on the same canonical event.
*/
export async function createHookReceivedPreloadEventV4(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Per the repo rule (every world-vercel request path must inject trace context, covered in trace-propagation.test.ts — this exact file regressed cross-service correlation once before): this new request shape currently inherits injection via fetchV4instrumentedFetch, which is correct, but trace-propagation.test.ts is untouched and the new tests assert Accept/meta/remoteRefBehavior but never traceparent. A later refactor giving this call its own fetch (e.g. streaming-specific abort/timeout handling) would silently break correlation for the new hot path — exactly how the original events-v4 regression happened. Can we add the propagation test for this path?

* ignored for other event types. Producer-side `resumeHook()` must not set
* it.
*/
preloadEvents?: true;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

The JSDoc names the fields a World may return, but the honored-only-if contract lives only in core's validation code: complete log (hasMore: false), a valid cursor, run + maxEvents, both lifecycle events, and the hook_received matching the delivery's resumeId. And unlike the sinceCursor doc above, nothing states the returned log must be read atomically consistent with this write, with the same ordering semantics as events.list.

A community world author mirroring the sinceCursor pattern (first page, hasMore: true) would silently never activate the fast path — confusing but safe. Worse: a non-transactional read that happens to contain the lifecycle events and the matching hook_received passes structural validation while missing a concurrently committed event, and replay initializes from a wrong log — the CORRUPTED_EVENT_LOG class this validation exists to prevent, on a path the validation cannot catch. Suggest spelling out the completeness + consistency rules here (and optionally a paragraph in the worlds docs).

let events: Event[];
let eventsFetchedPages = 0;
const usePreloaded = isFirstInvocation(preloadedEvents);
const usePreloaded =

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Test gap: the new consumer-preload tests drive only the node:vm engine, so this gate — trusting an attested-complete preload as the full log, and (critically) NOT trusting a fallback run_started preload beyond first invocation — has zero coverage. If a future edit drops the preloadedEventsComplete === true arm or the length guard, QuickJS hook resumes would replay from a bounded run_started page and silently drop the log tail. One QuickJS-engine test each for "attested complete → used as full log" and "fallback preload → refetches via events.list" would pin it.

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