[Adapters] Implement EmdashInventoryStore over one inventory document per SKU with embedded holds - #248
Merged
vedanshujain merged 1 commit intoSep 13, 2026
Conversation
… per SKU with embedded holds
The full 13-method InventoryStore port over document storage, on one aggregate
document per SKU with the live holds embedded in it.
The holds live inside the inventory document because an inventory decrement is
not idempotent unless the row records who applied it. So the decrement is ONE
compareAndSet on inventory/{sku} in which the onHand >= qty guard (computed in
JS), the new count and the hold record all commit together — no oversell and
once-only are the same atom.
Reserve is a two-step with exactly ONE crash window: claim reservation_keys/{key}
create-if-absent, carrying the sku, the qty and the minted reservation id → the
inventory compareAndSet → update the key document to its terminal ReserveResult.
The window is "claim written, compareAndSet not yet run", and it is healed rather
than merely tolerated — any replayer finds the claimed document and completes it
deterministically, reusing the RECORDED reservation id instead of minting a
second one, so the decrement happens exactly once and every caller gets the same
answer. A sweeper reaps claims nothing replays. What the embedded aggregate
removes is the SQL adapter's SECOND window (a pending reservation flipped to held
separately from the decrement); the claim window cannot be removed by any
single-document primitive, because the claim and the units live in different
documents by necessity. An OUT_OF_STOCK reserve mints nothing at all.
reservation_index is what pays for the embedding. Six port methods take
reservation ids with no sku, and a hold embedded per SKU cannot be found from an
id alone; the index document is written before the hold, so an id absent from it
is provably unknown — which preserves the port's asymmetry (commitMany throws for
a truly unknown id, adoptMany folds one into lost). Its create-if-absent result is
asserted, so a colliding id is loud rather than silently adopted. It also carries
the reservation's terminal state, because pruning a hold would otherwise erase the
difference between "never existed" and "existed and was released".
The terminal outcome is written to the key document BEFORE the hold is pruned, so
a replay after a prune answers from it instead of looking fresh and decrementing
again. That ORDERING is only observable under fault injection, which belongs to
the race-and-crash tier; the suites here pin its consequence.
The inventory CAS re-reads the key document on any attempt that finds no hold. No
hold is not proof the decrement never happened: a peer completing the same claim
may have created, committed and PRUNED the hold in between, and a committed prune
leaves onHand low with nothing to show for it, so writing a second hold there
would lose units permanently and silently. A terminal key document ends the
attempt with the recorded answer instead.
Every ledger is bounded. adjust/restock/removeStock keep their once-only record in
inventory_movements — one document per key carrying the full intent and then the
recorded answer, which is also what makes a key reused for a different movement
the port's typed rejection. The hot aggregate keeps only a 256-entry ring of
recently applied keys plus one field per hold, so no map on it grows without
limit; the residual that bound leaves is accepted as bounded and written down as a
contract the sweeper must satisfy.
adjust re-derives rather than refusing, as the SQL reference does: a completion
reads the hold's current qty and applies the absolute target against it, and the
claim's recorded fromQty is audit, not a guard. Its only outcomes are the port's
own, and every caller — claim winner or same-key loser — derives its answer from
the durable record, so one key cannot produce two answers.
Cross-SKU work is honest about not being atomic: adopt/adoptMany/commitMany/
releaseAdopted classify every id up front (duplicates collapsed), then apply one
compareAndSet per SKU, each idempotent by reservation id.
Contention is answered with bounded full-jittered retry and a documented ceiling
(CAS_MAX_ATTEMPTS = 12; the depth a writer can lose is bounded by the units on
hand, not the size of the crowd — the race measures 6). Exhaustion throws the
typed retryable StorageContentionError, carrying the last retryable host abort as
its cause, never OUT_OF_STOCK: a shopper who could have bought must not be told
the item is gone. The 503 mapping is a later increment.
Verified: the domain's inventoryStoreContract green on both dialects over real
storage repositories, no adapter-introduced skips, plus the concurrency suite on
the tier that can race — exactly the stocked number of winners on every loop, the
count ending at zero, retry depth strictly inside the ceiling, and a shared-key
burst resolving to one reservation.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011NjdC8awspUte5wML6eY2X
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What
EmdashInventoryStoreimplements all 13 methods of the domain'sInventoryStoreport ontop of
ctx.storage-shaped collections. Inventory is modeled as one document per SKU,carrying
onHandand the live holds map inline, so a decrement and the hold that causedit commit as a single atomic write — no oversell and once-only are the same atom. This is
the third increment of the effort to move the commerce store onto document storage.
Document model
inventory/{sku}— the aggregate document:onHand, the live holds map, and a boundedapplied-movement ring.
reservation_keys/{idempotencyKey}— a durable per-key claim written viacreate-if-absent BEFORE the inventory CAS, so once-only survives hold pruning; the claim
is then marked terminal.
reservation_index/{reservationId}→ sku, written before the hold, which is howid-only port methods find their document and how unknown ids are provable.
inventory_movements/{prefixedKey}— intent-carrying claims with anappliedrecord;the aggregate's own witness is a bounded 256-entry
appliedMovementsring plus aper-hold
lastMovementKey.Semantics worth knowing
replayer and a second, mitigated window closed by an in-step key re-read.
commitManyon an unknown id throws, whileadoptManyfolds it intolost, per thecontract.
adjustre-derives from the hold as stored, matching the SQL reference.StorageContentionErrordistinct fromOUT_OF_STOCK.the sweeper contract for a later increment.
Verification
onHand0, max CAS attempts 6 of 12, 0 contention errorsReview
Two independent reviews over three rounds; round 1 and 2 requested changes on once-only
durability across pruning, adjust replay semantics and ledger growth; round 3 both
approve; an independent verification run passed on both dialects.
🤖 Generated with Claude Code
https://claude.ai/code/session_011NjdC8awspUte5wML6eY2X