Skip to content

[world] Guard hook_received against a concurrent run termination#2987

Merged
VaguelySerious merged 7 commits into
mainfrom
peter/hook-received-terminal-run-guard
Jul 21, 2026
Merged

[world] Guard hook_received against a concurrent run termination#2987
VaguelySerious merged 7 commits into
mainfrom
peter/hook-received-terminal-run-guard

Conversation

@VaguelySerious

@VaguelySerious VaguelySerious commented Jul 17, 2026

Copy link
Copy Markdown
Member

Summary

hook_received had no branch in the terminal-run guard (it doesn't transition the run or create an entity), so a run reaching a terminal state (run_completed/run_failed/run_cancelled) concurrently with an in-flight hook_received could still get its event appended after the run was already done.

world-local

The filesystem provides no multi-file transaction, so the guard is built around a single atomic operation that decides both facts at once — the event stays invisible to readers until the arbitration has committed:

  • Terminal transitions durably commit a run-terminal marker (.locks/runs/<runId>.terminal), then reap the run's staging directory, both before writing the terminal run state or appending their terminal event.
  • hook_received (1) fast-path rejects if the run is already terminal, (2) stages its event at a non-reader-visible path under .locks, (3) re-checks the marker, (4) promotes the staged file into events/ with an atomic hard link.

The reap's unlink and the promote's link target the same staged file, so the filesystem serializes them and exactly one wins: either the event was reader-visible before the terminal transition proceeded (acceptance happened-before termination), or the promotion fails and the event is never visible to any reader. There is no in-memory lock (cross-process safe) and no rollback of reader-visible state.

world-postgres

hook_received runs inside its own drizzle.transaction, taking a FOR UPDATE lock on the run row before checking terminal status and inserting — the same linearization technique step_started already uses, adapted to guard against a concurrent terminal run transition (whose own UPDATE takes the same row lock).

Legacy runs

Both backends route legacy (pre-event-sourcing) runs through a separate handler that previously bypassed the guard entirely. The same protection now applies there: world-local's legacy run_cancelled commits the marker and reaps before its state write and legacy hook_received uses the same stage → check → promote protocol; world-postgres's legacy hook_received uses the same FOR UPDATE transaction.

Test plan

  • world-local: deterministic tests for each protocol step — fast-path rejection on terminal state, fast-path rejection on a marker committed by another process, rejection in the stage-to-check window, rejection when the reap wins the check-to-promote arbitration (promote returns 'missing'), the accept side (a promoted event survives a subsequent termination), and the transition-side reap of a stalled other-process resume. Each verified to fail with its mechanism removed.
  • world-local legacy: accepts on a live legacy run, rejects on a cancelled legacy run (marker committed by the legacy cancel path), rejects on a marker-only (cross-process, state still running) legacy run. Verified to fail with the legacy guard removed.
  • world-postgres: rejection when the run row is terminal at the FOR UPDATE re-check; legacy accepts-on-live and rejects-on-cancelled. Verified to fail with the guards removed.
  • Full pnpm vitest run suites pass: @workflow/world-local (475) and @workflow/world-postgres (150, via testcontainers).
  • pnpm typecheck and pnpm build pass for both packages.
  • Changeset added (patch bump for both packages).

🤖 Generated with Claude Code

Add a terminal-run status re-check for hook_received at the same
linearization point as its event write, closing the race where the
run reaches a terminal state (run_completed/failed/cancelled) between
hook_received's earlier validation reads and its write.

- world-local: re-read the run status immediately before the event
  publish, under the same in-process hookLocks lock already held for
  hook_received, and throw RunExpiredError if terminal.
- world-postgres: wrap the status check and event insert in a single
  transaction, taking a FOR UPDATE lock on the run row so the insert
  is linearized against a concurrent terminal-transition UPDATE.

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

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

@changeset-bot

changeset-bot Bot commented Jul 17, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 710c9bf

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

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

Copy link
Copy Markdown
Contributor

📊 Workflow Benchmarks

commit 710c9bf · Tue, 21 Jul 2026 20:40:28 GMT · run logs

Backend: vercel · app: nextjs-turbopack

Metric Scenario Best (ms) P75 (ms) P90 (ms) P99 (ms) Samples
TTFS step 181 (+63%) 🔻 369 🔴 (+65%) 🔻 523 🔴 (+29%) 🔻 1777 🔴 (+238%) 🔻 30
TTFS stream 176 (+29%) 🔻 570 🔴 (+97%) 🔻 880 🔴 (+143%) 🔻 4252 🔴 (+430%) 🔻 30
TTFS hook + stream 303 (-14%) 1049 🔴 (+106%) 🔻 3174 🔴 (+451%) 🔻 3793 🔴 (+515%) 🔻 30
STSO 1020 steps (1-20) 193 (+13%) 410 🔴 (+55%) 🔻 478 🔴 (+48%) 🔻 591 🔴 (+62%) 🔻 19
STSO 1020 steps (101-120) 232 (+13%) 482 🔴 (+58%) 🔻 588 🔴 (+72%) 🔻 1818 🔴 (+285%) 🔻 19
STSO 1020 steps (1001-1020) 661 (+13%) 908 🔴 (+27%) 🔻 992 🔴 (+28%) 🔻 1059 🔴 (+29%) 🔻 19
WO 1020 steps 654967 (+24%) 🔻 654967 (+24%) 🔻 654967 (+24%) 🔻 654967 (+24%) 🔻 1
SL stream latency 95 (+25%) 🔻 182 🔴 (+75%) 🔻 197 🔴 (+19%) 🔻 428 🔴 (+77%) 🔻 30
📜 Previous results (4)

b5801f2

Tue, 21 Jul 2026 20:18:21 GMT · run logs

vercel / nextjs-turbopack

Metric Scenario Best (ms) P75 (ms) P90 (ms) P99 (ms) Samples
TTFS step 169 (+52%) 🔻 394 🔴 (+77%) 🔻 563 🔴 (+39%) 🔻 1975 🔴 (+275%) 🔻 30
TTFS stream 157 (+15%) 🔻 368 🔴 (+27%) 🔻 1903 🔴 (+426%) 🔻 4645 🔴 (+478%) 🔻 30
TTFS hook + stream 334 (-4.8%) 592 🔴 (+17%) 🔻 645 🔴 (+12%) 2807 🔴 (+355%) 🔻 30
STSO 1020 steps (1-20) 183 (+7.0%) 343 🔴 (+29%) 🔻 468 🔴 (+45%) 🔻 608 🔴 (+67%) 🔻 19
STSO 1020 steps (101-120) 274 (+33%) 🔻 411 🔴 (+35%) 🔻 641 🔴 (+87%) 🔻 1141 🔴 (+142%) 🔻 19
STSO 1020 steps (1001-1020) 687 (+17%) 🔻 1018 🔴 (+43%) 🔻 1356 🔴 (+75%) 🔻 1666 🔴 (+103%) 🔻 19
WO 1020 steps 655299 (+24%) 🔻 655299 (+24%) 🔻 655299 (+24%) 🔻 655299 (+24%) 🔻 1
SL stream latency 95 (+25%) 🔻 153 🔴 (+47%) 🔻 195 🔴 (+17%) 🔻 458 🔴 (+89%) 🔻 30

91eeac7

Tue, 21 Jul 2026 19:44:27 GMT · run logs

vercel / nextjs-turbopack

Metric Scenario Best (ms) P75 (ms) P90 (ms) P99 (ms) Samples
TTFS step 177 (+59%) 🔻 358 🔴 (+61%) 🔻 382 🔴 (-5.9%) 3786 🔴 (+620%) 🔻 30
TTFS stream 142 (+4.4%) 233 🔴 (-19%) 💚 303 🔴 (-16%) 💚 529 (-34%) 💚 30
TTFS hook + stream 315 (-10%) 450 🔴 (-11%) 585 🔴 (+1.6%) 851 🔴 (+38%) 🔻 30
STSO 1020 steps (1-20) 191 (+12%) 315 🔴 (+19%) 🔻 393 🔴 (+22%) 🔻 401 🔴 (+10%) 19
STSO 1020 steps (101-120) 240 (+17%) 🔻 330 🔴 (+8.2%) 346 🔴 (+1.2%) 370 🔴 (-22%) 💚 19
STSO 1020 steps (1001-1020) 564 (-3.8%) 735 🔴 (+2.9%) 832 🔴 (+7.1%) 875 🔴 (+6.7%) 19
WO 1020 steps 545957 (+3.6%) 545957 (+3.6%) 545957 (+3.6%) 545957 (+3.6%) 1
SL stream latency 86 (+13%) 141 🔴 (+36%) 🔻 185 🔴 (+11%) 258 🔴 (+6.6%) 30

5a4616d

Fri, 17 Jul 2026 18:29:14 GMT · run logs

vercel / nextjs-turbopack

Metric Scenario Best (ms) P75 (ms) P90 (ms) P99 (ms) Samples
TTFS stream 1696 🔴 1774 🔴 2156 🔴 30
TTFS hook + stream 1941 🔴 2040 🔴 2164 🔴 30
STSO 1020 steps (1-20) 374 🔴 478 🔴 775 🔴 19
STSO 1020 steps (101-120) 333 🔴 375 🔴 439 🔴 19
STSO 1020 steps (1001-1020) 755 🔴 886 🔴 1000 🔴 19
WO stream 1696 1774 2156 30
WO hook + stream 1941 2040 2164 30
SL stream 5905 🔴 6003 🔴 6137 🔴 30
SL hook + stream 5802 🔴 5868 🔴 5984 🔴 30

3bbe1f8

Fri, 17 Jul 2026 17:40:02 GMT · run logs

vercel / nextjs-turbopack

Metric Scenario Best (ms) P75 (ms) P90 (ms) P99 (ms) Samples
TTFS stream 1766 🔴 1783 🔴 1815 🔴 30
TTFS hook + stream 1987 🔴 2028 🔴 2152 🔴 30
STSO 1020 steps (1-20) 309 🔴 500 🔴 525 🔴 19
STSO 1020 steps (101-120) 367 🔴 407 🔴 1295 🔴 19
STSO 1020 steps (1001-1020) 781 🔴 889 🔴 1029 🔴 19
WO stream 1766 1783 1815 30
WO hook + stream 1987 2028 2152 30
SL stream 5949 🔴 6013 🔴 6104 🔴 30
SL hook + stream 5819 🔴 5918 🔴 6091 🔴 30

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)

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)

🔴 marks a percentile over its target (within target is left unmarked). 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

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

Copy link
Copy Markdown
Contributor

🧪 E2E Test Results

All tests passed

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 marked this pull request as ready for review July 17, 2026 17:23
@VaguelySerious
VaguelySerious requested review from a team and ijjk as code owners July 17, 2026 17:23
// the terminal-run guard every other event type gets earlier in
// this function (hook_received has no branch there because it
// doesn't transition the run or create an entity).
if (data.eventType === 'hook_received') {

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.

[P1] Serialize this check with the event write

This re-read does not eliminate the race because terminal lifecycle writes are serialized with withRunFileLock, while this check and the later writeExclusive are outside that lock. hook_received can read running, a concurrent run_completed can acquire the run lock and write completed, and then hook_received can still publish its event. Please put the status re-read and event publication in the same withRunFileLock(effectiveRunId, ...) critical section used by terminal transitions. The new test directly makes the run terminal before calling events.create, so it verifies stale-status detection but cannot catch this check-to-write interleaving; a deterministic concurrent test should cover both lock orderings.

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.

Good catch — fixed in 5a4616d. The re-read is now inside withRunFileLock(effectiveRunId, ...), the same critical section run_completed/run_failed/run_cancelled use for their own terminal-transition write (via writeRunUnderLifecycleLock), and the writeExclusive publish happens inside that same lock. So hook_received either waits behind an in-flight terminal transition and observes the committed status, or completes its own check-then-publish before a transition's critical section can begin — no interleaving where both proceed.

Also added a deterministic test that manually occupies the run-file lock and asserts hook_received cannot settle until the hold is released (proving it actually queues on the shared lock, not just that it happens to see a pre-set status), plus a genuine concurrent Promise.allSettled race test that fuzzes both lock orderings.

Address review feedback: re-reading run status outside the shared
withRunFileLock used by run_completed/run_failed/run_cancelled did not
actually close the race, since a concurrent terminal transition could
still acquire that lock and commit between the unlocked re-read and
the event write. Move the check and the writeExclusive publish inside
the same withRunFileLock(effectiveRunId, ...) critical section so the
two are mutually exclusive with a run's terminal transition.

Add a deterministic test that manually occupies the run-file lock and
asserts hook_received cannot settle until the hold releases, proving
it actually queues on the shared lock rather than merely observing a
status set before it started. Also add a genuine concurrent race test
(Promise.allSettled) fuzzing both lock orderings.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…lication

Replaces the publish-then-rollback protocol: a published event file is
immediately visible to events.list() in other processes, so deleting it
after observing the terminal marker cannot undo the observation.

hook_received now stages its event at a non-reader-visible path under
.locks, re-checks the terminal marker, and promotes via atomic hard link
into events/. Terminal transitions write the marker and then reap the
staging directory before writing terminal state — the reap's unlink and
the resume's link race on the same staged file, so the filesystem
atomically picks a single winner and a rejected event is never visible.

Also applies the guard to legacy (pre-event-sourcing) runs, which
previously bypassed it entirely via handleLegacyEvent in both
world-local (same staged protocol) and world-postgres (same FOR UPDATE
transaction as the current-spec path).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@VaguelySerious

Copy link
Copy Markdown
Member Author

(AI) Retrospective on why this PR took three rounds of correctness feedback, and what the root problem was.

The root problem: this guard needs two-object atomicity — "check the run is not terminal" and "append the event" must be decided together — but world-local's only commit primitive is a single-file atomic create (writeExclusive), and that primitive makes a file durable and reader-visible at the same instant. Every rejected iteration tried to derive two-object atomicity from that one-object primitive, and each round patched the specific gap the review pointed at instead of re-deriving the mechanism from the invariant and the primitives' actual guarantees:

  1. Check-then-write, no serialization — plain TOCTOU; the check and the write were separately atomic but not together.
  2. withRunFileLock around both — correct shape, wrong scope: it's a module-level in-memory map, and world-local explicitly supports multiple processes sharing a dataDir.
  3. Durable marker + publish-then-rollback — modeled on the existing hook dispose-marker pattern, but that pattern's cross-process story is a documented narrowing of the window under an in-process lock, not a closure; the adaptation "closed" it by rolling back an already-visible file, which cannot undo an events.list() observation in another process. The stated correctness argument ("nothing else can observe it") only considered this process's cache, not filesystem readers.

The fix that ended the cycle stopped trying to combine two files atomically and instead made visibility itself the linearization point: the event is staged at a non-reader-visible path, and a single atomic filesystem operation — the promote hard-link vs. the terminal transition's reap unlink, both targeting the same staged file — decides its fate. Either the event was visible before the termination proceeded, or it never becomes visible at all. Rejections only ever touch invisible paths, so there is nothing to roll back.

Process takeaway: the invariant ("no hook_received may be accepted after the terminal transition has durably committed, across processes") and the primitive guarantees (single-file atomicity; visibility = durability) should have been written down before round 1. Each mechanism would have been checkable against them on paper — rounds 2 and 3 both fail that check in one sentence. The final round also surfaced that the legacy-run handlers bypassed the guard entirely in both backends, which a scoped invariant statement would have caught earlier as well; both are now covered.

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

The staging/promote protocol addresses the two previous findings, but there is still one local-world ordering race that can put the accepted hook after the terminal event during replay.

// so the reap's unlink and the resume's link race on the SAME
// staged file and the filesystem decides a single winner:
// either the resume's event was already visible before this
// transition proceeded (it legitimately precedes the

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.

[P1] Stamp the terminal event at the arbitration point

The promote-vs-reap protocol decides whether hook_received precedes termination, but replay order is still based on createdAt/eventId, both allocated at createImpl() entry (currently lines 681-682), before this marker/reap block. A terminal call can allocate the older timestamp/ULID and stall here; a later hook_received gets a newer timestamp/ULID and promotes before the marker, legitimately winning the filesystem arbitration. When the terminal call resumes, its event is published afterward, but events.list() sorts the older terminal createdAt first (and uses the event ID as the tie-breaker), so replay observes terminal then hook_received anyway. Please defer the terminal event's timestamp/ID allocation until after marker+reap (or otherwise derive log order from the same linearization point), and cover the case where a terminal invocation pauses after initial allocation while a later hook promotes.

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.

Fixed in b5801f2. You're right — acceptance was linearized but the replay-ordering key wasn't: createdAt/eventId were still allocated at createImpl() entry, so a terminal call that stalled after allocation could append with a key older than a hook that legitimately won the arbitration in the meantime.

Terminal transitions now re-mint eventId + createdAt immediately after marker + reap (mintRunDominantEventKey), choosing a key that strictly dominates every reader-visible event of the run: the timestamp is bumped past the max visible eventId's ULID timestamp when the wall clock hasn't advanced, so the minted ULID compares lexicographically greater than every visible eventId even against another process's random ULID bits (a plain "allocate later" re-stamp would still lose same-millisecond cross-process ties). Since every accepted hook is visible by the end of the reap, the terminal event is guaranteed to sort last under the (createdAt, eventId) comparator.

Covered with the interleaving you described: a test intercepts the terminal marker's writeExclusive (the transition's first durable step, i.e. a stall after entry-time allocation) and runs a full hook_received to completion inside the interception, then asserts the accepted hook replays strictly before run_completed and that the terminal event is last. Verified it fails with the re-stamp removed.

world-postgres needs no change here: it orders by eventId, but terminal events allocate theirs lazily after the conditional run UPDATE (the row-lock linearization point) from the same in-process monotonic factory — the ordering guarantee the hook_received transaction already documents.

Comment thread packages/world-local/src/storage/events-storage.ts
…point

events.list() sorts by (createdAt, eventId), both allocated at
createImpl() entry. A terminal invocation could allocate an older key,
stall before its marker write, lose the promote arbitration to a later
hook_received (legitimately), and then append its terminal event with
the stale key — replaying the accepted hook AFTER the terminal event.

Terminal transitions now re-mint eventId/createdAt after the marker +
reap, choosing a key that strictly dominates every reader-visible event
of the run (bumping the ULID timestamp past the max visible one, so
dominance holds against another process's random ULID bits). All
accepted hooks are visible by the end of the reap, so the terminal
event is guaranteed to replay last.

world-postgres needs no change: terminal events allocate their eventId
lazily after the conditional run UPDATE (the row-lock linearization
point), the same ordering guarantee hook_received's in-transaction
allocation documents.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

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

The replay-key re-mint addresses the previous ordering finding, but the filesystem protocol still has correctness-critical error paths that are silently treated as success.

let entries: string[];
try {
entries = await fs.readdir(dir);
} catch {

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.

[P1] Abort the terminal transition when the reap fails

This catch treats every readdir failure as an absent staging directory, and line 177 similarly ignores every unlink failure. If a hook_received has staged and passed its marker re-check, the terminal writer can commit the marker, hit an EACCES/EMFILE/I/O error here, then continue to publish the terminal state/event while the staged inode remains; when the stalled hook resumes, its hard-link promotion succeeds and becomes visible after termination. These operations are the correctness-critical half of the arbitration, not cleanup. Please ignore only ENOENT (and use the existing Windows retry behavior where applicable), while propagating other readdir/unlink failures so the terminal transition cannot proceed until the reap has actually completed.

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.

Fixed in 710c9bf. The reap now treats only ENOENT as benign: on readdir that means "nothing was ever staged", and on unlink it means the arbitration was already decided for that file (the resume promoted and cleaned up, or a concurrent reaper won). Every other failure propagates and aborts the terminal transition before the state write — the marker write and reap are idempotent, so the transition's retry re-runs them. Unlinks now also go through the shared withWindowsRetry helper for the transient EPERM/EBUSY cases. Only the trailing rmdir of the (now empty) staging directory remains best-effort, since a leftover empty directory has no correctness effect.

Also applied the same tightening to isRunTerminalCommitted's fs.access probe — it had the identical defect, and it is what rejects a resume that staged AFTER the reap passed, so a swallowed EACCES there could equally let an event become visible post-termination.

Tests (POSIX-only via chmod): a staged file plus an unreadable staging directory makes run_completed reject with EACCES before the terminal state write, the staged file survives, and a retry completes the transition and reaps it. Both verified to fail with the swallow-all catches restored.

let files: string[] = [];
try {
files = await fs.readdir(path.join(basedir, 'events'));
} catch {

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.

[P1] Do not silently lose the dominance scan

The new ordering guarantee depends on scanning every visible event, but this catch converts any events/ read failure into an empty log and mints from the wall clock. On a permission, descriptor-exhaustion, or I/O error that key is no longer guaranteed to dominate an already-visible accepted hook, recreating the replay-ordering bug this helper is meant to close. Catch only the expected ENOENT case; other errors need to abort the terminal write.

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.

Fixed in 710c9bf. The scan now swallows only ENOENT ("no events directory yet" — provably nothing to dominate) and propagates every other readdir failure, aborting the terminal transition before any state is written; the retry re-runs the scan. Covered with a chmod-based test (POSIX-only): an unreadable events/ directory makes run_completed reject with EACCES while the run state stays untouched, and a retry completes normally — verified it fails with the swallow-all catch restored.

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

Approving with the expectation that the two outstanding filesystem error-handling P1 threads are addressed before merge.

The reap and the dominance scan are correctness-critical halves of the
arbitration, but swallowed every readdir/unlink failure: an EACCES/
EMFILE/EIO during the reap let the terminal transition proceed with a
staged inode still linkable (the stalled resume's later promote would
become visible after termination), and a failed dominance scan silently
minted a wall-clock key with no ordering guarantee. The run-terminal
marker probe had the same defect, which guards the staged-after-reap
rejection.

All three now treat only ENOENT as the benign "not present" case and
propagate anything else, aborting the terminal transition (or failing
the resume) so a retry re-runs the idempotent step. Reap unlinks go
through the shared Windows retry helper.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@github-actions

Copy link
Copy Markdown
Contributor

Backport PR opened against stable: #3039. Merge conflicts were resolved by AI — please review carefully. (backport job run)

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.

2 participants