Skip to content

[upstream #4930] fix(core): Prevent stock oversell under concurrent checkout - #25

Open
ayim wants to merge 33 commits into
masterfrom
mirror/upstream-4930
Open

[upstream #4930] fix(core): Prevent stock oversell under concurrent checkout#25
ayim wants to merge 33 commits into
masterfrom
mirror/upstream-4930

Conversation

@ayim

@ayim ayim commented Jul 28, 2026

Copy link
Copy Markdown
Owner

Mirrored from vendurehq#4930 for the Overwatch review demo.

Original author: @grolmus


Fixes vendurehq#1738

Problem

Stock can be oversold under concurrency. The saleable-stock check (AddingItems → ArrangingPayment) and stock allocation (ArrangingPayment → PaymentSettled/Authorized) run in different transitions / transactions with unlocked reads. Between them, other orders can deplete stock; the later allocation then oversells — or, with the default multi-location strategy, silently under-allocates a paid order. Additionally, StockLevelService writes were non-atomic read-modify-write with no clamp, allowing lost updates and negative stock values.

Changes

Atomic, clamped stock writes

StockLevelService.updateStockOnHandForLocation / updateStockAllocatedForLocation now perform the read-modify-write under a pessimistic row lock and clamp stockAllocated at ≥ 0 (and never create a StockLevel with a negative value). This removes the lost-update race and prevents negative stock.

Locked re-check at allocation

StockMovementService.createAllocationsForOrderLines acquires a pessimistic_write lock on the variant's StockLevel rows before computing allocation, so concurrent settlements can no longer consume the same units. The default location strategy already caps allocation to availability; the lock makes that re-check race-free (the locking read is also the freshness re-check, MySQL REPEATABLE-READ-safe).

StockShortfallEvent (new)

When allocation cannot fully satisfy a line because stock was depleted since checkout, allocation is capped to what is available and a new StockShortfallEvent (order + per-line requested/allocated) is published. Because the payment may already be captured (async gateways), allocation is not hard-failed — plugins react to the event (refund, backorder, notify).

Notes

  • Allocation timing unchanged; no new config option; backward compatible.
  • SQLite is single-writer and not a concurrency-safe production driver — the lock is a documented no-op there (a concurrent write surfaces as SQLITE_BUSY, not a silent lost update).
  • Known edge (noted in code): a brand-new variant with no StockLevel row yet is not covered by the row lock; concurrent inserts would collide on the unique (productVariantId, stockLocationId) index — a safe rollback, not an oversell.

Testing

  • stock-control.e2e-spec.ts: sequential depletion (order A passes the checkout stock check, order B depletes and settles the stock, order A then settles) → allocation capped, no negative/oversold stock, StockShortfallEvent emitted — with exact-value assertions. Plus atomic/clamp coverage.
  • stock-control and stock-control-multi-location suites pass.

Relates to real-time cart recalculation (vendurehq#3510), handled separately.


View with Codesmith Autofix with Codesmith
Need help on this PR? Tag /codesmith with what you need. Autofix is disabled.

grolmus and others added 30 commits July 7, 2026 13:21
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.

Out of stock items can still be bought when inside an active order

3 participants