Add stateUpdatedAt precondition guard to event creation#2266
Add stateUpdatedAt precondition guard to event creation#2266VaguelySerious wants to merge 4 commits into
Conversation
Replay-context event creations now send `stateUpdatedAt` — the ULID time of the latest event the runtime has loaded — so the backend can reject a create whose snapshot predates a newer out-of-band event (a received hook or a completed step) with 412. On a 412 (`PreconditionFailedError`) the runtime reloads the event log to completion from its cursor, retries up to twice, then rethrows so the run falls back to a queue re-invocation with a fresh replay. - @workflow/world: CreateEventParams.stateUpdatedAt - @workflow/errors: PreconditionFailedError (412) - @workflow/world-vercel: send stateUpdatedAt; map 412 -> PreconditionFailedError - @workflow/core: latestEventStateUpdatedAt + withPreconditionRetry helpers; guard wait_completed / run_completed and every suspension-handler create; rethrow exhausted precondition errors to the queue Pairs with the workflow-server change that records the per-run latest-outside-event marker and returns 412. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
🦋 Changeset detectedLatest commit: a7286e4 The changes in this PR will be included in the next version bump. This PR includes changesets to release 22 packages
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 |
📊 Benchmark Results
workflow with no steps💻 Local Development
▲ Production (Vercel)
🔍 Observability: Nitro workflow with 1 step💻 Local Development
▲ Production (Vercel)
🔍 Observability: Nitro workflow with 10 sequential steps💻 Local Development
▲ Production (Vercel)
🔍 Observability: Nitro workflow with 25 sequential steps💻 Local Development
▲ Production (Vercel)
🔍 Observability: Nitro workflow with 50 sequential steps💻 Local Development
▲ Production (Vercel)
🔍 Observability: Nitro Promise.all with 10 concurrent steps💻 Local Development
▲ Production (Vercel)
🔍 Observability: Nitro Promise.all with 25 concurrent steps💻 Local Development
▲ Production (Vercel)
🔍 Observability: Nitro Promise.all with 50 concurrent steps💻 Local Development
▲ Production (Vercel)
🔍 Observability: Nitro Promise.race with 10 concurrent steps💻 Local Development
▲ Production (Vercel)
🔍 Observability: Nitro Promise.race with 25 concurrent steps💻 Local Development
▲ Production (Vercel)
🔍 Observability: Nitro Promise.race with 50 concurrent steps💻 Local Development
▲ Production (Vercel)
🔍 Observability: Nitro workflow with 10 sequential data payload steps (10KB)💻 Local Development
▲ Production (Vercel)
🔍 Observability: Nitro workflow with 25 sequential data payload steps (10KB)💻 Local Development
▲ Production (Vercel)
🔍 Observability: Nitro workflow with 50 sequential data payload steps (10KB)💻 Local Development
▲ Production (Vercel)
🔍 Observability: Nitro workflow with 10 concurrent data payload steps (10KB)💻 Local Development
▲ Production (Vercel)
🔍 Observability: Nitro workflow with 25 concurrent data payload steps (10KB)💻 Local Development
▲ Production (Vercel)
🔍 Observability: Nitro workflow with 50 concurrent data payload steps (10KB)💻 Local Development
▲ Production (Vercel)
🔍 Observability: Nitro Stream Benchmarks (includes TTFB metrics)workflow with stream💻 Local Development
▲ Production (Vercel)
🔍 Observability: Nitro stream pipeline with 5 transform steps (1MB)💻 Local Development
▲ Production (Vercel)
🔍 Observability: Nitro 10 parallel streams (1MB each)💻 Local Development
▲ Production (Vercel)
🔍 Observability: Nitro fan-out fan-in 10 streams (1MB each)💻 Local Development
▲ Production (Vercel)
🔍 Observability: Nitro SummaryFastest Framework by WorldWinner determined by most benchmark wins
Fastest World by FrameworkWinner determined by most benchmark wins
Column Definitions
Worlds:
❌ Some benchmark jobs failed:
Check the workflow run for details. |
🧪 E2E Test Results❌ Some tests failed Summary
❌ Failed Tests▲ Vercel Production (25 failed)astro (2 failed):
example (3 failed):
express (2 failed):
fastify (2 failed):
hono (2 failed):
nextjs-turbopack (2 failed):
nextjs-webpack (2 failed):
nitro (2 failed):
nuxt (2 failed):
sveltekit (3 failed):
vite (3 failed):
📋 Other (2 failed)e2e-vercel-prod-tanstack-start (2 failed):
Details by Category❌ ▲ Vercel Production
✅ 💻 Local Development
✅ 📦 Local Production
✅ 🐘 Local Postgres
✅ 🪟 Windows
❌ 📋 Other
❌ Some E2E test jobs failed:
Check the workflow run for details. |
Temporary — run e2e against the workflow-server peter/event-precondition-guard preview to exercise the 412 guard. Reverted before merge / once the server side merges. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ion-guard # Conflicts: # packages/core/src/runtime/helpers.test.ts
Summary
Adds an optimistic-concurrency guard so a replaying runtime working from a stale event-log snapshot can't advance a run past an event it hasn't seen (e.g. complete the run, or go to sleep, while a hook it should observe just arrived).
Replay-context event creations now send
stateUpdatedAt— the ULID time (epoch ms) of the latest event the runtime has loaded. The backend records the time of the most recent out-of-band event per run and rejects a create whose snapshot predates it with 412. On rejection the runtime:Changes
@workflow/world—CreateEventParams.stateUpdatedAt.@workflow/errors—PreconditionFailedError(HTTP 412).@workflow/world-vercel— sendsstateUpdatedAtin the event body; maps a 412 response toPreconditionFailedError.@workflow/core—latestEventStateUpdatedAt+withPreconditionRetryhelpers; guardswait_completed/run_completedin the replay loop and every create insidehandleSuspension(per-create, so a retry never re-issues an already-created event); rethrows exhausted precondition errors so the queue re-invokes the flow route.Tests
helpers.test.ts—latestEventStateUpdatedAt(prefix-stripping ULID decode) andwithPreconditionRetry(reload+retry, exhaustion rethrow, non-precondition passthrough).world-vercel— 412 →PreconditionFailedErrormapping;stateUpdatedAtis present/absent in the wire body as expected.Server side
Pairs with the backend change that records the per-run latest-outside-event marker (bumped on externally-originated
hook_received/step_completed) and returns 412. Fully backward-compatible: with an older backend,stateUpdatedAtis ignored and the guard is inert.🤖 Generated with Claude Code