Skip to content

[core] Fold events.create deltas into the replay log - #3382

Merged
VaguelySerious merged 1 commit into
mainfrom
peter/events-create-cursor-delta
Aug 7, 2026
Merged

[core] Fold events.create deltas into the replay log#3382
VaguelySerious merged 1 commit into
mainfrom
peter/events-create-cursor-delta

Conversation

@VaguelySerious

Copy link
Copy Markdown
Member

Outside of turbo mode, every write the orchestrator loop makes now carries sinceCursor (the cursor of the log it was computed against) and folds whatever the World hands back into that log.

The follow-up events.list the loop made after committing an elapsed wait_completed is gone. A supporting World has already returned the delta with the write, so the fetch now only runs for completions still missing locally.

What changed

packages/core/src/runtime.ts

  • The loop's EventCreator computes a sinceCursor per write (deltaRequestCursor) and absorbs the response delta (absorbCreateDelta).
  • deltaRequestCursor declines for: turbo (it exists to make the first invocation's writes cheap, and there is no loaded log to extend), run-terminal writes (nothing reads the log afterwards), a caller that set its own sinceCursor, and a caller that asked for the run_started / hook_received preload, which owns the same response fields.
  • absorbCreateDelta declines on a truncated page (hasMore), on a cursor that moved since the request went out, and while a pendingInlineDelta is unconsumed. Declining is always safe: an unabsorbed delta is a delta the next events.list returns. The cursor-equality gate is what serializes concurrent absorbs, since appendUniqueEvents deliberately does not re-sort.
  • The wait pass filters waitsToComplete down to the completions not already in the local log and skips the fetch when that filter is empty. The remaining cases are a World that ignores sinceCursor, a truncated delta, a lost cursor race, and an EntityConflictError, whose rejection carries no delta.

Worldsworld-local, world-postgres, and world-vercel now serve the delta for any event type, not just terminal step events. world-postgres gains a delta branch mirroring its own list cursor semantics (same limit, gt on eventId, limit + 1 for hasMore). world-vercel already forwarded sinceCursor generically.

Scope

Only the orchestrator loop's EventCreator was wired up. The step-executor and suspension-handler creators were left alone deliberately: absorbing there saves no round trip and multiplies the concurrent-absorb ordering hazard. The step-executor already has its own inline-delta path feeding pendingInlineDelta.

Tests

  • wait-completion-replay.test.ts: a fake World that honors sinceCursor (the follow-up fetch is skipped entirely), and one that truncates the delta (the fetch still runs). The fake implements full events.list cursor semantics, so the five pre-existing tests in that file pass unchanged.
  • runtime.test.ts: no inline delta is requested on a run-terminal write, or anywhere under turbo.
  • world-local/storage.test.ts: the create-response delta for non-terminal event types and for wait_completed matches a real events.list from the same cursor.

Both new runtime behaviors were checked to be load-bearing by neutering the implementation and confirming exactly the expected tests fail.

🤖 Generated with Claude Code

Outside of turbo mode, every write the orchestrator loop makes now
carries `sinceCursor` (the cursor of the log it was computed against)
and folds whatever the World hands back into that log.

This removes the extra `events.list` the loop made after committing an
elapsed `wait_completed`: a supporting World has already returned the
delta with the write, so the follow-up fetch only runs for completions
still missing locally (a World that ignores `sinceCursor`, a truncated
page, a lost cursor race, or an `EntityConflictError`, whose rejection
carries no delta).

world-local, world-postgres, and world-vercel now serve the delta for
any event type, not just terminal step events.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@vercel

vercel Bot commented Aug 7, 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 7, 2026 1:18am
example-nextjs-workflow-webpack Ready Ready Preview Aug 7, 2026 1:18am
example-workflow Ready Ready Preview Aug 7, 2026 1:18am
workbench-astro-workflow Ready Ready Preview Aug 7, 2026 1:18am
workbench-express-workflow Ready Ready Preview Aug 7, 2026 1:18am
workbench-fastify-workflow Ready Ready Preview Aug 7, 2026 1:18am
workbench-hono-workflow Ready Ready Preview Aug 7, 2026 1:18am
workbench-nestjs-workflow Ready Ready Preview Aug 7, 2026 1:18am
workbench-nitro-workflow Ready Ready Preview Aug 7, 2026 1:18am
workbench-nuxt-workflow Ready Ready Preview Aug 7, 2026 1:18am
workbench-python-workflow Error Error Aug 7, 2026 1:18am
workbench-sveltekit-workflow Ready Ready Preview Aug 7, 2026 1:18am
workbench-tanstack-start-workflow Ready Ready Preview Aug 7, 2026 1:18am
workbench-vite-workflow Ready Ready Preview Aug 7, 2026 1:18am
workflow-docs Ready Ready Preview, v0 Aug 7, 2026 1:18am
workflow-swc-playground Ready Ready Preview Aug 7, 2026 1:18am
workflow-tarballs Ready Ready Preview Aug 7, 2026 1:18am
workflow-web Ready Ready Preview Aug 7, 2026 1:18am

@changeset-bot

changeset-bot Bot commented Aug 7, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 8e345a4

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

This PR includes changesets to release 20 packages
Name Type
@workflow/core Patch
@workflow/world Patch
@workflow/world-local Patch
@workflow/world-postgres Patch
@workflow/world-vercel Patch
@workflow/builders Patch
@workflow/cli Patch
@workflow/next Patch
@workflow/nitro Patch
@workflow/vitest Patch
@workflow/web-shared Patch
@workflow/web Patch
workflow Patch
@workflow/world-testing 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

@github-actions

github-actions Bot commented Aug 7, 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 3290 0 454 3744
✅ 📦 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 15209 0 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 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 7, 2026

Copy link
Copy Markdown
Contributor

📊 Workflow Benchmarks

commit 8e345a4 · Fri, 07 Aug 2026 01:32:56 GMT · run logs

Backend: vercel · app: nextjs-turbopack

Metric Scenario Best (ms) P75 (ms) P90 (ms) P99 (ms) Samples
TTFS step 237 (-67%) 💚 1375 🔴 (+27%) 🔻 1402 🔴 (+26%) 🔻 1500 🔴 (+33%) 🔻 30
TTFS stream 237 (±0%) 1410 🔴 (+33%) 🔻 1443 🔴 (+32%) 🔻 1514 🔴 (+35%) 🔻 30
TTFS hook + stream 353 (-20%) 💚 1636 🔴 (+15%) 🔻 1677 🔴 (+12%) 2127 🔴 (+23%) 🔻 30
STSO 1020 steps (inline) 90 (-18%) 💚 150 (-29%) 💚 174 (-39%) 💚 311 (-40%) 💚 1019
WO 1020 steps 148171 (-28%) 💚 148171 (-28%) 💚 148171 (-28%) 💚 148171 (-28%) 💚 1
SL stream latency 106 (+1.0%) 175 🔴 (-51%) 💚 285 🔴 (-34%) 💚 321 🔴 (-55%) 💚 30
SO stream overhead (text) 123 (-19%) 💚 218 (-30%) 💚 309 (-20%) 💚 781 (+8.0%) 30
SO stream overhead (structured) 126 (-23%) 💚 342 🔴 (+26%) 🔻 815 🔴 (+149%) 🔻 1365 🔴 (+92%) 🔻 30
📈 STSO distribution vs main (inline / queue-hop histograms)

1020 steps (inline)

Cumulative STSO time: main 204348ms → this run 146786ms (Δ -57562ms, -28%)

 50-100 ms  ┃                         main   0  this   2    +2
100-150 ms  ██████░░░░░░░░░░░░░░░░░┃  main 197  this 756  +559
150-200 ms  ██████┃████████           main 462  this 213  -249
200-250 ms  ┃██████                   main 223  this  26  -197
250-300 ms  ┃█                        main  50  this   9   -41
300-350 ms  ┃                         main  31  this   6   -25
350-400 ms  ┃                         main  19  this   3   -16
400-450 ms  ┃                         main  11  this   1   -10
450-500 ms  ┃                         main  12  this   2   -10
500-550 ms  ┃                         main   6  this   0    -6
550-600 ms  ┃                         main   5  this   0    -5
600-650 ms  ┃                         main   1  this   0    -1
750-800 ms  ┃                         main   1  this   0    -1
800-850 ms  ┃                         main   0  this   1    +1
850-900 ms  ┃                         main   1  this   0    -1
ℹ️ Metric definitions & methodology

The collapsed STSO distribution section above buckets every step gap of the sequential-steps run (not a sampled window), split by whether the step ending the gap ran inline — in the same warm process as the step before it, so the gap is pure framework overhead — or after a queue-hop — the first step of a fresh process, which pays queue dispatch, client reinit and event-log replay. Bars overlay the two runs: is main, marks where this run lands, bridges the gap when this run has more samples in a bucket.

Best/P75/P90/P99 deltas compare against the most recent benchmark run on main at the time of this run. 🔻 flags a delta worse than +15%, 💚 one better than −15%.

Metrics — TTFS: time to first step body (in-deployment start() → first step body, deployment clocks) · STSO: step-to-step overhead (gap between consecutive step bodies) · WO: workflow overhead (whole-run time outside step bodies, in-deployment anchored) · SL: stream latency (in-deployment write → read propagation, readAt - writtenAt) · SO: stream overhead (end-to-end write+consume time beyond the modelled generation window)

Scenarios — step: one trivial no-op step, no stream; no hooks, so the run stays in turbo mode (in-process fast path) · stream: one streaming step; no hooks, so the run stays in turbo mode (in-process fast path) · hook + stream: registers a hook before one step, which exits turbo mode (dispatch path) · 1020 steps: 1020 trivial sequential steps; STSO is measured between consecutive steps in the given step ranges, and WO is the whole-run overhead outside step bodies · stream latency: parallel reader/writer steps on a dedicated stream; SL is the in-deployment write->read propagation (readAt - writtenAt) · stream overhead (text): writer streams 300 variable-length text token deltas paced at 100/s for 3s (a haiku-size LLM's token throughput) while a parallel reader drains the whole stream; SO is the end-to-end write+consume time beyond the 3s generation window (overhead/backpressure) · stream overhead (structured): same workload as stream overhead (text), but each delta is an AI-SDK-style structured object ({ type: 'text-delta', id, text }) instead of a raw string, so the SO gap vs the text scenario is the added serialization cost

🔴 marks a percentile over its target (within target is left unmarked). Targets (p75/p90/p99, ms) — TTFS 200/300/600 · SL 50/60/125 · SO 250/500/1000

All metrics are measured from deployment-side timestamps only. Runs are triggered by an in-deployment route that stamps the anchor (clientStart) right before start(), so the CI runner’s request and its path through api.vercel.com sit outside every measured window. TTFS = in-deployment start() → first step body (turbo uses the in-process fast path, non-turbo the dispatch path), and includes the VQS dispatch hop plus any /flow cold start. STSO/WO are measured between step bodies on the deployment. SL is measured inside the workflow (parallel reader/writer steps), so it no longer includes the api.vercel.com read path.

Cold starts are kept in the numbers on purpose — they are part of real bursty-workload latency. The workbench deployment cold-starts the /flow invocation for a large fraction of runs, inflating P75+; the Best column shows the fastest (warm-start) sample for comparison.

@VaguelySerious
VaguelySerious marked this pull request as ready for review August 7, 2026 01:30
@VaguelySerious
VaguelySerious requested a review from a team as a code owner August 7, 2026 01:30
@VaguelySerious VaguelySerious added the event-log-race-repro Run the event log race reproduction job label Aug 7, 2026
@github-actions

github-actions Bot commented Aug 7, 2026

Copy link
Copy Markdown
Contributor

Event Log Race Repro

7 of 14 latest repro runs hit event-log regressions.

Run History

Metric 2026-08-07 01:34 UTC #1
logs / deploy
2026-08-07 01:46 UTC #2
logs / deploy
Result 4/14 regressions 7/14 regressions
Total 14 14
completed 10 7
CORRUPTED_EVENT_LOG 4 7
USER_ERROR 0 0
RUNTIME_ERROR 0 0
stuck 0 0
other 0 0
infra 0 0
Config 14 runs / step-storm 6, hook-storm 6, hook-sleep 2 / c8 / 6x8 14 runs / step-storm 6, hook-storm 6, hook-sleep 2 / c8 / 6x8
Timing watchdog 2500ms / step 2200±250ms / stagger 400ms / poke 750ms / timeout 240000ms watchdog 2500ms / step 2200±250ms / stagger 400ms / poke 750ms / timeout 240000ms

Latest Scenario Breakdown

Scenario Total completed CORRUPTED_EVENT_LOG USER_ERROR RUNTIME_ERROR stuck other infra
step-storm 6 2 4 0 0 0 0 0
hook-storm 6 3 3 0 0 0 0 0
hook-sleep 2 2 0 0 0 0 0 0

Latest Non-Completed Runs

Scenario Attempt Outcome Status Error code Run
step-storm 1 CORRUPTED_EVENT_LOG failed CORRUPTED_EVENT_LOG wrun_41KZCY7ZGB0GTGXJN7A1CD1RBP
step-storm 5 CORRUPTED_EVENT_LOG failed CORRUPTED_EVENT_LOG wrun_41KZCY7ZHH0GG1SMJZ13D0H7EY
step-storm 4 CORRUPTED_EVENT_LOG failed CORRUPTED_EVENT_LOG wrun_41KZCY7ZGA0GNKXP67AH7Z91S4
step-storm 3 CORRUPTED_EVENT_LOG failed CORRUPTED_EVENT_LOG wrun_41KZCY7ZN90GMD08440PRD6KJG
hook-storm 4 CORRUPTED_EVENT_LOG failed CORRUPTED_EVENT_LOG wrun_41KZCYA2W70GZCE5SH8ZXMVRKG
hook-storm 6 CORRUPTED_EVENT_LOG failed CORRUPTED_EVENT_LOG wrun_41KZCYA2W70GZCE5SH8ZXMVRKJ
hook-storm 5 CORRUPTED_EVENT_LOG failed CORRUPTED_EVENT_LOG wrun_41KZCYA2W70GZCE5SH8ZXMVRKH

@shalabhc shalabhc left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Design wise, this feels like the right direction.

Should we stamp sinceCursor on the event derived from that prefix? Might be super useful when debugging cases where another event slips in between the prefix we read and the event we produced.

.where(
and(
eq(Schema.events.runId, effectiveRunId),
gt(Schema.events.eventId, params.sinceCursor)

@shalabhc shalabhc Aug 7, 2026

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Note only, possibly preexisting:
If mint order is different than commit order (eg in current ULID scheme) could this skip events on delta read? IOW some events that land on the db in the past could be missed by a delta read.

I think this possibility exists everywhere we use the current scheme of minting ULIDs before commit, not just postgres.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

yes, this can be an issue, but I'm closing that gap separately in #3389

@VaguelySerious
VaguelySerious merged commit a8db185 into main Aug 7, 2026
290 of 296 checks passed
@VaguelySerious
VaguelySerious deleted the peter/events-create-cursor-delta branch August 7, 2026 17:12
@github-actions github-actions Bot mentioned this pull request Aug 7, 2026
@github-actions

github-actions Bot commented Aug 7, 2026

Copy link
Copy Markdown
Contributor

No backport to stable for a8db185 (AI decision).

This is a performance optimization: it extends the existing sinceCursor inline-delta mechanism to every non-turbo orchestrator write (and to all event types across world-local/postgres/vercel) so the loop can fold the write response into its replay log and skip a follow-up events.list round trip. No user-visible defect, correctness bug, hang, or data-loss issue is being fixed — the pre-existing fallback fetch path remains intact and correct — and it adds new World behavior plus new response-delta semantics, which is exactly the kind of change a maintenance line should not take.

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

a8db185c3b19b3dab971f51aa076aead81ed26ea

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

event-log-race-repro Run the event log race reproduction job

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants