Skip to content

[core] Gate event creation on the loaded event count and restart replays in-process - #3145

Open
VaguelySerious wants to merge 6 commits into
mainfrom
peter/event-count-guard
Open

[core] Gate event creation on the loaded event count and restart replays in-process#3145
VaguelySerious wants to merge 6 commits into
mainfrom
peter/event-count-guard

Conversation

@VaguelySerious

@VaguelySerious VaguelySerious commented Jul 27, 2026

Copy link
Copy Markdown
Member

Depends on the matching world-vercel backend change, which has landed and is live in production.

Why

The stateUpdatedAt precondition guard was supposed to stop CORRUPTED_EVENT_LOG failures caused by an event landing between a replay's event load and its event write. It does not fully. It compares two scalars — the client's watermark (the ULID time of its latest loaded event) against the latest recorded out-of-band event — which proves only "no outside event landed strictly above my watermark". Replay determinism needs "my loaded array is exactly the canonical log prefix."

A run corrupted with the guard on, reconstructed from its full event log:

19:05:49.612  evnt_…VC91P  wait_completed   replay-context write → no marker
19:05:49.659  evnt_…D6CAS  wait_completed   replay-context write → no marker
19:05:49.659  evnt_…3KXW1  step_completed   out-of-band → marker = 1785179149659
19:05:49.935  5x step_created from a replay missing that step_completed  ← accepted

The writing replay's snapshot ended at the second wait_completed, so its watermark was exactly equal to the marker stamped by the step_completed it had never loaded. Equality is allowed by design (to avoid livelock), so the write was accepted 276 ms late. That replay emitted 2 of a step where the canonical log has 3, and since correlation IDs are positional ordinals of one seeded ULID sequence, every entity from that point on was renamed.

Two properties of this failure matter: a concurrent replay's writes carry no marker at all, so the guard was blind to them; and a watermark cannot represent a hole below itself.

What changes

  • Creations describe their snapshot, not just its tip. stateEventCount (how many events were loaded) and stateCursor join stateUpdatedAt, sent atomically by one helper so they can never diverge. A world rejects when it recorded more events at or below the watermark than the client loaded.
  • A rejection restarts the replay in-process. withPreconditionRetry is deleted: it reloaded the log and then re-committed the same payload, whose correlation IDs were minted by the pre-reload replay — so its retry wrote events no correct replay would produce. A rejection now rebuilds the workflow from a corrected log within the same invocation (bounded by WORKFLOW_PRECONDITION_MAX_INPROCESS_RESTARTS, default 3), then falls back to today's re-invocation. This is what the existing comment on the run_completed path already asked for.
  • The restart can be free. A world may attach the missing events to its 412; the runtime feeds them into the event-log delta path that already exists, so the restart issues no events.list at all. Trusted only on an invocation's first restart, since the completeness proof leans on the world's own bookkeeping. Optional for worlds — absent or unusable details fall back to a full, cursor-less reload.
  • Why cursor-less. An eid: cursor filters lexicographically while a hole is defined by ULID time: an event in the same millisecond sorts either side of the cursor by its random component, and one minted before the client's read but committed after always sorts below it. An incremental reload heals a hole only by luck.
  • attr_set from a suspension is now guarded — it previously bypassed the guard entirely. setAttributes() called from a step body stays unguarded, and says why: it holds no replay snapshot, so there is nothing to compare.
  • A merged event log re-sorts by event ID when an append arrives out of order, and warns when it does. No producer of out-of-order merges was proven; the warn is the point, since the count check's correctness rests on the array's tail being its maximum.

@workflow/world-local and @workflow/world-postgres ignore the new params, as they do stateUpdatedAt, and do not declare the capability.

Known residual

All creations in one suspension share a snapshot and a Promise.all, so the fence rejects every sibling arriving after it engages — but a sibling that committed before it engaged is still unexplained by the corrected replay. Unchanged by this PR (a re-invocation has identical exposure today) and not closable by any optimistic-concurrency check; it needs a run-level append-tail fence or an atomic multi-event create.

Test plan

  • packages/core/src/runtime/precondition-guard-replay.test.ts — restart instead of re-invocation; cursor-less reload; delta consumed with zero events.list; malformed delta falls back; delta ignored on the second restart; exactly WORKFLOW_PRECONDITION_MAX_INPROCESS_RESTARTS restarts then one re-invocation; the restarted create carries correlation IDs derived from the corrected log; attr_set carries all three fields.
  • packages/core/src/runtime/helpers.test.ts — the three fields are sent or withheld as a unit; out-of-order merge re-sorts, warns, and leaves the tail as the maximum; 412 delta decoding.
  • packages/core/src/runtime.test.ts — an inline claim rejected mid-batch now completes the run inside the same delivery, with the step body running exactly once.
  • packages/world-vercel — wire-format pairs for both new fields, absence on the legacy compat path, and a 412 body whose events arrive decoded on the error.

🤖 Generated with Claude Code

@vercel

vercel Bot commented Jul 27, 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, Comment Jul 28, 2026 8:40pm
example-nextjs-workflow-webpack Ready Ready Preview, Comment Jul 28, 2026 8:40pm
example-workflow Ready Ready Preview, Comment Jul 28, 2026 8:40pm
workbench-astro-workflow Ready Ready Preview, Comment Jul 28, 2026 8:40pm
workbench-express-workflow Ready Ready Preview, Comment Jul 28, 2026 8:40pm
workbench-fastify-workflow Ready Ready Preview, Comment Jul 28, 2026 8:40pm
workbench-hono-workflow Ready Ready Preview, Comment Jul 28, 2026 8:40pm
workbench-nestjs-workflow Ready Ready Preview, Comment Jul 28, 2026 8:40pm
workbench-nitro-workflow Ready Ready Preview, Comment Jul 28, 2026 8:40pm
workbench-nuxt-workflow Ready Ready Preview, Comment Jul 28, 2026 8:40pm
workbench-sveltekit-workflow Ready Ready Preview, Comment Jul 28, 2026 8:40pm
workbench-tanstack-start-workflow Ready Ready Preview, Comment Jul 28, 2026 8:40pm
workbench-vite-workflow Ready Ready Preview, Comment Jul 28, 2026 8:40pm
workflow-docs Ready Ready Preview, Comment, Open in v0 Jul 28, 2026 8:40pm
workflow-swc-playground Ready Ready Preview, Comment Jul 28, 2026 8:40pm
workflow-tarballs Ready Ready Preview, Comment Jul 28, 2026 8:40pm
workflow-web Ready Ready Preview, Comment Jul 28, 2026 8:40pm

@changeset-bot

changeset-bot Bot commented Jul 27, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: e81e530

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

This PR includes changesets to release 21 packages
Name Type
workflow Minor
@workflow/core Minor
@workflow/world-vercel Minor
@workflow/world Minor
@workflow/errors Minor
@workflow/world-testing Patch
@workflow/builders Patch
@workflow/cli Patch
@workflow/next Patch
@workflow/nitro Patch
@workflow/vitest Patch
@workflow/web-shared Patch
@workflow/web Patch
@workflow/world-local Patch
@workflow/world-postgres Patch
@workflow/astro Patch
@workflow/nest Patch
@workflow/nuxt Patch
@workflow/rollup Patch
@workflow/sveltekit Patch
@workflow/vite 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 Jul 27, 2026

Copy link
Copy Markdown
Contributor

📊 Workflow Benchmarks

commit e81e530 · Tue, 28 Jul 2026 20:57:48 GMT · run logs

Backend: vercel · app: nextjs-turbopack

Metric Scenario Best (ms) P75 (ms) P90 (ms) P99 (ms) Samples
TTFS step 1239 (+75%) 🔻 1291 🔴 (+28%) 🔻 1309 🔴 (+29%) 🔻 1411 🔴 (-6.5%) 30
TTFS stream 1227 (+28%) 🔻 1273 🔴 (+29%) 🔻 1289 🔴 (+28%) 🔻 1299 🔴 (+24%) 🔻 30
TTFS hook + stream 1350 (+11%) 1517 🔴 (+18%) 🔻 1551 🔴 (+16%) 🔻 1781 🔴 (+11%) 30
STSO 1020 steps (1-20) 160 (±0%) 270 🔴 (+5.1%) 354 🔴 (+17%) 🔻 531 🔴 (+25%) 🔻 19
STSO 1020 steps (101-120) 177 (-4.3%) 259 🔴 (+5.7%) 313 🔴 (+11%) 363 🔴 (-48%) 💚 19
STSO 1020 steps (1001-1020) 461 (-1.3%) 574 🔴 (+1.6%) 736 🔴 (+21%) 🔻 856 🔴 (+25%) 🔻 19
WO 1020 steps 379345 (-1.7%) 379345 (-1.7%) 379345 (-1.7%) 379345 (-1.7%) 1
SL stream latency 84 (+3.7%) 140 🔴 (+6.1%) 190 🔴 (+36%) 🔻 718 🔴 (+301%) 🔻 30
SO stream overhead (text) 103 (+2.0%) 154 (-14%) 194 (-4.0%) 287 (+15%) 30
SO stream overhead (structured) 101 (+2.0%) 139 (-14%) 159 (-18%) 💚 258 (+19%) 🔻 30
📜 Previous results (7)

55c3746

Tue, 28 Jul 2026 20:20:51 GMT · run logs

vercel / nextjs-turbopack

Metric Scenario Best (ms) P75 (ms) P90 (ms) P99 (ms) Samples
TTFS step 211 (-70%) 💚 815 🔴 (-19%) 💚 856 🔴 (-16%) 💚 2051 🔴 (+36%) 🔻 30
TTFS stream 213 (-78%) 💚 803 🔴 (-19%) 💚 1149 🔴 (+14%) 1829 🔴 (+74%) 🔻 30
TTFS hook + stream 412 (-66%) 💚 1404 🔴 (+8.8%) 1761 🔴 (+32%) 🔻 2189 🔴 (+36%) 🔻 30
STSO 1020 steps (1-20) 157 (-1.9%) 280 🔴 (+8.9%) 444 🔴 (+47%) 🔻 541 🔴 (+28%) 🔻 19
STSO 1020 steps (101-120) 157 (-15%) 💚 262 🔴 (+6.9%) 336 🔴 (+20%) 🔻 365 🔴 (-48%) 💚 19
STSO 1020 steps (1001-1020) 503 (+7.7%) 594 🔴 (+5.1%) 687 🔴 (+13%) 794 🔴 (+16%) 🔻 19
WO 1020 steps 401569 (+4.0%) 401569 (+4.0%) 401569 (+4.0%) 401569 (+4.0%) 1
SL stream latency 99 (+22%) 🔻 487 🔴 (+269%) 🔻 730 🔴 (+421%) 🔻 845 🔴 (+372%) 🔻 30
SO stream overhead (text) 143 (+42%) 🔻 1740 🔴 (+867%) 🔻 3296 🔴 (+1532%) 🔻 3469 🔴 (+1288%) 🔻 30
SO stream overhead (structured) 121 (+22%) 🔻 683 🔴 (+322%) 🔻 1249 🔴 (+541%) 🔻 4445 🔴 (+1948%) 🔻 30

f00518b

Tue, 28 Jul 2026 18:53:10 GMT · run logs

vercel / nextjs-turbopack

Metric Scenario Best (ms) P75 (ms) P90 (ms) P99 (ms) Samples
TTFS step 1246 (+78%) 🔻 1315 🔴 (+21%) 🔻 1353 🔴 (+21%) 🔻 1598 🔴 (+8.9%) 30
TTFS stream 1268 (+32%) 🔻 1309 🔴 (+28%) 🔻 1324 🔴 (+26%) 🔻 1351 🔴 (+7.5%) 30
TTFS hook + stream 800 (+62%) 🔻 1601 🔴 (+19%) 🔻 1793 🔴 (+17%) 🔻 2192 🔴 (+29%) 🔻 30
STSO 1020 steps (1-20) 170 (-3.4%) 289 🔴 (+5.1%) 304 🔴 (-9.3%) 330 🔴 (-1.8%) 19
STSO 1020 steps (101-120) 183 (+4.0%) 241 🔴 (-8.4%) 272 🔴 (-23%) 💚 359 🔴 (-3.2%) 19
STSO 1020 steps (1001-1020) 480 (+3.9%) 535 🔴 (-0.9%) 653 🔴 (+15%) 657 🔴 (-13%) 19
WO 1020 steps 385384 (-0.6%) 385384 (-0.6%) 385384 (-0.6%) 385384 (-0.6%) 1
SL stream latency 85 (+6.3%) 141 🔴 (-6.6%) 168 🔴 (-33%) 💚 221 🔴 (-40%) 💚 30
SO stream overhead (text) 105 (+11%) 131 (-23%) 💚 145 (-55%) 💚 183 (-53%) 💚 30
SO stream overhead (structured) 89 (-6.3%) 156 (-16%) 💚 182 (-22%) 💚 211 (-35%) 💚 30

0dabdf8

Tue, 28 Jul 2026 15:55:44 GMT · run logs

vercel / nextjs-turbopack

Metric Scenario Best (ms) P75 (ms) P90 (ms) P99 (ms) Samples
TTFS step 1022 (+44%) 🔻 1297 🔴 (+25%) 🔻 1302 🔴 (+22%) 🔻 1596 🔴 (+7.8%) 30
TTFS stream 1235 (+28%) 🔻 1286 🔴 (+28%) 🔻 1299 🔴 (+27%) 🔻 1387 🔴 (+33%) 🔻 30
TTFS hook + stream 1474 (+224%) 🔻 1553 🔴 (+20%) 🔻 1587 🔴 (+18%) 🔻 1633 🔴 (-0.6%) 30
STSO 1020 steps (1-20) 173 (-2.3%) 262 🔴 (-6.8%) 329 🔴 (+14%) 346 🔴 (-33%) 💚 19
STSO 1020 steps (101-120) 178 (-3.3%) 246 🔴 (+4.7%) 301 🔴 (+17%) 🔻 307 🔴 (-53%) 💚 19
STSO 1020 steps (1001-1020) 459 (-3.4%) 576 🔴 (+2.1%) 637 🔴 (+8.5%) 655 🔴 (-60%) 💚 19
WO 1020 steps 381544 (-4.2%) 381544 (-4.2%) 381544 (-4.2%) 381544 (-4.2%) 1
SL stream latency 84 (+3.7%) 141 🔴 (±0%) 149 🔴 (-7.5%) 280 🔴 (+16%) 🔻 30
SO stream overhead (text) 98 (-5.8%) 150 (-23%) 💚 172 (-70%) 💚 226 (-85%) 💚 30
SO stream overhead (structured) 99 (-13%) 136 (-25%) 💚 157 (-31%) 💚 371 (-29%) 💚 30

3003c5f

Tue, 28 Jul 2026 02:26:16 GMT · run logs

vercel / nextjs-turbopack

Metric Scenario Best (ms) P75 (ms) P90 (ms) P99 (ms) Samples
TTFS step 1233 (+49%) 🔻 1373 🔴 (+29%) 🔻 1395 🔴 (+29%) 🔻 1415 🔴 (+12%) 30
TTFS stream 1311 (+601%) 🔻 1374 🔴 (+30%) 🔻 1383 🔴 (+27%) 🔻 1401 🔴 (+9.7%) 30
TTFS hook + stream 665 (+44%) 🔻 1559 🔴 (+22%) 🔻 1604 🔴 (+23%) 🔻 1837 🔴 (+37%) 🔻 30
STSO 1020 steps (1-20) 164 (+1.9%) 262 🔴 (-11%) 274 🔴 (-19%) 💚 308 🔴 (-8.9%) 19
STSO 1020 steps (101-120) 179 (+8.5%) 258 🔴 (-0.8%) 269 🔴 (-54%) 💚 331 🔴 (-53%) 💚 19
STSO 1020 steps (1001-1020) 450 (-1.7%) 597 🔴 (+8.5%) 746 🔴 (+24%) 🔻 3605 🔴 (+495%) 🔻 19
WO 1020 steps 372526 (-7.4%) 372526 (-7.4%) 372526 (-7.4%) 372526 (-7.4%) 1
SL stream latency 98 (+13%) 133 🔴 (-1.5%) 153 🔴 (-3.8%) 377 🔴 (-52%) 💚 30
SO stream overhead (text) 101 (-5.6%) 135 (-25%) 💚 138 (-36%) 💚 169 (-45%) 💚 30
SO stream overhead (structured) 90 (-20%) 💚 132 (-20%) 💚 149 (-26%) 💚 187 (-36%) 💚 30

1b0a10c

Tue, 28 Jul 2026 01:38:01 GMT · run logs

vercel / nextjs-turbopack

Metric Scenario Best (ms) P75 (ms) P90 (ms) P99 (ms) Samples
TTFS step 265 (-73%) 💚 1294 🔴 (+17%) 🔻 1307 🔴 (+17%) 🔻 1577 🔴 (+37%) 🔻 30
TTFS stream 1214 (+330%) 🔻 1272 🔴 (+16%) 🔻 1276 🔴 (+13%) 1444 🔴 (+22%) 🔻 30
TTFS hook + stream 472 (-48%) 💚 1540 🔴 (+15%) 🔻 1600 🔴 (+14%) 1961 🔴 (+31%) 🔻 30
STSO 1020 steps (1-20) 162 (-4.7%) 235 🔴 (-16%) 💚 278 🔴 (-14%) 301 🔴 (-18%) 💚 19
STSO 1020 steps (101-120) 174 (±0%) 247 🔴 (-13%) 322 🔴 (+0.9%) 361 🔴 (-51%) 💚 19
STSO 1020 steps (1001-1020) 480 (-2.6%) 613 🔴 (±0%) 891 🔴 (+38%) 🔻 3473 🔴 (+329%) 🔻 19
WO 1020 steps 374484 (-14%) 374484 (-14%) 374484 (-14%) 374484 (-14%) 1
SL stream latency 88 (-5.4%) 135 🔴 (-19%) 💚 144 🔴 (-20%) 💚 154 🔴 (-35%) 💚 30
SO stream overhead (text) 102 (-23%) 💚 137 (-45%) 💚 151 (-61%) 💚 270 (-53%) 💚 30
SO stream overhead (structured) 98 (-20%) 💚 142 (-66%) 💚 159 (-80%) 💚 394 (-89%) 💚 30

5bbc400

Mon, 27 Jul 2026 23:58:27 GMT · run logs

vercel / nextjs-turbopack

Metric Scenario Best (ms) P75 (ms) P90 (ms) P99 (ms) Samples
TTFS step 1311 (+78%) 🔻 1392 🔴 (+23%) 🔻 1423 🔴 (+18%) 🔻 1487 🔴 (+8.8%) 30
TTFS stream 1309 (+317%) 🔻 1404 🔴 (+28%) 🔻 1440 🔴 (+26%) 🔻 1505 🔴 (+28%) 🔻 30
TTFS hook + stream 1564 (+23%) 🔻 1674 🔴 (+19%) 🔻 1774 🔴 (+21%) 🔻 2146 🔴 (+31%) 🔻 30
STSO 1020 steps (1-20) 204 (+20%) 🔻 309 🔴 (+2.3%) 367 🔴 (+4.3%) 378 🔴 (±0%) 19
STSO 1020 steps (101-120) 207 (+5.1%) 277 🔴 (-18%) 💚 304 🔴 (-26%) 💚 322 🔴 (-36%) 💚 19
STSO 1020 steps (1001-1020) 499 (+3.3%) 541 🔴 (-5.7%) 599 🔴 (-7.3%) 653 🔴 (-0.6%) 19
WO 1020 steps 413742 (-5.8%) 413742 (-5.8%) 413742 (-5.8%) 413742 (-5.8%) 1
SL stream latency 119 (+28%) 🔻 185 🔴 (+10%) 224 🔴 (-4.3%) 415 🔴 (+64%) 🔻 30
SO stream overhead (text) 132 (+0.8%) 194 (-33%) 💚 208 (-41%) 💚 313 (-26%) 💚 30
SO stream overhead (structured) 136 (+0.7%) 187 (-28%) 💚 215 (-23%) 💚 274 (-34%) 💚 30

483a5c6

Mon, 27 Jul 2026 23:12:42 GMT · run logs

vercel / nextjs-turbopack

Metric Scenario Best (ms) P75 (ms) P90 (ms) P99 (ms) Samples
TTFS step 1217 (+65%) 🔻 1292 🔴 (+14%) 1344 🔴 (+11%) 1399 🔴 (+2.3%) 30
TTFS stream 440 (+40%) 🔻 1373 🔴 (+25%) 🔻 1381 🔴 (+21%) 🔻 1537 🔴 (+31%) 🔻 30
TTFS hook + stream 1468 (+16%) 🔻 1562 🔴 (+11%) 1584 🔴 (+8.4%) 1839 🔴 (+12%) 30
STSO 1020 steps (1-20) 161 (-5.3%) 241 🔴 (-20%) 💚 335 🔴 (-4.8%) 631 🔴 (+66%) 🔻 19
STSO 1020 steps (101-120) 184 (-6.6%) 253 🔴 (-25%) 💚 316 🔴 (-23%) 💚 416 🔴 (-17%) 💚 19
STSO 1020 steps (1001-1020) 477 (-1.2%) 583 🔴 (+1.6%) 670 🔴 (+3.7%) 684 🔴 (+4.1%) 19
WO 1020 steps 377059 (-14%) 377059 (-14%) 377059 (-14%) 377059 (-14%) 1
SL stream latency 80 (-14%) 131 🔴 (-22%) 💚 140 🔴 (-40%) 💚 182 🔴 (-28%) 💚 30
SO stream overhead (text) 103 (-21%) 💚 147 (-49%) 💚 169 (-52%) 💚 203 (-52%) 💚 30
SO stream overhead (structured) 88 (-35%) 💚 129 (-50%) 💚 142 (-49%) 💚 279 (-32%) 💚 30
ℹ️ Metric definitions & methodology

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 · STSO (1-20) 20/30/60 · STSO (101-120) 30/45/90 · STSO (1001-1020) 40/60/120

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.

@github-actions

github-actions Bot commented Jul 27, 2026

Copy link
Copy Markdown
Contributor

🧪 E2E Test Results

All tests passed

E2E Test Summary

Summary
Passed Failed Skipped Total
✅ ▲ Vercel Production 1455 0 239 1694
✅ 💻 Local Development 1621 0 227 1848
✅ 📦 Local Production 1621 0 227 1848
✅ 🐘 Local Postgres 1621 0 227 1848
✅ 🪟 Windows 154 0 0 154
✅ 📋 Other 1020 0 212 1232
✅ vercel-multi-region 27 0 0 27
Total 7519 0 1132 8651
Details by Category

✅ ▲ Vercel Production

App Passed Failed Skipped
✅ astro 126 0 28
✅ example 126 0 28
✅ express 126 0 28
✅ fastify 126 0 28
✅ hono 126 0 28
✅ nextjs-turbopack 151 0 3
✅ nextjs-webpack 151 0 3
✅ nitro 126 0 28
✅ nuxt 126 0 28
✅ sveltekit 145 0 9
✅ vite 126 0 28

✅ 💻 Local Development

App Passed Failed Skipped
✅ astro-stable 128 0 26
✅ express-stable 128 0 26
✅ fastify-stable 128 0 26
✅ hono-stable 128 0 26
✅ nextjs-turbopack-canary 135 0 19
✅ nextjs-turbopack-stable 154 0 0
✅ nextjs-webpack-canary 135 0 19
✅ nextjs-webpack-stable 154 0 0
✅ nitro-stable 128 0 26
✅ nuxt-stable 128 0 26
✅ sveltekit-stable 147 0 7
✅ vite-stable 128 0 26

✅ 📦 Local Production

App Passed Failed Skipped
✅ astro-stable 128 0 26
✅ express-stable 128 0 26
✅ fastify-stable 128 0 26
✅ hono-stable 128 0 26
✅ nextjs-turbopack-canary 135 0 19
✅ nextjs-turbopack-stable 154 0 0
✅ nextjs-webpack-canary 135 0 19
✅ nextjs-webpack-stable 154 0 0
✅ nitro-stable 128 0 26
✅ nuxt-stable 128 0 26
✅ sveltekit-stable 147 0 7
✅ vite-stable 128 0 26

✅ 🐘 Local Postgres

App Passed Failed Skipped
✅ astro-stable 128 0 26
✅ express-stable 128 0 26
✅ fastify-stable 128 0 26
✅ hono-stable 128 0 26
✅ nextjs-turbopack-canary 135 0 19
✅ nextjs-turbopack-stable 154 0 0
✅ nextjs-webpack-canary 135 0 19
✅ nextjs-webpack-stable 154 0 0
✅ nitro-stable 128 0 26
✅ nuxt-stable 128 0 26
✅ sveltekit-stable 147 0 7
✅ vite-stable 128 0 26

✅ 🪟 Windows

App Passed Failed Skipped
✅ nextjs-turbopack 154 0 0

✅ 📋 Other

App Passed Failed Skipped
✅ e2e-local-dev-nest-stable 128 0 26
✅ e2e-local-dev-tanstack-start- 128 0 26
✅ e2e-local-postgres-nest-stable 128 0 26
✅ e2e-local-postgres-tanstack-start- 128 0 26
✅ e2e-local-prod-nest-stable 128 0 26
✅ e2e-local-prod-tanstack-start- 128 0 26
✅ e2e-vercel-prod-nest 126 0 28
✅ e2e-vercel-prod-tanstack-start 126 0 28

✅ vercel-multi-region

App Passed Failed Skipped
✅ nextjs-turbopack 27 0 0

📋 View full workflow run

@VaguelySerious VaguelySerious added the event-log-race-repro Run the event log race reproduction job label Jul 27, 2026
@VaguelySerious
VaguelySerious force-pushed the peter/event-count-guard branch from 5bbc400 to 1b0a10c Compare July 28, 2026 01:15
VaguelySerious and others added 5 commits July 27, 2026 19:04
…process

A replay-context event creation previously described its snapshot with a
single watermark, which only proves no event landed above it. It cannot
detect a *missing* event below it, so a replay working from a log with a
hole still committed events derived from that hole — and because
correlation IDs are positional ordinals of one seeded sequence, a
one-event difference renames every downstream entity and corrupts the log.

Creations now also send the snapshot's event count and its cursor, and a
rejection restarts the replay inside the same invocation instead of
re-posting the rejected payload (whose IDs the corrected log invalidates)
or paying a queue round trip. A world may attach the missing events to
its 412, in which case the first restart needs no event-log request.

Also guards the suspension `attr_set` write, and re-sorts a merged event
log by event ID when an append arrives out of order.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Also makes the v4 event tests derive their mock origin from the override
like the rest of the file already does, so a non-empty override does not
fail unit tests.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The matching world-vercel guard shipped and is live in production, so the e2e
lanes exercise both halves against the default endpoint.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Resolves the overlap with #3110, which introduced the same event-log merge
consolidation this branch had added as `mergeEvents`: `appendUniqueEvents`
now carries the optional id set from main plus the out-of-order re-sort and
warning, and `mergeEvents` is gone. Main's `withPreconditionRetry` edit drops
out with the function itself.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The event-log merge no longer re-sorts by event id. A World's canonical
order is its own: world-vercel orders by event id, but world-local orders
by (createdAt, eventId) and deliberately re-mints keys (dominant-event and
claim canonicalization) so the two diverge. Re-sorting by event id there
produced an order no ordered load would ever return, reordering a terminal
event ahead of an accepted hook and breaking concurrent hook-token
arbitration.

The merge was only sorting so the snapshot could read its watermark off the
tail, so read the maximum ULID time across the log instead. That removes
the ordering dependency entirely and is exact rather than merely safe:
every loaded event is at or below the maximum, so stateEventCount is still
events.length whatever order the World returned.

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

@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.

The analysis in this PR is the best root-cause writeup I've read in this repo, and the design that follows from it is right. I want to flag one CI signal before it merges, because it bears directly on the headline claim — and to own that the mechanism this replaces is one I approved.

First, the correction to my own review

I approved withPreconditionRetry on #2266/#2946 and backported it on #3079. Your diagnosis that it re-commits a payload whose correlation IDs were minted by the pre-reload replay is correct, and I missed it: I reasoned about wait_completed being a fact (safe to re-commit) and run_completed being excluded, but never followed the same logic through the handleSuspension creates, where the payload's correlation IDs are positional ordinals of the pre-reload seeded sequence. Retrying those in place writes events no correct replay would produce — the retry could manufacture the divergence it was meant to prevent. Deleting it in favour of an in-process replay restart is the right call, and the two-property framing of why a scalar watermark can't work ("a concurrent replay's writes carry no marker at all" + "a watermark cannot represent a hole below itself") is exactly the missing piece.

Blocking: the Event Log Race Repro is red, with the class this PR exists to eliminate

The Event Log Race Repro lane fails on the current head (e81e5305d, run 30397001409). The outcome distribution is what concerns me, measured against main's own most recent run of the same lane (fba26fd9b, ~90 minutes earlier):

run CORRUPTED_EVENT_LOG stuck total failures
main fba26fd9b 0 754 754
this PR e81e5305d 375 66 441

Fewer failures overall, but the corruption class goes 0 → 375 on the branch whose stated purpose is to eliminate it.

I don't think that's necessarily a defect, and I can see at least two readings that would exonerate it:

  • The lane can't exercise the fix. The description says the backend half is live in production; if the preview deployment this lane targets doesn't enforce the count, the guard is inert here and these 375 are the pre-existing corruption class — in which case the lane is currently unable to validate the central claim, and that's worth saying explicitly.
  • Unmasking. main's 754 stuck runs may be failing before they can corrupt; this PR reduces stuck 754 → 66, so runs that previously wedged now proceed far enough to hit corruption that was always latent.

Both are plausible, and distinguishing them needs facts I don't have. What I can rule out: the run isn't stale (it's the current head), and I couldn't find guard activity in the job output — but the runtime's restarting replay in-process warning goes to the deployment's logs, not the runner's, so that's inconclusive rather than negative.

So the ask is narrow: confirm whether the guard engages in that lane, and account for the 375. If it's the inert-backend reading, say so in the description and note that the repro can't gate this change (and ideally what does). If runs are being unmasked, that's fine too — but it should be stated, since "this PR eliminates CORRUPTED_EVENT_LOG" and "its repro shows 375 of them" can't both stand unexplained in the record.

What I verified and think is right

  • Snapshot atomicity. preconditionSnapshotParams emits all three fields or none, and the three-way fail-open (guard off / watermark underivable → {}) means a partial snapshot can never reach a backend. stateEventCount = events.length is sound because the watermark is the max, and the last commit deriving it from the maximum rather than the tail is load-bearing, not cosmetic: with world-local ordering by (createdAt, eventId) the tail need not be the greatest id, and a tail-derived watermark would understate — safe, but it would silently break the "count = events at or below" identity the backend contract depends on. Good that appendUniqueEvents now documents "nothing downstream may assume the tail is the newest".
  • The backend contract doc is the most valuable artifact here. "Count every created event including replay-origin ones", "at or below, never strictly below, never against a total", and above all one-sided safety — anything incomplete/uncomputable/expired must allow the write — is precisely the rule that keeps a fence from becoming a corruption source in its own right, given the client responds to a rejection by discarding its whole replay.
  • The restart. Bounded by WORKFLOW_PRECONDITION_MAX_INPROCESS_RESTARTS (default 3) then falling back to a single immediate re-invocation rather than failing the run; full cursor-less reload with the lexicographic-vs-ULID-time reasoning spelled out; the attached-delta shortcut trusted only on the first restart, so a backend that under-counts costs one wasted restart rather than an unbounded loop. The tests cover each of those, including the bound and the override.
  • attr_set from a suspension is now guarded, setAttributes() from a step body deliberately isn't, with the reason recorded at the call site (no replay snapshot exists to compare) — the right split, and previously a real bypass.
  • Clean removal: no orphaned references to withPreconditionRetry / MutableEventLog / stateUpdatedAtForCreate / PRECONDITION_MAX_RELOAD_RETRIES. Local suites green — core 1632 (+3 pre-existing expected-fails), world-vercel 310. All other CI is green (100 pass).

One question and one follow-up

  1. Sibling creates under clock skew. Every create in a suspension shares one snapshot, and siblings are excluded from the count only because their event ids carry a ULID time above the watermark. If the id-minting clock runs behind a client watermark derived from previously-loaded events, a sibling could land at or below it and trip the fence. The failure direction is safe (reject → restart → re-invoke, never a bad commit), so this is a liveness question, not a correctness one — but is it bounded by anything other than the restart budget?
  2. stable now carries the design this PR corrects. #3079 backported the watermark guard and withPreconditionRetry, so 4.x has both the hole and the retry that can re-commit stale correlation IDs. Whatever the plan is — backport this, or neuter just the retry there — it'd be good to capture it while the reasoning is fresh.

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