Skip to content

ADR-0003. Atomic adjustStock with forceNonNegative guard

FieldValue
StatusAccepted
Date2026-02-10
Decidersinventory-team
Supersedes

Context

  • Sale payment success can deliver to inventory at-least-once.
  • Two clients (e.g. POS + admin) may attempt to deduct stock for the same item simultaneously.
  • A naïve SELECT current; UPDATE current - delta; is a textbook race condition under concurrency.
  • Some items must hard-block oversell (raw materials with allowOversell=false), others must allow it (back-orderable products).

Decision

InventoryStockRepository.adjustStock({ stockId, adjustOnHand, adjustAvailable, adjustReserved, forceNonNegative }) performs the read-and-write in a single SQL UPDATE statement with the guard inline:

sql
UPDATE "InventoryStock"
SET quantity_on_hand = quantity_on_hand + $adjust_on_hand,
    quantity_available = quantity_available + $adjust_available,
    quantity_reserved = quantity_reserved + $adjust_reserved
WHERE id = $stock_id
  AND ($forceNonNegative = false
       OR (quantity_on_hand + $adjust_on_hand >= 0
           AND quantity_available + $adjust_available >= 0
           AND quantity_reserved + $adjust_reserved >= 0))
RETURNING quantity_on_hand, quantity_available, quantity_reserved;

When forceNonNegative=true and any post-adjust quantity would go negative, the UPDATE matches zero rows; the repository returns null. Caller writes OVERSELL_BLOCKED tracking note and skips deduction silently.

Consequences

ProsCons
Race-free under arbitrary concurrencyCaller must check null return and handle it
Single round-trip to DBNo fine-grained error: just "guard failed"
Works under serializable, read-committed, etc.SQL is harder to read than ORM-level
Compatible with PostgreSQL + DrizzleCross-database portability nuanced

Alternatives Considered

OptionProsConsWhy rejected
Optimistic locking (version column + retry)DB-portableRetry storms under hot contentionWorse perf for hot SKUs
Pessimistic SELECT FOR UPDATE then UPDATEFamiliar pattern2x round-trips; longer lock holdingSlower; same correctness
Application-level mutex (Redis lock)Cross-DBStale lock risk, single-point-of-failureMore fragile than DB primitives

References

  • core/src/repositories/inventory/inventory-stock.repository.ts:36-76 (adjustStock)
  • inventory/src/common/constants.ts:8 (InventoryTrackingNotes.OVERSELL_BLOCKED)
  • inventory/src/services/inventory-worker.service.ts (caller pattern)

Proprietary and Confidential. Unauthorized copying, distribution, or use of this software is strictly prohibited.