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

vercelBot commented Aug 7, 2026

Copy link
Copy Markdown
Contributor

@changeset-bot

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
NameType
@workflow/corePatch
@workflow/worldPatch
@workflow/world-localPatch
@workflow/world-postgresPatch
@workflow/world-vercelPatch
@workflow/buildersPatch
@workflow/cliPatch
@workflow/nextPatch
@workflow/nitroPatch
@workflow/vitestPatch
@workflow/web-sharedPatch
@workflow/webPatch
workflowPatch
@workflow/world-testingPatch
@workflow/astroPatch
@workflow/nestPatch
@workflow/rollupPatch
@workflow/sveltekitPatch
@workflow/vitePatch
@workflow/nuxtPatch

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

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

@github-actions

github-actionsBot commented Aug 7, 2026

Copy link
Copy Markdown
Contributor

🧪 E2E Test Results

All tests passed

E2E Test Summary

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

✅ ▲ Vercel Production

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

✅ 💻 Local Development

AppPassedFailedSkipped
✅ astro-stable-node130026
✅ astro-stable-quickjs130026
✅ express-stable-node130026
✅ express-stable-quickjs130026
✅ fastify-stable-node130026
✅ fastify-stable-quickjs130026
✅ hono-stable-node130026
✅ hono-stable-quickjs130026
✅ nextjs-turbopack-canary-node137019
✅ nextjs-turbopack-canary-quickjs137019
✅ nextjs-turbopack-stable-node15600
✅ nextjs-turbopack-stable-quickjs15600
✅ nextjs-webpack-canary-node137019
✅ nextjs-webpack-canary-quickjs137019
✅ nextjs-webpack-stable-node15600
✅ nextjs-webpack-stable-quickjs15600
✅ nitro-stable-node130026
✅ nitro-stable-quickjs130026
✅ nuxt-stable-node130026
✅ nuxt-stable-quickjs130026
✅ sveltekit-stable-node14907
✅ sveltekit-stable-quickjs14907
✅ vite-stable-node130026
✅ vite-stable-quickjs130026

✅ 📦 Local Production

AppPassedFailedSkipped
✅ astro-stable-node130026
✅ astro-stable-quickjs130026
✅ express-stable-node130026
✅ express-stable-quickjs130026
✅ fastify-stable-node130026
✅ fastify-stable-quickjs130026
✅ hono-stable-node130026
✅ hono-stable-quickjs130026
✅ nextjs-turbopack-canary-node137019
✅ nextjs-turbopack-canary-quickjs137019
✅ nextjs-turbopack-stable-node15600
✅ nextjs-turbopack-stable-quickjs15600
✅ nextjs-webpack-canary-node137019
✅ nextjs-webpack-canary-quickjs137019
✅ nextjs-webpack-stable-node15600
✅ nextjs-webpack-stable-quickjs15600
✅ nitro-stable-node130026
✅ nitro-stable-quickjs130026
✅ nuxt-stable-node130026
✅ nuxt-stable-quickjs130026
✅ sveltekit-stable-node14907
✅ sveltekit-stable-quickjs14907
✅ vite-stable-node130026
✅ vite-stable-quickjs130026

✅ 🐘 Local Postgres

AppPassedFailedSkipped
✅ astro-stable-node130026
✅ astro-stable-quickjs130026
✅ express-stable-node130026
✅ express-stable-quickjs130026
✅ fastify-stable-node130026
✅ fastify-stable-quickjs130026
✅ hono-stable-node130026
✅ hono-stable-quickjs130026
✅ nextjs-turbopack-canary-node137019
✅ nextjs-turbopack-canary-quickjs137019
✅ nextjs-turbopack-stable-node15600
✅ nextjs-turbopack-stable-quickjs15600
✅ nextjs-webpack-canary-node137019
✅ nextjs-webpack-canary-quickjs137019
✅ nextjs-webpack-stable-node15600
✅ nextjs-webpack-stable-quickjs15600
✅ nitro-stable-node130026
✅ nitro-stable-quickjs130026
✅ nuxt-stable-node130026
✅ nuxt-stable-quickjs130026
✅ sveltekit-stable-node14907
✅ sveltekit-stable-quickjs14907
✅ vite-stable-node130026
✅ vite-stable-quickjs130026

✅ 🪟 Windows

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

✅ 📋 Other

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

✅ vercel-multi-region

AppPassedFailedSkipped
✅ nextjs-turbopack2700

📋 View full workflow run

@github-actions

github-actionsBot commented Aug 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

MetricScenarioBest (ms)P75 (ms)P90 (ms)P99 (ms)Samples
TTFSstep237 (-67%) 💚1375 🔴 (+27%) 🔻1402 🔴 (+26%) 🔻1500 🔴 (+33%) 🔻30
TTFSstream237 (±0%)1410 🔴 (+33%) 🔻1443 🔴 (+32%) 🔻1514 🔴 (+35%) 🔻30
TTFShook + stream353 (-20%) 💚1636 🔴 (+15%) 🔻1677 🔴 (+12%)2127 🔴 (+23%) 🔻30
STSO1020 steps (inline)90 (-18%) 💚150 (-29%) 💚174 (-39%) 💚311 (-40%) 💚1019
WO1020 steps148171 (-28%) 💚148171 (-28%) 💚148171 (-28%) 💚148171 (-28%) 💚1
SLstream latency106 (+1.0%)175 🔴 (-51%) 💚285 🔴 (-34%) 💚321 🔴 (-55%) 💚30
SOstream overhead (text)123 (-19%) 💚218 (-30%) 💚309 (-20%) 💚781 (+8.0%)30
SOstream 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 ownerAugust 7, 2026 01:30
@VaguelySeriousVaguelySerious added the event-log-race-repro Run the event log race reproduction job label Aug 7, 2026
@github-actions

github-actionsBot 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

Metric2026-08-07 01:34 UTC #1
logs / deploy
2026-08-07 01:46 UTC #2
logs / deploy
Result4/14 regressions7/14 regressions
Total1414
completed107
CORRUPTED_EVENT_LOG47
USER_ERROR00
RUNTIME_ERROR00
stuck00
other00
infra00
Config14 runs / step-storm 6, hook-storm 6, hook-sleep 2 / c8 / 6x814 runs / step-storm 6, hook-storm 6, hook-sleep 2 / c8 / 6x8
Timingwatchdog 2500ms / step 2200±250ms / stagger 400ms / poke 750ms / timeout 240000mswatchdog 2500ms / step 2200±250ms / stagger 400ms / poke 750ms / timeout 240000ms

Latest Scenario Breakdown

ScenarioTotalcompletedCORRUPTED_EVENT_LOGUSER_ERRORRUNTIME_ERRORstuckotherinfra
step-storm62400000
hook-storm63300000
hook-sleep22000000

Latest Non-Completed Runs

ScenarioAttemptOutcomeStatusError codeRun
step-storm1CORRUPTED_EVENT_LOGfailedCORRUPTED_EVENT_LOGwrun_41KZCY7ZGB0GTGXJN7A1CD1RBP
step-storm5CORRUPTED_EVENT_LOGfailedCORRUPTED_EVENT_LOGwrun_41KZCY7ZHH0GG1SMJZ13D0H7EY
step-storm4CORRUPTED_EVENT_LOGfailedCORRUPTED_EVENT_LOGwrun_41KZCY7ZGA0GNKXP67AH7Z91S4
step-storm3CORRUPTED_EVENT_LOGfailedCORRUPTED_EVENT_LOGwrun_41KZCY7ZN90GMD08440PRD6KJG
hook-storm4CORRUPTED_EVENT_LOGfailedCORRUPTED_EVENT_LOGwrun_41KZCYA2W70GZCE5SH8ZXMVRKG
hook-storm6CORRUPTED_EVENT_LOGfailedCORRUPTED_EVENT_LOGwrun_41KZCYA2W70GZCE5SH8ZXMVRKJ
hook-storm5CORRUPTED_EVENT_LOGfailedCORRUPTED_EVENT_LOGwrun_41KZCYA2W70GZCE5SH8ZXMVRKH

@shalabhcshalabhc 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)

@shalabhcshalabhcAug 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
MemberAuthor

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 mainAug 7, 2026
290 of 296 checks passed
@VaguelySerious
VaguelySerious deleted the peter/events-create-cursor-delta branch August 7, 2026 17:12
@github-actionsgithub-actionsBot mentioned this pull request Aug 7, 2026
@github-actions

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

Labels

event-log-race-reproRun the event log race reproduction job

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants

@VaguelySerious@shalabhc