ADR-0003. Atomic adjustStock with forceNonNegative guard
| Field | Value |
|---|---|
| Status | Accepted |
| Date | 2026-02-10 |
| Deciders | inventory-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
| Pros | Cons |
|---|---|
| Race-free under arbitrary concurrency | Caller must check null return and handle it |
| Single round-trip to DB | No fine-grained error: just "guard failed" |
| Works under serializable, read-committed, etc. | SQL is harder to read than ORM-level |
| Compatible with PostgreSQL + Drizzle | Cross-database portability nuanced |
Alternatives Considered
| Option | Pros | Cons | Why rejected |
|---|---|---|---|
Optimistic locking (version column + retry) | DB-portable | Retry storms under hot contention | Worse perf for hot SKUs |
Pessimistic SELECT FOR UPDATE then UPDATE | Familiar pattern | 2x round-trips; longer lock holding | Slower; same correctness |
| Application-level mutex (Redis lock) | Cross-DB | Stale lock risk, single-point-of-failure | More 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)