Skip to content

perf(core): prepare replay payloads concurrently - #2980

Merged
NathanColosimo merged 8 commits into
mainfrom
codex/replay-payload-cache
Jul 17, 2026
Merged

perf(core): prepare replay payloads concurrently#2980
NathanColosimo merged 8 commits into
mainfrom
codex/replay-payload-cache

Conversation

@NathanColosimo

@NathanColosimoNathanColosimo commented Jul 17, 2026

Copy link
Copy Markdown
Contributor

Summary

  • split replay payload hydration into a VM-independent prepare phase (decrypt + decompress) and a VM-specific revive phase
  • encapsulate invocation-scoped prepared payloads and primitive step results in ReplayPayloadCache, with one instance shared across inline replay VMs
  • prewarm only newly discovered workflow input, completed/failed step, and hook payloads concurrently, then reuse their prepared bytes without repeatedly awaiting old cache entries
  • preserve deterministic delivery ordering, failure observation, legacy payload behavior, and fresh object graphs inside each replay VM
  • expose a sync-or-async ReplayPayloadPreparer boundary so a future Node synchronous decrypt implementation can replace Web Crypto without changing the cache or consumers
  • consolidate the replay cache tests into one focused suite; compared with main, the cache coverage grows by about 30 lines rather than roughly 300

Benchmark

Benchmarked this SDK change against the immutable preview for vercel/workflow-server#632 at server commit 063557ca5565d9dc3b182f602372c0222a7c9379. The temporary WORKFLOW_SERVER_URL_OVERRIDE pin has been removed.

End-to-end main -> combined SDK + server:

Step windowMainCombinedChange
1-20301.7 ms286.6 ms-5.0%
101-120432.8 ms244.7 ms-43.5%
1001-1020856.6 ms556.4 ms-35.0%

Marginal SDK gain with both branches on the same server preview: approximately 0%, 16.2%, and 22.3% for those windows. Matched workflow.run spans improved from 101.6/141.2/570.2 ms to 100.8/102.4/388.6 ms.

Detailed trace analysis: #2980 (comment)

Tradeoff

Prepared plaintext remains cached for the invocation lifetime so replay preparation stays once per payload. Peak cache memory is therefore the sum of decrypted and decompressed payload sizes for that invocation; the cache never crosses runs or queue deliveries.

Validation

  • pnpm test in packages/core (69 files, 1,477 passing, 3 expected failures)
  • pnpm build in packages/core
  • focused replay/hook/abort ordering suites (56 passing, 2 expected failures)
  • Biome check on the cache implementation and tests
  • git diff --check
  • Autoreview loop with Codex gpt-5.6-sol xhigh and Claude Opus 4.8 xhigh: clean, zero findings

@changeset-bot

changeset-botBot commented Jul 17, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 9259e59

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

This PR includes changesets to release 16 packages
NameType
@workflow/corePatch
workflowPatch
@workflow/buildersPatch
@workflow/cliPatch
@workflow/nextPatch
@workflow/nitroPatch
@workflow/vitestPatch
@workflow/web-sharedPatch
@workflow/webPatch
@workflow/world-testingPatch
@workflow/astroPatch
@workflow/nestPatch
@workflow/nuxtPatch
@workflow/rollupPatch
@workflow/sveltekitPatch
@workflow/vitePatch

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 Jul 17, 2026

Copy link
Copy Markdown
Contributor

@github-actions

github-actionsBot commented Jul 17, 2026

Copy link
Copy Markdown
Contributor

📊 Workflow Benchmarks

commit 9259e59 · Fri, 17 Jul 2026 09:57:58 GMT · run logs

Backend: vercel · app: nextjs-turbopack

MetricScenarioAvg (ms)P75 (ms)P90 (ms)P99 (ms)Samples
TTFSstream1331 (+23%)1671 🔴1709 🔴1819 🔴30
TTFShook + stream1549 (+17%)1878 🔴1901 🔴2109 🔴30
STSO1020 steps (1-20)283 (+1.4%)295 🔴409 🔴642 🔴19
STSO1020 steps (101-120)384 (+0.8%)419 🔴452 🔴462 🔴19
STSO1020 steps (1001-1020)613 (-50%)637 🔴674 🔴730 🔴19
WOstream1331 (+23%)16711709181930
WOhook + stream1549 (+17%)18781901210930
SLstream2546 (+133%)5758 🔴6044 🔴6062 🔴30
SLhook + stream4371 (+154%)5891 🔴5998 🔴6131 🔴30
📜 Previous results (3)

cbb98ce

Fri, 17 Jul 2026 08:52:24 GMT · run logs

vercel / nextjs-turbopack

MetricScenarioAvg (ms)P75 (ms)P90 (ms)P99 (ms)Samples
TTFSstream1442 (+33%)1739 🔴1755 🔴1919 🔴30
TTFShook + stream1517 (+15%)1896 🔴1975 🔴2191 🔴30
STSO1020 steps (1-20)244 (-12%)260 🔴309 🔴346 🔴19
STSO1020 steps (101-120)363 (-4.6%)389 🔴453 🔴463 🔴19
STSO1020 steps (1001-1020)685 (-44%)714 🔴725 🔴961 🔴19
WOstream1442 (+33%)17391755191930
WOhook + stream1517 (+15%)18961975219130
SLstream2744 (+151%)6024 🔴6116 🔴6366 🔴30
SLhook + stream4034 (+134%)5919 🔴6057 🔴6182 🔴30

358ed35

Fri, 17 Jul 2026 06:11:42 GMT · run logs

vercel / nextjs-turbopack

MetricScenarioAvg (ms)P75 (ms)P90 (ms)P99 (ms)Samples
TTFSstream1254 (+16%)1583 🔴1643 🔴1718 🔴30
TTFShook + stream1473 (+12%)1819 🔴1859 🔴1963 🔴30
STSO1020 steps (1-20)295 (+5.8%)326 🔴344 🔴751 🔴19
STSO1020 steps (101-120)397 (+4.2%)412 🔴542 🔴564 🔴19
STSO1020 steps (1001-1020)643 (-47%)669 🔴686 🔴694 🔴19
WOstream1254 (+16%)15831643171830
WOhook + stream1473 (+12%)18191859196330
SLstream3883 (+255%)5788 🔴5897 🔴6059 🔴30
SLhook + stream4169 (+142%)5565 🔴5640 🔴5678 🔴30

4605884

Fri, 17 Jul 2026 04:32:33 GMT · run logs

vercel / nextjs-turbopack

MetricScenarioAvg (ms)P75 (ms)P90 (ms)P99 (ms)Samples
TTFSstream1189 (+9.4%)1608 🔴1640 🔴1831 🔴30
TTFShook + stream1454 (+1.8%)1780 🔴1816 🔴2137 🔴30
STSO1020 steps (1-20)287 (-5.0%)312 🔴468 🔴705 🔴19
STSO1020 steps (101-120)245 (-43%)262 🔴304 🔴382 🔴19
STSO1020 steps (1001-1020)556 (-35%)572 🔴595 🔴762 🔴19
WOstream1189 (+9.4%)16081640183130
WOhook + stream1454 (+1.8%)17801816213730
SLstream4743 (+1.3%)5499 🔴5588 🔴6080 🔴30
SLhook + stream4937 (+1.7%)5559 🔴5629 🔴5659 🔴30

Avg deltas compare against the most recent benchmark run on main at the time of this run.

Metrics — TTFS: time to first step body execution · STSO: step-to-step overhead (gap between consecutive step bodies) · WO: workflow overhead (time outside step bodies, client start → last step body exit) · SL: stream latency (first chunk write → visible to the reader)

Scenarios — stream: one step that streams chunks back to the client; no hooks, so the run stays in turbo mode · hook + stream: registers a hook before the same streaming step, which exits turbo mode · 1020 steps: 1020 trivial sequential steps; STSO is measured between consecutive steps in the given step ranges

🟢/🔴 mark percentiles within/above target. Targets (p75/p90/p99, ms) — TTFS 200/300/600 · SL 50/60/125 · STSO (1-20) 20/30/60 · STSO (101-120) 30/45/90 · STSO (1001-1020) 40/60/120

TTFS/WO compare client vs deployment clocks and SL compares the step runner’s clock vs the client’s (NTP-synced in CI). WO ends at the last step body exit, the closest observable proxy for the final step-completion request.

@github-actions

github-actionsBot commented Jul 17, 2026

Copy link
Copy Markdown
Contributor

🧪 E2E Test Results

All tests passed

Summary

PassedFailedSkippedTotal
✅ ▲ Vercel Production145302301683
✅ 💻 Local Development161702191836
✅ 📦 Local Production161702191836
✅ 🐘 Local Postgres161702191836
✅ 🪟 Windows15300153
✅ 📋 Other89401771071
✅ vercel-multi-region270027
Total7378010648442

Details by Category

✅ ▲ Vercel Production
AppPassedFailedSkipped
✅ astro126027
✅ example126027
✅ express126027
✅ fastify126027
✅ hono126027
✅ nextjs-turbopack15003
✅ nextjs-webpack15003
✅ nitro126027
✅ nuxt126027
✅ sveltekit14508
✅ vite126027
✅ 💻 Local Development
AppPassedFailedSkipped
✅ astro-stable128025
✅ express-stable128025
✅ fastify-stable128025
✅ hono-stable128025
✅ nextjs-turbopack-canary134019
✅ nextjs-turbopack-stable15300
✅ nextjs-webpack-canary134019
✅ nextjs-webpack-stable15300
✅ nitro-stable128025
✅ nuxt-stable128025
✅ sveltekit-stable14706
✅ vite-stable128025
✅ 📦 Local Production
AppPassedFailedSkipped
✅ astro-stable128025
✅ express-stable128025
✅ fastify-stable128025
✅ hono-stable128025
✅ nextjs-turbopack-canary134019
✅ nextjs-turbopack-stable15300
✅ nextjs-webpack-canary134019
✅ nextjs-webpack-stable15300
✅ nitro-stable128025
✅ nuxt-stable128025
✅ sveltekit-stable14706
✅ vite-stable128025
✅ 🐘 Local Postgres
AppPassedFailedSkipped
✅ astro-stable128025
✅ express-stable128025
✅ fastify-stable128025
✅ hono-stable128025
✅ nextjs-turbopack-canary134019
✅ nextjs-turbopack-stable15300
✅ nextjs-webpack-canary134019
✅ nextjs-webpack-stable15300
✅ nitro-stable128025
✅ nuxt-stable128025
✅ sveltekit-stable14706
✅ vite-stable128025
✅ 🪟 Windows
AppPassedFailedSkipped
✅ nextjs-turbopack15300
✅ 📋 Other
AppPassedFailedSkipped
✅ e2e-local-dev-nest-stable128025
✅ e2e-local-dev-tanstack-start-128025
✅ e2e-local-postgres-nest-stable128025
✅ e2e-local-postgres-tanstack-start-128025
✅ e2e-local-prod-nest-stable128025
✅ e2e-local-prod-tanstack-start-128025
✅ e2e-vercel-prod-tanstack-start126027
✅ vercel-multi-region
AppPassedFailedSkipped
✅ nextjs-turbopack2700

📋 View full workflow run

Comment threadpackages/world-vercel/src/utils.ts Outdated
@NathanColosimo

NathanColosimo commented Jul 17, 2026

Copy link
Copy Markdown
ContributorAuthor

Benchmark against workflow-server PR #632 is complete: Actions run 29554462485, generated comparison.

The run used SDK feature commit d0fbcc3ca, benchmark pin 46058844e, and the exact server preview for commit 063557ca5565d9dc3b182f602372c0222a7c9379 (workflow-server-rb43t17tx.vercel.sh).

End-to-end STSO versus main

Step windowMain avgThis PR avgChangePR p75
1-20301.7 ms286.6 ms-5.0%312 ms
101-120432.8 ms244.7 ms-43.5%262 ms
1001-1020856.6 ms556.4 ms-35.0%572 ms

Matched workflow.run evidence

The closest prior control is run 29552741525, which used the same server commit/preview plus the SDK event-page-size change. Event loading happens outside workflow.run, so matching these replay spans isolates the hydration change more directly than end-to-end STSO does.

Event-count rangePrior avgThis PR avgChange
0-70101.6 ms100.8 ms-0.8%
300-365141.2 ms102.4 ms-27.5%
3000-3065570.2 ms388.6 ms-31.9%

Datadog: new full trace, prior full trace, new representative event 3059 span (406.5 ms), prior event 3059 span (559.4 ms).

All 1,020 prior workflow.run spans carried workflow.serialization.operation=deserialize; the new trace has zero such workflow.run spans because decrypt/decompress now starts in the host-side prewarm phase and prepared bytes are reused. Fresh VM-specific devalue revival still occurs in ordered delivery slots, which is why replay time still grows with depth.

The flat early window and increasing mid/late savings are the expected shape: fixed VM and durable transport costs remain, while repeated payload preparation grows with replay depth and is now removed. The benchmark-only URL override remains as the second commit and must be removed before merge; it intentionally trips No Test Overrides and three world-vercel HTTP mock tests that expect the default origin.

CI follow-up: the one initial Astro readableStreamWorkflow failure passed on a targeted rerun (job 87806063509, 126/126 tests). The remaining red checks are only the intentional URL-override checks described above.

@VaguelySeriousVaguelySerious left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Traced the full change (cache, serialization split, and all four consumer sites). Approving — no correctness regressions found; the only real cost is memory, which the PR describes honestly.

Correctness / regression assessment — low risk

  • Buffer aliasing across VMs is safe. The same prepared Uint8Array is now fed to deserialize on every replay. The read path — decodeFormatPrefix (subarray views only) → TextDecoder.decode → devalue parse — never mutates the buffer, so sharing one decrypted copy across fresh VMs cannot corrupt a later replay.
  • The one mutating path (legacy unflatten) is correctly excluded. Both prewarm's start() and consumePreparation bail on !(value instanceof Uint8Array), so legacy flattened arrays are never cached — matching prior per-consumer behavior. That branch is effectively dead in prod (SDK version pinned per run).
  • Determinism preserved. All prepare*/getStepResult awaits happen inside the same ctx.promiseQueue slot (step.ts, hook.ts, abort-controller.ts), so ordered delivery, pendingDeliveries, and barriers are unchanged. Fresh object graphs are still produced per VM; only immutable primitives are returned by reference (same 4 KiB bound as before).
  • Failure semantics preserved. Prewarm retains rejections (Promise.allSettled); the ordered consumer evicts a rejected entry only after observing it (consumePreparation's .catch), so a later replay re-attempts — same as the old "errors never cached" rule. No unhandled-rejection window, since allSettled attaches handlers eagerly.
  • maybeDecryptdecrypt swap is a no-op (maybeDecrypt is literally return decrypt(data, key)).
  • No dangling references. Only one runWorkflow caller (updated); no non-test imports of the removed step-hydration-cache remain. WorkflowOrchestratorContext.replayPayloadCache is now required and TS-enforced; all internal construction + test harnesses updated.

Minor, non-blocking

  1. Telemetry frequency change.recordCompression(_, 'deserialize') used to fire on every hydrate (O(N²) across replays); it now fires once per unique payload at prepare time, and from prewarm it runs outside the workflow's promiseQueue, so getActiveSpan() may resolve to a different/absent span. Deserialize-compression metric volume and span attribution will shift. Observability-only.
  2. prewarm only covers step_completed / step_failed / hook_received. If abort-controller payloads arrive as a distinct event type, they fall to an on-demand cache miss — still correct, just a missed concurrency win.

Estimated memory overhead — the real tradeoff

Genuine, intentional increase (matches the PR's "Tradeoff" section):

  • Old: retained only hydrated primitive results ≤4 KiB. Decompressed object plaintext was transient (decompressed one-at-a-time per hydrate, then GC'd). Steady-state extra ≈ 0.
  • New:preparedPayloads holds the decrypted + decompressed serialized bytes of every workflow input + step_completed.result + step_failed.error + hook_received.payloadsimultaneously, for the whole invocation, on top of the compressed/encrypted copies already in cachedEvents.

Peak overhead:

ΔRSS ≈ Σ (decompressed serialized size) over {input, all step results, all step errors, all hook payloads} in the replayed log
≈ (payload bytes already in cachedEvents) × avg_compression_ratio

Scales linearly with step count × payload size, and a single re-invocation of a long-running workflow replays the entire history — so early step results the workflow no longer references still stay resident.

Rough magnitudes:

  • Typical small payloads: negligible (KBs).
  • 1,000 steps × ~5 KB decompressed → ~5 MB.
  • 10,000 steps × ~50 KB → ~500 MB — could threaten OOM in a memory-constrained function (128–1024 MB), and raises peak transient RSS during replay vs. the old one-at-a-time decompress.

Lifetime is sound: constructed once per invocation outside the replay loop, never crosses runs or queue deliveries, GC'd on return — bounded and self-freeing, just larger.

Bottom line: aliasing, determinism, and failure-retry invariants all hold. The cost is memory — peak RSS rises by roughly the full decompressed plaintext of all replayed payloads (a few MB typical; hundreds of MB for high-step-count or large-payload workflows). Worth sanity-checking real-world worst-case step counts before/after rollout, but the tradeoff is reasonable for the reported deep-replay latency win. 🤖 Reviewed with Claude Opus 4.8 (1M context)

@NathanColosimo
NathanColosimo merged commit 268fede into mainJul 17, 2026
171 of 174 checks passed
@NathanColosimo
NathanColosimo deleted the codex/replay-payload-cache branch July 17, 2026 17:18
@github-actionsgithub-actionsBot mentioned this pull request Jul 17, 2026
@github-actions

Copy link
Copy Markdown
Contributor

Backport to stable failed — the cherry-pick had conflicts that could not be resolved automatically (backport job run).

To resolve manually, push a backport branch and open a PR against stable (the workflow never pushes directly to stable). Note: this repository requires verified signatures on every branch, so your local commits must be signed (git config commit.gpgsign true with a configured GPG/SSH signing key, or git cherry-pick -S).

git fetch origin stable
git checkout -b backport/pr-2980-to-stable origin/stable
git cherry-pick -S 268fede627b3a83dbabcff9d35fd946132bf9a06 # -S signs the commit# Fix conflicts, then:
git add -A
git cherry-pick --continue
git push -u origin backport/pr-2980-to-stable
gh pr create --base stable --head backport/pr-2980-to-stable \
--title "Backport #2980: <original PR title>" \
--body "Manual backport of #2980 (cherry-pick 268fede627b3) to \`stable\`."

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.

2 participants

@NathanColosimo@VaguelySerious