Slot event identity (SDK 2/4): claim event slots client-side and reclaim on conflict - #3234
Slot event identity (SDK 2/4): claim event slots client-side and reclaim on conflict#3234VaguelySerious wants to merge 6 commits into
Conversation
On a run that numbers its events by slot, the runtime names each event's own id, which is its claim on that position in the log. The backend inserts the id conditionally, so a 409 proves another writer got there first and that this replay ran against a log missing at least one event. Claims are reserved contiguously off the loaded log rather than all at maxSlot + 1: a suspension flushes its operations concurrently, so without reservation every operation in a flush would propose the same slot and all but one would conflict, on every flush. Operations are built in deterministic replay order, so each one's slot is replay-stable. `withEventCreateFence` picks the run's fence: the event slot for a slot-numbered run, the `stateUpdatedAt` watermark otherwise. The two retry loops stay separate — they differ in what a rejection proves and in what the client does about it, and both are live at once while runs on the older numbering drain, which is what keeps 409s and 412s separately countable during a rollout. Creates that must not retry in place (`run_completed`, the inline `step_started` claims) take a bare fence from `eventCreateFenceFor`, so a rejection escapes to a fresh replay: merged events can change what the workflow body decides, and only a replay from the top can act on them. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
🦋 Changeset detectedLatest commit: 70c8871 The changes in this PR will be included in the next version bump. This PR includes changesets to release 21 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 |
🧪 E2E Test Results❌ Some tests failed ❌ Failed E2E Tests▲ Vercel Production (451 failed)astro (32 failed):
example (33 failed):
express (36 failed):
fastify (39 failed):
hono (45 failed):
nextjs-turbopack (49 failed):
nextjs-webpack (39 failed):
nitro (57 failed):
nuxt (42 failed):
sveltekit (47 failed):
vite (32 failed):
📋 Other (101 failed)e2e-vercel-prod-nest (71 failed):
e2e-vercel-prod-tanstack-start (30 failed):
E2E Test SummarySummary
Details by Category❌ ▲ Vercel Production
✅ 💻 Local Development
✅ 📦 Local Production
✅ 🐘 Local Postgres
✅ 🪟 Windows
❌ 📋 Other
✅ vercel-multi-region
|
A lazy inline step start publishes two events: the World also writes the `step_created` the start deferred. That second event takes the slot immediately below the claim, so the claim has to reserve it — the batch hands out slots synchronously, before any of them land, and a second event numbered off the log as the backend sees it lands on the slot the next start in the batch is holding and costs that start its claim. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Main's in-process replay restart (#3145) replaced the per-create retry loops the slot work was built on. Collapse the slot path onto the same machinery rather than resurrect the old scaffolding: - `isStaleWriteRejection` classifies both rejections that prove a replay read an incomplete log — 412 for the event-log watermark, 409 for a lost slot claim — and drives one restart path for both. - `preconditionEventDelta` reads the delta off either error, refusing a truncated slot-conflict delta so a restart never resumes on a log that is still missing events. - `eventCreateFenceFor` returns whichever fence the run's numbering calls for, so every create site carries one fence instead of two. - The v4 error decoder is CBOR-aware for both the 409 slot conflict and the 412 precondition details.
|
(AI) Superseded by #3305, which combines this stack into a single PR. The correlation-id renumbering that this stack carried is dropped: it was separable from slot event identity, and it dragged in prerequisites of its own (run-scoped queue idempotency keys, run-scoped step keys). Correlation ids stay ULIDs. |
Stacked on #3228. Second of four SDK PRs for slot-based event identity; the paired
world-vercelbackend changes are already deployed on their own stack.What this does
On a run whose spec version numbers events by slot, the runtime names each event's own id —
evnt_plus the zero-padded slot — which is its claim on that position in the log. The backend inserts the id under a uniqueness constraint, so aSlotConflictError(409) proves another writer got there first and, with it, that this replay ran against an event log missing at least one event.The reply to a lost slot is never "send it again": that would lose the same slot again. Each attempt merges the events it was missing — inline off the rejection body, topped up from the backend when the delta was truncated — and claims a fresh slot past them.
Reserving contiguously
Claims come off the loaded log contiguously (
maxSlot + ++reserved) rather than all atmaxSlot + 1. A suspension flushes its operations concurrently, so with a naivemaxSlot + 1every operation in a flush would propose the same slot and all but one would conflict — on every single flush. Operations are built in deterministic replay order, so the slot each one draws is replay-stable, and the fan-out costs no extra round-trips.maxSlotis maintained by scanning merged events rather than reading the array's last element: events are appended without sorting, so after a mid-flight reload the last element is not the max.One fence per run, two loops
withEventCreateFencepicks whichever fence the run uses — its event slot when it numbers events by slot, thestateUpdatedAtwatermark otherwise. The two retry loops stay separate rather than being folded together: they differ in what a rejection proves and in what the client does about it, and both are live at once while runs on the older numbering drain, which is what keeps 409s and 412s separately countable during a rollout.Creates that deliberately do not retry in place —
run_completed, the inlinestep_startedclaims — take a bare fence fromeventCreateFenceForand let a rejection escape to a fresh replay. That is not a giving-up path: merged events can change what the workflow body decides, and only a replay from the top can act on them.stateUpdatedAtForCreatenow takes the run's mode explicitly instead of inferring it. Inference would produce a wrong value rather than none: a padded slot body is valid Crockford base32, so decoding it yields epoch 0 rather than failing, and the client would claim a snapshot older than every event in the log.Notes
SPEC_VERSION_CURRENTis still 5, so no run is stamped with slot identity yet — that happens in the third SDK PR, behind a flag.world-vercelnormalizes its dates into the sameEventshape every other read path produces, keepingSlotConflictError.eventsmean the same thing for every World that raises it.EntityConflictError.Docs
SlotConflictError/v5/docs/api-reference/workflow-errors/slot-conflict-error(Preview base URL from the
workflow-docsrow of thevercel[bot]comment once it lands.)🤖 Generated with Claude Code
Stack: #3228 → #3234 → #3246 → #3247
Paired backend stack (world-vercel side): 6 PRs, all rebased on main and stacked;
WORKFLOW_SERVER_URL_OVERRIDEpoints at the top of it and must be reverted before merge.