Two shoppers each add the last unit to the basket. Only one order may get it, and the other must hear no before they pay, not after. And when the warehouse finds two units broken on the shelf, an order already paid for has to ship in pieces.
Inventory is the design round behind every shop, and it is easy to start wrong with a quantity field and quantity -= 1. The interviewer wants three ideas: stock is a counter pair, not a single number, so "promised" and "physically there" can differ; a reservation is a timed claim on that counter; and an order is a state machine whose transitions are a table, not a pile of ifs. The movie ticket lesson (LLD-05) holds named seats, where each seat is one object. Here units are interchangeable and live in several warehouses, so the new things are allocation across warehouses, partial fulfilment, and what to do when reality drops below the promise.
How the round runs#
"Design the inventory and order system for an online shop with several warehouses. A customer places an order of several items; stock is held while they pay; when paid, the warehouses ship. Never sell what you don't have. 60 minutes."
- 0–8 min — clarify: units or serial items, how long a hold lasts, who picks the warehouse, partial shipments
- 8–20 min —
Stock(two counters),Reservation, and the one invariant that prevents overselling - 20–35 min — reserve, confirm, release and expire in
Inventory; the allocation strategy - 35–48 min — the order state machine and
OrderService - 48–55 min — a scripted run: a rejection, an expiry, a short shelf
- 55–60 min — follow-ups: backorders, safety stock, returns, a thousand concurrent buyers
Step 1: Clarify the requirements#
| You ask | They answer | What it changes |
|---|---|---|
| Are units interchangeable? | Yes, a SKU is a count, not individual items. | Counters, not objects per unit |
| How long do we hold stock for an unpaid order? | A fixed time, say 5 minutes. | expires_at on a reservation, swept on the caller's clock |
| Can one order draw from several warehouses? | Yes, if no single one has enough. | An AllocationStrategy, and several reservations per line |
| What if one line of a multi-line order is out of stock? | Reject the whole order, nothing held. | Plan every line first, then apply; all or nothing |
| What if payment arrives after the hold lapsed? | Don't ship; refund. | confirm can fail, and the order moves to EXPIRED |
| What if the shelf has fewer units than were promised? | It happens: damage, a miscount. Ship what's there. | Partial fulfilment; stock can drop below reserved |
| Concurrency? | Many buyers at once. | One lock around check-and-reserve |
Eight units on hand, six reserved, two buyers want two each
on_hand = 8, reserved = 6. Two requests arrive together, each for 2 units, and each reads "available = 2" before either writes. What happens with no lock, and what is the minimum fix?
One strong answer
Both pass the check, both add 2 to reserved: reserved = 10 against on_hand = 8, and two units are promised that don't exist. The read of available and the write of reserved must be one step: one lock around check-then-reserve, or a conditional update in a database (UPDATE ... SET reserved = reserved + 2 WHERE on_hand - reserved >= 2). Here: one lock inside Inventory.
Step 2: Identify the entities#
Step 3: Class diagram#
%% title: Inventory and orders: the classes
classDiagram
class OrderService {
+place(oid, lines, now) Order
+pay(oid, now) str
+fulfil(oid) Order
+cancel(oid)
}
class Order {
+oid str
+lines dict
+state OrderState
+fire(event)
}
class OrderState {
<<enumeration>>
NEW
RESERVED
PAID
PARTIALLY_SHIPPED
SHIPPED
CANCELLED
EXPIRED
REJECTED
}
class Inventory {
+receive(wh, sku, qty)
+adjust(wh, sku, delta)
+reserve(order_id, lines, now) list
+confirm(order_id, now) bool
+release(order_id)
+ship(order_id) int
+expire(now) int
}
class Stock {
+on_hand int
+reserved int
+available() int
}
class Reservation {
+order_id str
+sku str
+wh str
+qty int
+expires_at int
+state ResState
}
class AllocationStrategy {
<<interface>>
+allocate(free, qty) list
}
OrderService --> Inventory
OrderService "1" *-- "many" Order
Order --> OrderState
Inventory "1" *-- "many" Stock
Inventory "1" *-- "many" Reservation
Inventory --> AllocationStrategy
AllocationStrategy <|.. FewestShipments
AllocationStrategy <|.. NearestFirstThree decisions, three homes. How many can we promise is Inventory's job, behind one lock. From which warehouse is a swappable AllocationStrategy. What an order may do next is a table, TRANSITIONS, which OrderService consults and Inventory knows nothing about. Keep them apart and each is testable alone: the allocator is a pure function of "units free per warehouse", and the state machine needs no stock at all.
The heart of it is Stock: available = on_hand - reserved. Reserving raises reserved and never touches on_hand. Shipping lowers both by the units sent. The two numbers drifting apart is the whole point: reserved is a promise, on_hand is the shelf.
Step 4: Patterns applied#
| Pattern | Where | Why it earns its place |
|---|---|---|
| Strategy | AllocationStrategy |
Ship from the nearest warehouse, or from as few as possible: the cost of shipping changes by season and by contract. Each rule is a class; Inventory is untouched. |
| State (as a table) | TRANSITIONS |
Eight states and ten legal moves. A table is easier to review and test than eight classes; the vending machine lesson shows the full State pattern for when each state has its own behaviour. |
| Facade | OrderService |
Callers talk to one service. Whether stock, payment or shipping sits behind it changes later. |
Step 5: Code#
The interfaces#
# inventory.py, part 1: stock, reservations and the allocation strategy
from __future__ import annotations
import threading
from abc import ABC, abstractmethod
from dataclasses import dataclass
from enum import Enum
class OutOfStock(Exception):
pass
class IllegalTransition(Exception):
pass
class ResState(Enum):
HELD = 1 # counted against stock, expires if the order is not paid in time
CONFIRMED = 2 # paid: no expiry, waiting to ship
RELEASED = 3 # given back to stock
SHIPPED = 4 # fully shipped
@dataclass
class Stock: # one SKU in one warehouse
on_hand: int = 0 # units physically on the shelf
reserved: int = 0 # units promised to orders, not yet shipped
def available(self) -> int:
return max(0, self.on_hand - self.reserved)
@dataclass
class Reservation: # a claim on units of one SKU in one warehouse, for one order
rid: int
order_id: str
sku: str
wh: str
qty: int
expires_at: int # seconds on the caller's clock; ignored once CONFIRMED
state: ResState = ResState.HELD
class AllocationStrategy(ABC):
@abstractmethod
def allocate(self, free: dict[str, int], qty: int) -> list[tuple[str, int]] | None:
"""free: warehouse -> units available. Return [(warehouse, units)] summing to qty, or None."""
class FewestShipments(AllocationStrategy):
def allocate(self, free, qty):
fits = sorted((a, wh) for wh, a in free.items() if a >= qty)
if fits:
return [(fits[0][1], qty)] # best fit: the smallest warehouse that holds it all
plan, need = [], qty
for a, wh in sorted(((a, wh) for wh, a in free.items()), key=lambda x: (-x[0], x[1])):
take = min(a, need)
plan.append((wh, take))
need -= take
if need == 0:
return plan
return None
class NearestFirst(AllocationStrategy):
def __init__(self, order: list[str]) -> None:
self.order = order # warehouses, nearest to the customer first
def allocate(self, free, qty):
plan, need = [], qty
for wh in self.order:
take = min(free.get(wh, 0), need)
if take:
plan.append((wh, take))
need -= take
return plan if need == 0 else None// InventoryDemo.java: stock per warehouse, reservations that expire, and an order state machine
import java.util.ArrayList;
import java.util.Comparator;
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.TreeMap;
import java.util.concurrent.locks.ReentrantLock;
class OutOfStock extends RuntimeException {
OutOfStock(String message) { super(message); }
}
class IllegalTransition extends RuntimeException {
IllegalTransition(String message) { super(message); }
}
enum ResState {
HELD, // counted against stock, expires if the order is not paid in time
CONFIRMED, // paid: no expiry, waiting to ship
RELEASED, // given back to stock
SHIPPED // fully shipped
}
/** One SKU in one warehouse. */
final class Stock {
int onHand; // units physically on the shelf
int reserved; // units promised to orders, not yet shipped
int available() { return Math.max(0, onHand - reserved); }
}
/** A claim on units of one SKU in one warehouse, for one order. */
final class Reservation {
final int rid;
final String orderId, sku, wh;
int qty;
final int expiresAt; // seconds on the caller's clock; ignored once CONFIRMED
ResState state = ResState.HELD;
Reservation(int rid, String orderId, String sku, String wh, int qty, int expiresAt) {
this.rid = rid;
this.orderId = orderId;
this.sku = sku;
this.wh = wh;
this.qty = qty;
this.expiresAt = expiresAt;
}
}
record Take(String wh, int qty) {}
interface AllocationStrategy {
/** free: warehouse -> units available. Returns takes summing to qty, or null. */
List<Take> allocate(Map<String, Integer> free, int qty);
}
final class FewestShipments implements AllocationStrategy {
public List<Take> allocate(Map<String, Integer> free, int qty) {
String fit = null;
for (var e : free.entrySet()) { // best fit: the smallest warehouse that holds it all
if (e.getValue() < qty) continue;
if (fit == null || e.getValue() < free.get(fit)
|| (e.getValue().equals(free.get(fit)) && e.getKey().compareTo(fit) < 0)) fit = e.getKey();
}
if (fit != null) return List.of(new Take(fit, qty));
List<Map.Entry<String, Integer>> byBig = new ArrayList<>(free.entrySet());
byBig.sort(Comparator.<Map.Entry<String, Integer>>comparingInt(e -> -e.getValue()).thenComparing(Map.Entry::getKey));
List<Take> plan = new ArrayList<>();
int need = qty;
for (var e : byBig) {
int take = Math.min(e.getValue(), need);
plan.add(new Take(e.getKey(), take));
need -= take;
if (need == 0) return plan;
}
return null;
}
}
final class NearestFirst implements AllocationStrategy {
private final List<String> order; // warehouses, nearest to the customer first
NearestFirst(List<String> order) { this.order = order; }
public List<Take> allocate(Map<String, Integer> free, int qty) {
List<Take> plan = new ArrayList<>();
int need = qty;
for (String wh : order) {
int take = Math.min(free.getOrDefault(wh, 0), need);
if (take > 0) {
plan.add(new Take(wh, take));
need -= take;
}
}
return need == 0 ? plan : null;
}
}// inventory.cpp: stock per warehouse, reservations that expire, and an order state machine
#include <algorithm>
#include <iostream>
#include <map>
#include <memory>
#include <mutex>
#include <stdexcept>
#include <string>
#include <utility>
#include <vector>
struct OutOfStock : std::runtime_error { using std::runtime_error::runtime_error; };
struct IllegalTransition : std::runtime_error { using std::runtime_error::runtime_error; };
enum class ResState {
Held, // counted against stock, expires if the order is not paid in time
Confirmed, // paid: no expiry, waiting to ship
Released, // given back to stock
Shipped // fully shipped
};
struct Stock { // one SKU in one warehouse
int onHand = 0; // units physically on the shelf
int reserved = 0; // units promised to orders, not yet shipped
int available() const { return std::max(0, onHand - reserved); }
};
struct Reservation { // a claim on units of one SKU in one warehouse, for one order
int rid;
std::string orderId, sku, wh;
int qty;
int expiresAt; // seconds on the caller's clock; ignored once Confirmed
ResState state = ResState::Held;
};
using Take = std::pair<std::string, int>; // (warehouse, units)
class AllocationStrategy {
public:
virtual ~AllocationStrategy() = default;
// free: warehouse -> units available. Returns takes summing to qty; empty when it cannot be met.
virtual std::vector<Take> allocate(const std::map<std::string, int>& free, int qty) const = 0;
};
class FewestShipments : public AllocationStrategy {
public:
std::vector<Take> allocate(const std::map<std::string, int>& free, int qty) const override {
const std::pair<const std::string, int>* fit = nullptr;
for (const auto& e : free) { // best fit: the smallest warehouse that holds it all (map order breaks ties)
if (e.second >= qty && (!fit || e.second < fit->second)) fit = &e;
}
if (fit) return {{fit->first, qty}};
std::vector<Take> byBig(free.begin(), free.end());
std::stable_sort(byBig.begin(), byBig.end(), [](const Take& a, const Take& b) { return a.second > b.second; });
std::vector<Take> plan;
int need = qty;
for (const auto& e : byBig) {
int take = std::min(e.second, need);
plan.push_back({e.first, take});
need -= take;
if (need == 0) return plan;
}
return {};
}
};
class NearestFirst : public AllocationStrategy {
public:
explicit NearestFirst(std::vector<std::string> order) : order_(std::move(order)) {} // nearest to the customer first
std::vector<Take> allocate(const std::map<std::string, int>& free, int qty) const override {
std::vector<Take> plan;
int need = qty;
for (const auto& wh : order_) {
auto it = free.find(wh);
int take = std::min(it == free.end() ? 0 : it->second, need);
if (take > 0) {
plan.push_back({wh, take});
need -= take;
}
}
return need == 0 ? plan : std::vector<Take>{};
}
private:
std::vector<std::string> order_;
};// inventory.js: stock per warehouse, reservations that expire, and an order state machine
class OutOfStock extends Error {}
class IllegalTransition extends Error {}
const ResState = Object.freeze({
HELD: "HELD", // counted against stock, expires if the order is not paid in time
CONFIRMED: "CONFIRMED", // paid: no expiry, waiting to ship
RELEASED: "RELEASED", // given back to stock
SHIPPED: "SHIPPED", // fully shipped
});
class Stock { // one SKU in one warehouse
constructor() {
this.onHand = 0; // units physically on the shelf
this.reserved = 0; // units promised to orders, not yet shipped
}
available() {
return Math.max(0, this.onHand - this.reserved);
}
}
// a claim on units of one SKU in one warehouse, for one order
class Reservation {
constructor(rid, orderId, sku, wh, qty, expiresAt) {
Object.assign(this, { rid, orderId, sku, wh, qty, expiresAt }); // expiresAt: seconds on the caller's clock; ignored once CONFIRMED
this.state = ResState.HELD;
}
}
class AllocationStrategy {
// free: Map warehouse -> units available. Return [[warehouse, units], ...] summing to qty, or null.
allocate(free, qty) { throw new Error("not implemented"); }
}
class FewestShipments extends AllocationStrategy {
allocate(free, qty) {
const fits = [...free].filter(([, a]) => a >= qty).sort((x, y) => x[1] - y[1] || (x[0] < y[0] ? -1 : 1));
if (fits.length) return [[fits[0][0], qty]]; // best fit: the smallest warehouse that holds it all
const plan = [];
let need = qty;
for (const [wh, a] of [...free].sort((x, y) => y[1] - x[1] || (x[0] < y[0] ? -1 : 1))) {
const take = Math.min(a, need);
plan.push([wh, take]);
need -= take;
if (need === 0) return plan;
}
return null;
}
}
class NearestFirst extends AllocationStrategy {
constructor(order) {
super();
this.order = order; // warehouses, nearest to the customer first
}
allocate(free, qty) {
const plan = [];
let need = qty;
for (const wh of this.order) {
const take = Math.min(free.get(wh) ?? 0, need);
if (take) {
plan.push([wh, take]);
need -= take;
}
}
return need === 0 ? plan : null;
}
}Stock.available() is clamped at zero because the shelf can fall below the promises (a stock count finds damage). Reservation.qty is mutable: a partial shipment reduces what is still owed rather than creating a new row. The strategy sees only a map of "units free per warehouse", so it cannot reach into stock or break the invariant.
Inventory: reserve, confirm, ship#
reserve plans every line first and only then touches counters, so a failure on the third line leaves nothing held. A retried request for the same order id returns the existing holds. Expiry is lazy: reserve and confirm start by releasing lapsed holds on the caller's clock, so there is no timer thread and a test can jump time by passing a number.
ship is where reality wins. It ships min(what is owed, what is on the shelf) for each confirmed reservation and leaves the rest owed.
# part 2: the inventory: one lock, reserve-then-confirm, expiry on the caller's clock
class Inventory:
def __init__(self, strategy: AllocationStrategy, ttl: int = 300) -> None:
self.strategy, self.ttl = strategy, ttl
self.stock: dict[tuple[str, str], Stock] = {} # (sku, warehouse) -> Stock
self.holds: dict[str, list[Reservation]] = {} # order id -> its reservations
self._next = 1
self._lock = threading.RLock() # check-then-reserve must be one step
def _at(self, sku: str, wh: str) -> Stock:
return self.stock.setdefault((sku, wh), Stock())
def receive(self, wh: str, sku: str, qty: int) -> None: # a delivery from a supplier
with self._lock:
self._at(sku, wh).on_hand += qty
def adjust(self, wh: str, sku: str, delta: int) -> None: # a stock count: damage, loss, found units
with self._lock:
self._at(sku, wh).on_hand += delta
def available(self, sku: str) -> int:
with self._lock:
return sum(s.available() for (k, _), s in self.stock.items() if k == sku)
def _release(self, r: Reservation) -> None:
self._at(r.sku, r.wh).reserved -= r.qty
r.state = ResState.RELEASED
def expire(self, now: int) -> int: # lazy: called before every reserve and confirm, no timer thread
with self._lock:
lapsed = [r for rs in self.holds.values() for r in rs if r.state is ResState.HELD and r.expires_at <= now]
for r in lapsed:
self._release(r)
return len(lapsed)
def reserve(self, order_id: str, lines: dict[str, int], now: int) -> list[Reservation]:
with self._lock:
old = self.holds.get(order_id)
if old and any(r.state is not ResState.RELEASED for r in old):
return old # a retried request gets the same holds, not new ones
self.expire(now)
plans = [] # plan every line first; touch nothing until all of them fit
for sku, qty in lines.items():
free = {wh: s.available() for (k, wh), s in self.stock.items() if k == sku and s.available() > 0}
plan = self.strategy.allocate(free, qty)
if plan is None:
raise OutOfStock(f"{sku}: wanted {qty}, {sum(free.values())} available")
plans.append((sku, plan))
made = []
for sku, plan in plans:
for wh, qty in plan:
made.append(Reservation(self._next, order_id, sku, wh, qty, now + self.ttl))
self._next += 1
self._at(sku, wh).reserved += qty
self.holds[order_id] = made
return made
def confirm(self, order_id: str, now: int) -> bool: # payment arrived: stop the clock, or say it is too late
with self._lock:
self.expire(now)
rs = self.holds.get(order_id, [])
if not rs or any(r.state is not ResState.HELD for r in rs):
return False
for r in rs:
r.state = ResState.CONFIRMED
return True
def release(self, order_id: str) -> None:
with self._lock:
for r in self.holds.get(order_id, []):
if r.state in (ResState.HELD, ResState.CONFIRMED):
self._release(r)
def ship(self, order_id: str) -> int: # what is physically there, not what was promised; returns units sent
with self._lock:
sent = 0
for r in self.holds.get(order_id, []):
if r.state is not ResState.CONFIRMED:
continue
s = self._at(r.sku, r.wh)
n = min(r.qty, max(0, s.on_hand))
s.on_hand -= n
s.reserved -= n
r.qty -= n
sent += n
if r.qty == 0:
r.state = ResState.SHIPPED
return sent
def pending(self, order_id: str) -> int: # units still owed on this order
with self._lock:
return sum(r.qty for r in self.holds.get(order_id, []) if r.state is ResState.CONFIRMED)/** The inventory: one lock, reserve-then-confirm, expiry on the caller's clock. */
final class Inventory {
final AllocationStrategy strategy;
final int ttl;
final Map<String, Stock> stock = new TreeMap<>(); // "sku@warehouse" -> Stock
final Map<String, List<Reservation>> holds = new HashMap<>(); // order id -> its reservations
private int next = 1;
private final ReentrantLock lock = new ReentrantLock(); // check-then-reserve must be one step
Inventory(AllocationStrategy strategy, int ttl) {
this.strategy = strategy;
this.ttl = ttl;
}
private Stock at(String sku, String wh) { return stock.computeIfAbsent(sku + "@" + wh, k -> new Stock()); }
void receive(String wh, String sku, int qty) { // a delivery from a supplier
lock.lock();
try { at(sku, wh).onHand += qty; } finally { lock.unlock(); }
}
void adjust(String wh, String sku, int delta) { // a stock count: damage, loss, found units
lock.lock();
try { at(sku, wh).onHand += delta; } finally { lock.unlock(); }
}
int available(String sku) {
lock.lock();
try {
int total = 0;
for (var e : stock.entrySet()) if (e.getKey().startsWith(sku + "@")) total += e.getValue().available();
return total;
} finally { lock.unlock(); }
}
private void release(Reservation r) {
at(r.sku, r.wh).reserved -= r.qty;
r.state = ResState.RELEASED;
}
int expire(int now) { // lazy: called before every reserve and confirm, no timer thread
lock.lock();
try {
List<Reservation> lapsed = new ArrayList<>();
for (var rs : holds.values()) {
for (Reservation r : rs) if (r.state == ResState.HELD && r.expiresAt <= now) lapsed.add(r);
}
for (Reservation r : lapsed) release(r);
return lapsed.size();
} finally { lock.unlock(); }
}
List<Reservation> reserve(String orderId, Map<String, Integer> lines, int now) {
lock.lock();
try {
List<Reservation> old = holds.get(orderId);
if (old != null && old.stream().anyMatch(r -> r.state != ResState.RELEASED)) {
return old; // a retried request gets the same holds, not new ones
}
expire(now);
Map<String, List<Take>> plans = new LinkedHashMap<>(); // plan every line first; touch nothing until all fit
for (var line : lines.entrySet()) {
String sku = line.getKey();
Map<String, Integer> free = new HashMap<>();
for (var e : stock.entrySet()) {
if (e.getKey().startsWith(sku + "@") && e.getValue().available() > 0) {
free.put(e.getKey().substring(sku.length() + 1), e.getValue().available());
}
}
List<Take> plan = strategy.allocate(free, line.getValue());
if (plan == null) {
int total = free.values().stream().mapToInt(Integer::intValue).sum();
throw new OutOfStock(sku + ": wanted " + line.getValue() + ", " + total + " available");
}
plans.put(sku, plan);
}
List<Reservation> made = new ArrayList<>();
for (var p : plans.entrySet()) {
for (Take t : p.getValue()) {
made.add(new Reservation(next++, orderId, p.getKey(), t.wh(), t.qty(), now + ttl));
at(p.getKey(), t.wh()).reserved += t.qty();
}
}
holds.put(orderId, made);
return made;
} finally { lock.unlock(); }
}
boolean confirm(String orderId, int now) { // payment arrived: stop the clock, or say it is too late
lock.lock();
try {
expire(now);
List<Reservation> rs = holds.getOrDefault(orderId, List.of());
if (rs.isEmpty() || rs.stream().anyMatch(r -> r.state != ResState.HELD)) return false;
for (Reservation r : rs) r.state = ResState.CONFIRMED;
return true;
} finally { lock.unlock(); }
}
void release(String orderId) {
lock.lock();
try {
for (Reservation r : holds.getOrDefault(orderId, List.of())) {
if (r.state == ResState.HELD || r.state == ResState.CONFIRMED) release(r);
}
} finally { lock.unlock(); }
}
int ship(String orderId) { // what is physically there, not what was promised; returns units sent
lock.lock();
try {
int sent = 0;
for (Reservation r : holds.getOrDefault(orderId, List.of())) {
if (r.state != ResState.CONFIRMED) continue;
Stock s = at(r.sku, r.wh);
int n = Math.min(r.qty, Math.max(0, s.onHand));
s.onHand -= n;
s.reserved -= n;
r.qty -= n;
sent += n;
if (r.qty == 0) r.state = ResState.SHIPPED;
}
return sent;
} finally { lock.unlock(); }
}
int pending(String orderId) { // units still owed on this order
lock.lock();
try {
int owed = 0;
for (Reservation r : holds.getOrDefault(orderId, List.of())) if (r.state == ResState.CONFIRMED) owed += r.qty;
return owed;
} finally { lock.unlock(); }
}
}// The inventory: one lock, reserve-then-confirm, expiry on the caller's clock.
class Inventory {
public:
Inventory(std::unique_ptr<AllocationStrategy> strategy, int ttl) : strategy_(std::move(strategy)), ttl_(ttl) {}
void receive(const std::string& wh, const std::string& sku, int qty) { // a delivery from a supplier
std::lock_guard<std::recursive_mutex> g(lock_);
at(sku, wh).onHand += qty;
}
void adjust(const std::string& wh, const std::string& sku, int delta) { // a stock count: damage, loss, found units
std::lock_guard<std::recursive_mutex> g(lock_);
at(sku, wh).onHand += delta;
}
int available(const std::string& sku) {
std::lock_guard<std::recursive_mutex> g(lock_);
int total = 0;
for (const auto& e : stock) if (e.first.first == sku) total += e.second.available();
return total;
}
int expire(int now) { // lazy: called before every reserve and confirm, no timer thread
std::lock_guard<std::recursive_mutex> g(lock_);
int lapsed = 0;
for (auto& entry : holds) {
for (auto& r : entry.second) {
if (r.state == ResState::Held && r.expiresAt <= now) {
release(r);
lapsed++;
}
}
}
return lapsed;
}
const std::vector<Reservation>& reserve(const std::string& orderId, const std::map<std::string, int>& lines, int now) {
std::lock_guard<std::recursive_mutex> g(lock_);
auto old = holds.find(orderId);
if (old != holds.end() && std::any_of(old->second.begin(), old->second.end(),
[](const Reservation& r) { return r.state != ResState::Released; })) {
return old->second; // a retried request gets the same holds, not new ones
}
expire(now);
std::vector<std::pair<std::string, std::vector<Take>>> plans; // plan every line first; touch nothing until all fit
for (const auto& line : lines) {
std::map<std::string, int> free;
int total = 0;
for (const auto& e : stock) {
if (e.first.first == line.first && e.second.available() > 0) {
free[e.first.second] = e.second.available();
total += e.second.available();
}
}
auto plan = strategy_->allocate(free, line.second);
if (plan.empty()) {
throw OutOfStock(line.first + ": wanted " + std::to_string(line.second) + ", " + std::to_string(total) + " available");
}
plans.push_back({line.first, plan});
}
std::vector<Reservation> made;
for (const auto& p : plans) {
for (const auto& t : p.second) {
made.push_back({next_++, orderId, p.first, t.first, t.second, now + ttl_});
at(p.first, t.first).reserved += t.second;
}
}
return holds[orderId] = made;
}
bool confirm(const std::string& orderId, int now) { // payment arrived: stop the clock, or say it is too late
std::lock_guard<std::recursive_mutex> g(lock_);
expire(now);
auto it = holds.find(orderId);
if (it == holds.end() || it->second.empty()) return false;
for (const auto& r : it->second) if (r.state != ResState::Held) return false;
for (auto& r : it->second) r.state = ResState::Confirmed;
return true;
}
void releaseOrder(const std::string& orderId) {
std::lock_guard<std::recursive_mutex> g(lock_);
for (auto& r : holds[orderId]) {
if (r.state == ResState::Held || r.state == ResState::Confirmed) release(r);
}
}
int ship(const std::string& orderId) { // what is physically there, not what was promised; returns units sent
std::lock_guard<std::recursive_mutex> g(lock_);
int sent = 0;
for (auto& r : holds[orderId]) {
if (r.state != ResState::Confirmed) continue;
Stock& s = at(r.sku, r.wh);
int n = std::min(r.qty, std::max(0, s.onHand));
s.onHand -= n;
s.reserved -= n;
r.qty -= n;
sent += n;
if (r.qty == 0) r.state = ResState::Shipped;
}
return sent;
}
int pending(const std::string& orderId) { // units still owed on this order
std::lock_guard<std::recursive_mutex> g(lock_);
int owed = 0;
for (const auto& r : holds[orderId]) if (r.state == ResState::Confirmed) owed += r.qty;
return owed;
}
std::map<std::pair<std::string, std::string>, Stock> stock; // (sku, warehouse) -> Stock
std::map<std::string, std::vector<Reservation>> holds; // order id -> its reservations
private:
Stock& at(const std::string& sku, const std::string& wh) { return stock[{sku, wh}]; }
void release(Reservation& r) {
at(r.sku, r.wh).reserved -= r.qty;
r.state = ResState::Released;
}
std::unique_ptr<AllocationStrategy> strategy_;
int ttl_;
int next_ = 1;
std::recursive_mutex lock_; // check-then-reserve must be one step
};/** The inventory: reserve-then-confirm, expiry on the caller's clock. (No lock: JS runs one call at a time.) */
class Inventory {
constructor(strategy, ttl = 300) {
this.strategy = strategy;
this.ttl = ttl;
this.stock = new Map(); // "sku@warehouse" -> Stock
this.holds = new Map(); // order id -> its reservations
this.next = 1;
}
#at(sku, wh) {
const key = `${sku}@${wh}`;
if (!this.stock.has(key)) this.stock.set(key, new Stock());
return this.stock.get(key);
}
receive(wh, sku, qty) { this.#at(sku, wh).onHand += qty; } // a delivery from a supplier
adjust(wh, sku, delta) { this.#at(sku, wh).onHand += delta; } // a stock count: damage, loss, found units
available(sku) {
let total = 0;
for (const [key, s] of this.stock) if (key.startsWith(`${sku}@`)) total += s.available();
return total;
}
#release(r) {
this.#at(r.sku, r.wh).reserved -= r.qty;
r.state = ResState.RELEASED;
}
expire(now) { // lazy: called before every reserve and confirm, no timer
const lapsed = [...this.holds.values()].flat().filter((r) => r.state === ResState.HELD && r.expiresAt <= now);
lapsed.forEach((r) => this.#release(r));
return lapsed.length;
}
reserve(orderId, lines, now) {
const old = this.holds.get(orderId);
if (old?.some((r) => r.state !== ResState.RELEASED)) return old; // a retried request gets the same holds, not new ones
this.expire(now);
const plans = []; // plan every line first; touch nothing until all of them fit
for (const [sku, qty] of Object.entries(lines)) {
const free = new Map();
for (const [key, s] of this.stock) {
if (key.startsWith(`${sku}@`) && s.available() > 0) free.set(key.slice(sku.length + 1), s.available());
}
const plan = this.strategy.allocate(free, qty);
if (!plan) {
const total = [...free.values()].reduce((a, b) => a + b, 0);
throw new OutOfStock(`${sku}: wanted ${qty}, ${total} available`);
}
plans.push([sku, plan]);
}
const made = [];
for (const [sku, plan] of plans) {
for (const [wh, qty] of plan) {
made.push(new Reservation(this.next++, orderId, sku, wh, qty, now + this.ttl));
this.#at(sku, wh).reserved += qty;
}
}
this.holds.set(orderId, made);
return made;
}
confirm(orderId, now) { // payment arrived: stop the clock, or say it is too late
this.expire(now);
const rs = this.holds.get(orderId) ?? [];
if (rs.length === 0 || rs.some((r) => r.state !== ResState.HELD)) return false;
rs.forEach((r) => { r.state = ResState.CONFIRMED; });
return true;
}
release(orderId) {
for (const r of this.holds.get(orderId) ?? []) {
if (r.state === ResState.HELD || r.state === ResState.CONFIRMED) this.#release(r);
}
}
ship(orderId) { // what is physically there, not what was promised; returns units sent
let sent = 0;
for (const r of this.holds.get(orderId) ?? []) {
if (r.state !== ResState.CONFIRMED) continue;
const s = this.#at(r.sku, r.wh);
const n = Math.min(r.qty, Math.max(0, s.onHand));
s.onHand -= n;
s.reserved -= n;
r.qty -= n;
sent += n;
if (r.qty === 0) r.state = ResState.SHIPPED;
}
return sent;
}
pending(orderId) { // units still owed on this order
return (this.holds.get(orderId) ?? []).filter((r) => r.state === ResState.CONFIRMED).reduce((a, r) => a + r.qty, 0);
}
}The order: a state machine as a table#
An order is a small automaton. Writing the legal moves as a table means an illegal one (cancel after it shipped, pay twice, ship an expired order) fails in one place, with one message, and a new state is one new row.
NEW→RESERVEDorREJECTED(out of stock, final)RESERVED→PAID,EXPIRED(hold lapsed before payment) orCANCELLEDPAID→PARTIALLY_SHIPPED,SHIPPEDorCANCELLEDPARTIALLY_SHIPPED→ itself (more units go out) orSHIPPED
OrderService.pay is the one place where two clocks meet: payment arrives late, and the hold may already be gone. If confirm fails, the order becomes EXPIRED and the caller is told to refund. A repeated payment callback on a PAID order returns "paid" and changes nothing.
# part 3: the order state machine and the service that drives it
class OrderState(Enum):
NEW = 1
RESERVED = 2
PAID = 3
PARTIALLY_SHIPPED = 4
SHIPPED = 5
CANCELLED = 6
EXPIRED = 7
REJECTED = 8
S = OrderState
TRANSITIONS = { # (state, event) -> next state; anything not listed is illegal
(S.NEW, "reserved"): S.RESERVED,
(S.NEW, "rejected"): S.REJECTED,
(S.RESERVED, "paid"): S.PAID,
(S.RESERVED, "expired"): S.EXPIRED,
(S.RESERVED, "cancel"): S.CANCELLED,
(S.PAID, "ship_some"): S.PARTIALLY_SHIPPED,
(S.PAID, "ship_all"): S.SHIPPED,
(S.PAID, "cancel"): S.CANCELLED,
(S.PARTIALLY_SHIPPED, "ship_some"): S.PARTIALLY_SHIPPED,
(S.PARTIALLY_SHIPPED, "ship_all"): S.SHIPPED,
}
class Order:
def __init__(self, oid: str, lines: dict[str, int]) -> None:
self.oid, self.lines, self.state = oid, lines, S.NEW
def fire(self, event: str) -> None:
nxt = TRANSITIONS.get((self.state, event))
if nxt is None:
raise IllegalTransition(f"{self.oid}: {event} while {self.state.name}")
self.state = nxt
class OrderService:
def __init__(self, inventory: Inventory) -> None:
self.inv = inventory
self.orders: dict[str, Order] = {}
self._lock = threading.RLock()
def place(self, oid: str, lines: dict[str, int], now: int) -> Order:
with self._lock:
order = self.orders.setdefault(oid, Order(oid, lines))
if order.state is not S.NEW:
return order # a retried place() is not a second order
try:
self.inv.reserve(oid, lines, now)
order.fire("reserved")
except OutOfStock:
order.fire("rejected")
raise
return order
def pay(self, oid: str, now: int) -> str:
with self._lock:
order = self.orders[oid]
if order.state is S.PAID:
return "paid" # a retried payment callback: nothing to do
if order.state is S.EXPIRED:
return "refund" # the money arrived after the hold lapsed
if order.state is S.RESERVED and not self.inv.confirm(oid, now):
order.fire("expired")
return "refund"
order.fire("paid")
return "paid"
def fulfil(self, oid: str) -> Order:
with self._lock:
order = self.orders[oid]
if (order.state, "ship_some") not in TRANSITIONS:
raise IllegalTransition(f"{oid}: ship while {order.state.name}")
sent = self.inv.ship(oid)
if self.inv.pending(oid) == 0:
order.fire("ship_all")
elif sent:
order.fire("ship_some")
return order
def cancel(self, oid: str) -> None:
with self._lock:
order = self.orders[oid]
order.fire("cancel")
self.inv.release(oid)enum OrderState { NEW, RESERVED, PAID, PARTIALLY_SHIPPED, SHIPPED, CANCELLED, EXPIRED, REJECTED }
final class Order {
static final Map<String, OrderState> TRANSITIONS = Map.ofEntries( // "STATE/event" -> next state; anything else is illegal
Map.entry("NEW/reserved", OrderState.RESERVED),
Map.entry("NEW/rejected", OrderState.REJECTED),
Map.entry("RESERVED/paid", OrderState.PAID),
Map.entry("RESERVED/expired", OrderState.EXPIRED),
Map.entry("RESERVED/cancel", OrderState.CANCELLED),
Map.entry("PAID/ship_some", OrderState.PARTIALLY_SHIPPED),
Map.entry("PAID/ship_all", OrderState.SHIPPED),
Map.entry("PAID/cancel", OrderState.CANCELLED),
Map.entry("PARTIALLY_SHIPPED/ship_some", OrderState.PARTIALLY_SHIPPED),
Map.entry("PARTIALLY_SHIPPED/ship_all", OrderState.SHIPPED));
final String oid;
final Map<String, Integer> lines;
OrderState state = OrderState.NEW;
Order(String oid, Map<String, Integer> lines) {
this.oid = oid;
this.lines = lines;
}
void fire(String event) {
OrderState nxt = TRANSITIONS.get(state + "/" + event);
if (nxt == null) throw new IllegalTransition(oid + ": " + event + " while " + state);
state = nxt;
}
}
final class OrderService {
final Inventory inv;
final Map<String, Order> orders = new HashMap<>();
private final ReentrantLock lock = new ReentrantLock();
OrderService(Inventory inv) { this.inv = inv; }
Order place(String oid, Map<String, Integer> lines, int now) {
lock.lock();
try {
Order order = orders.computeIfAbsent(oid, k -> new Order(oid, lines));
if (order.state != OrderState.NEW) return order; // a retried place() is not a second order
try {
inv.reserve(oid, lines, now);
order.fire("reserved");
} catch (OutOfStock e) {
order.fire("rejected");
throw e;
}
return order;
} finally { lock.unlock(); }
}
String pay(String oid, int now) {
lock.lock();
try {
Order order = orders.get(oid);
if (order.state == OrderState.PAID) return "paid"; // a retried payment callback: nothing to do
if (order.state == OrderState.EXPIRED) return "refund"; // the money arrived after the hold lapsed
if (order.state == OrderState.RESERVED && !inv.confirm(oid, now)) {
order.fire("expired");
return "refund";
}
order.fire("paid");
return "paid";
} finally { lock.unlock(); }
}
Order fulfil(String oid) {
lock.lock();
try {
Order order = orders.get(oid);
if (!Order.TRANSITIONS.containsKey(order.state + "/ship_some")) {
throw new IllegalTransition(oid + ": ship while " + order.state);
}
int sent = inv.ship(oid);
if (inv.pending(oid) == 0) order.fire("ship_all");
else if (sent > 0) order.fire("ship_some");
return order;
} finally { lock.unlock(); }
}
void cancel(String oid) {
lock.lock();
try {
orders.get(oid).fire("cancel");
inv.release(oid);
} finally { lock.unlock(); }
}
}enum class OrderState { New, Reserved, Paid, PartiallyShipped, Shipped, Cancelled, Expired, Rejected };
std::string name(OrderState s) {
static const char* names[] = {"NEW", "RESERVED", "PAID", "PARTIALLY_SHIPPED", "SHIPPED", "CANCELLED", "EXPIRED", "REJECTED"};
return names[static_cast<int>(s)];
}
using S = OrderState;
const std::map<std::pair<OrderState, std::string>, OrderState> TRANSITIONS = { // (state, event) -> next; anything else is illegal
{{S::New, "reserved"}, S::Reserved},
{{S::New, "rejected"}, S::Rejected},
{{S::Reserved, "paid"}, S::Paid},
{{S::Reserved, "expired"}, S::Expired},
{{S::Reserved, "cancel"}, S::Cancelled},
{{S::Paid, "ship_some"}, S::PartiallyShipped},
{{S::Paid, "ship_all"}, S::Shipped},
{{S::Paid, "cancel"}, S::Cancelled},
{{S::PartiallyShipped, "ship_some"}, S::PartiallyShipped},
{{S::PartiallyShipped, "ship_all"}, S::Shipped},
};
struct Order {
std::string oid;
std::map<std::string, int> lines;
OrderState state = OrderState::New;
void fire(const std::string& event) {
auto it = TRANSITIONS.find({state, event});
if (it == TRANSITIONS.end()) throw IllegalTransition(oid + ": " + event + " while " + name(state));
state = it->second;
}
};
class OrderService {
public:
explicit OrderService(Inventory& inv) : inv_(inv) {}
Order& place(const std::string& oid, const std::map<std::string, int>& lines, int now) {
std::lock_guard<std::recursive_mutex> g(lock_);
Order& order = orders.try_emplace(oid, Order{oid, lines}).first->second;
if (order.state != OrderState::New) return order; // a retried place() is not a second order
try {
inv_.reserve(oid, lines, now);
order.fire("reserved");
} catch (const OutOfStock&) {
order.fire("rejected");
throw;
}
return order;
}
std::string pay(const std::string& oid, int now) {
std::lock_guard<std::recursive_mutex> g(lock_);
Order& order = orders.at(oid);
if (order.state == OrderState::Paid) return "paid"; // a retried payment callback: nothing to do
if (order.state == OrderState::Expired) return "refund"; // the money arrived after the hold lapsed
if (order.state == OrderState::Reserved && !inv_.confirm(oid, now)) {
order.fire("expired");
return "refund";
}
order.fire("paid");
return "paid";
}
Order& fulfil(const std::string& oid) {
std::lock_guard<std::recursive_mutex> g(lock_);
Order& order = orders.at(oid);
if (!TRANSITIONS.count({order.state, "ship_some"})) throw IllegalTransition(oid + ": ship while " + name(order.state));
int sent = inv_.ship(oid);
if (inv_.pending(oid) == 0) order.fire("ship_all");
else if (sent > 0) order.fire("ship_some");
return order;
}
void cancel(const std::string& oid) {
std::lock_guard<std::recursive_mutex> g(lock_);
orders.at(oid).fire("cancel");
inv_.releaseOrder(oid);
}
std::map<std::string, Order> orders;
private:
Inventory& inv_;
std::recursive_mutex lock_;
};const TRANSITIONS = { // "STATE/event" -> next state; anything not listed is illegal
"NEW/reserved": "RESERVED",
"NEW/rejected": "REJECTED",
"RESERVED/paid": "PAID",
"RESERVED/expired": "EXPIRED",
"RESERVED/cancel": "CANCELLED",
"PAID/ship_some": "PARTIALLY_SHIPPED",
"PAID/ship_all": "SHIPPED",
"PAID/cancel": "CANCELLED",
"PARTIALLY_SHIPPED/ship_some": "PARTIALLY_SHIPPED",
"PARTIALLY_SHIPPED/ship_all": "SHIPPED",
};
class Order {
constructor(oid, lines) {
this.oid = oid;
this.lines = lines;
this.state = "NEW";
}
fire(event) {
const next = TRANSITIONS[`${this.state}/${event}`];
if (!next) throw new IllegalTransition(`${this.oid}: ${event} while ${this.state}`);
this.state = next;
}
}
class OrderService {
constructor(inventory) {
this.inv = inventory;
this.orders = new Map();
}
place(oid, lines, now) {
if (!this.orders.has(oid)) this.orders.set(oid, new Order(oid, lines));
const order = this.orders.get(oid);
if (order.state !== "NEW") return order; // a retried place() is not a second order
try {
this.inv.reserve(oid, lines, now);
order.fire("reserved");
} catch (e) {
if (e instanceof OutOfStock) order.fire("rejected");
throw e;
}
return order;
}
pay(oid, now) {
const order = this.orders.get(oid);
if (order.state === "PAID") return "paid"; // a retried payment callback: nothing to do
if (order.state === "EXPIRED") return "refund"; // the money arrived after the hold lapsed
if (order.state === "RESERVED" && !this.inv.confirm(oid, now)) {
order.fire("expired");
return "refund";
}
order.fire("paid");
return "paid";
}
fulfil(oid) {
const order = this.orders.get(oid);
if (!TRANSITIONS[`${order.state}/ship_some`]) throw new IllegalTransition(`${oid}: ship while ${order.state}`);
const sent = this.inv.ship(oid);
if (this.inv.pending(oid) === 0) order.fire("ship_all");
else if (sent > 0) order.fire("ship_some");
return order;
}
cancel(oid) {
this.orders.get(oid).fire("cancel");
this.inv.release(oid);
}
}The code#
Two warehouses, four orders. o1 needs 6 of A when no warehouse has 6, so it is split. o2 is turned away. o3 is never paid and expires. Then a stock count finds damage and o1 ships in two parts.
# part 4: two warehouses, four orders
if __name__ == "__main__":
inv = Inventory(FewestShipments(), ttl=300)
svc = OrderService(inv)
inv.receive("W1", "A", 5)
inv.receive("W2", "A", 3)
inv.receive("W1", "B", 2)
o1 = svc.place("o1", {"A": 6, "B": 1}, now=0)
print("o1", o1.state.name, "[" + ", ".join(f"({r.sku}, {r.wh}, {r.qty})" for r in inv.holds["o1"]) + "]")
try:
svc.place("o2", {"A": 3}, now=10)
except OutOfStock as e:
print("o2 rejected:", e)
svc.place("o3", {"A": 2}, now=20)
print("A available:", inv.available("A"))
print("o1 pay:", svc.pay("o1", now=100), "| o3 pay at 400:", svc.pay("o3", now=400))
print("A available:", inv.available("A"), "| o3", svc.orders["o3"].state.name)
inv.adjust("W1", "A", -2) # a stock count finds two units damaged
print("o1 fulfil 1:", svc.fulfil("o1").state.name, "| owed", inv.pending("o1"))
inv.receive("W1", "A", 4)
print("o1 fulfil 2:", svc.fulfil("o1").state.name, "| owed", inv.pending("o1"))
try:
svc.cancel("o1")
except IllegalTransition as e:
print("cancel refused:", e)
for (sku, wh), s in sorted(inv.stock.items()):
print(f" {sku}@{wh}: on_hand {s.on_hand}, reserved {s.reserved}")public class InventoryDemo {
// two warehouses, four orders
public static void main(String[] args) {
Inventory inv = new Inventory(new FewestShipments(), 300);
OrderService svc = new OrderService(inv);
inv.receive("W1", "A", 5);
inv.receive("W2", "A", 3);
inv.receive("W1", "B", 2);
Map<String, Integer> lines1 = new LinkedHashMap<>();
lines1.put("A", 6);
lines1.put("B", 1);
Order o1 = svc.place("o1", lines1, 0);
StringBuilder held = new StringBuilder();
for (Reservation r : inv.holds.get("o1")) held.append(held.length() > 0 ? ", " : "").append("(" + r.sku + ", " + r.wh + ", " + r.qty + ")");
System.out.println("o1 " + o1.state + " [" + held + "]");
try {
svc.place("o2", Map.of("A", 3), 10);
} catch (OutOfStock e) {
System.out.println("o2 rejected: " + e.getMessage());
}
svc.place("o3", Map.of("A", 2), 20);
System.out.println("A available: " + inv.available("A"));
System.out.println("o1 pay: " + svc.pay("o1", 100) + " | o3 pay at 400: " + svc.pay("o3", 400));
System.out.println("A available: " + inv.available("A") + " | o3 " + svc.orders.get("o3").state);
inv.adjust("W1", "A", -2); // a stock count finds two units damaged
System.out.println("o1 fulfil 1: " + svc.fulfil("o1").state + " | owed " + inv.pending("o1"));
inv.receive("W1", "A", 4);
System.out.println("o1 fulfil 2: " + svc.fulfil("o1").state + " | owed " + inv.pending("o1"));
try {
svc.cancel("o1");
} catch (IllegalTransition e) {
System.out.println("cancel refused: " + e.getMessage());
}
for (var e : inv.stock.entrySet()) {
String[] k = e.getKey().split("@");
System.out.println(" " + k[0] + "@" + k[1] + ": on_hand " + e.getValue().onHand + ", reserved " + e.getValue().reserved);
}
}
}// two warehouses, four orders
int main() {
Inventory inv(std::make_unique<FewestShipments>(), 300);
OrderService svc(inv);
inv.receive("W1", "A", 5);
inv.receive("W2", "A", 3);
inv.receive("W1", "B", 2);
Order& o1 = svc.place("o1", {{"A", 6}, {"B", 1}}, 0);
std::string held;
for (const auto& r : inv.holds["o1"]) held += (held.empty() ? "" : ", ") + ("(" + r.sku + ", " + r.wh + ", " + std::to_string(r.qty) + ")");
std::cout << "o1 " << name(o1.state) << " [" << held << "]\n";
try {
svc.place("o2", {{"A", 3}}, 10);
} catch (const OutOfStock& e) {
std::cout << "o2 rejected: " << e.what() << "\n";
}
svc.place("o3", {{"A", 2}}, 20);
std::cout << "A available: " << inv.available("A") << "\n";
std::string p1 = svc.pay("o1", 100), p3 = svc.pay("o3", 400);
std::cout << "o1 pay: " << p1 << " | o3 pay at 400: " << p3 << "\n";
std::cout << "A available: " << inv.available("A") << " | o3 " << name(svc.orders.at("o3").state) << "\n";
inv.adjust("W1", "A", -2); // a stock count finds two units damaged
std::cout << "o1 fulfil 1: " << name(svc.fulfil("o1").state) << " | owed " << inv.pending("o1") << "\n";
inv.receive("W1", "A", 4);
std::cout << "o1 fulfil 2: " << name(svc.fulfil("o1").state) << " | owed " << inv.pending("o1") << "\n";
try {
svc.cancel("o1");
} catch (const IllegalTransition& e) {
std::cout << "cancel refused: " << e.what() << "\n";
}
for (const auto& e : inv.stock) {
std::cout << " " << e.first.first << "@" << e.first.second << ": on_hand " << e.second.onHand << ", reserved " << e.second.reserved << "\n";
}
}// two warehouses, four orders
const inv = new Inventory(new FewestShipments(), 300);
const svc = new OrderService(inv);
inv.receive("W1", "A", 5);
inv.receive("W2", "A", 3);
inv.receive("W1", "B", 2);
const o1 = svc.place("o1", { A: 6, B: 1 }, 0);
console.log("o1", o1.state, "[" + inv.holds.get("o1").map((r) => `(${r.sku}, ${r.wh}, ${r.qty})`).join(", ") + "]");
try {
svc.place("o2", { A: 3 }, 10);
} catch (e) {
if (!(e instanceof OutOfStock)) throw e;
console.log("o2 rejected:", e.message);
}
svc.place("o3", { A: 2 }, 20);
console.log("A available:", inv.available("A"));
console.log("o1 pay:", svc.pay("o1", 100), "| o3 pay at 400:", svc.pay("o3", 400));
console.log("A available:", inv.available("A"), "| o3", svc.orders.get("o3").state);
inv.adjust("W1", "A", -2); // a stock count finds two units damaged
console.log("o1 fulfil 1:", svc.fulfil("o1").state, "| owed", inv.pending("o1"));
inv.receive("W1", "A", 4);
console.log("o1 fulfil 2:", svc.fulfil("o1").state, "| owed", inv.pending("o1"));
try {
svc.cancel("o1");
} catch (e) {
if (!(e instanceof IllegalTransition)) throw e;
console.log("cancel refused:", e.message);
}
for (const [key, s] of [...inv.stock].sort()) console.log(` ${key}: on_hand ${s.onHand}, reserved ${s.reserved}`);o1 RESERVED [(A, W1, 5), (A, W2, 1), (B, W1, 1)]
o2 rejected: A: wanted 3, 2 available
A available: 0
o1 pay: paid | o3 pay at 400: refund
A available: 2 | o3 EXPIRED
o1 fulfil 1: PARTIALLY_SHIPPED | owed 2
o1 fulfil 2: SHIPPED | owed 0
cancel refused: o1: cancel while SHIPPED
A@W1: on_hand 2, reserved 0
A@W2: on_hand 2, reserved 0
B@W1: on_hand 1, reserved 0o1 took 5 from W1 and 1 from W2 (the largest warehouse first, since none held all six). o2 was refused because only 2 of A were free, and o3 took them, then lost them when payment arrived at 400 s against a hold that ended at 320 s. After adjust(-2) on W1, o1 could ship only 3 of its 5 there, so it went out in part and 2 units stayed owed. When W1 received 4 more, the second fulfil sent them and the order closed. Reserved is back to 0 everywhere: nothing is promised that is not on a shelf.
Step 6: Concurrency and edge cases#
Every read-modify-write on a counter happens under Inventory's lock: reserve, confirm, release, ship, receive and adjust. OrderService has its own lock so one order's state can't change halfway through a method; it takes the inventory lock inside (always in that order, so the two cannot deadlock). The lock is re-entrant because reserve calls expire while it holds it. At real scale one process lock is not enough: the same check becomes a conditional update on the row (WHERE on_hand - reserved >= :qty), and the expiry sweep becomes a job; the invariant is the same.
- Two orders for the last unit: the second plan finds zero available and raises
OutOfStock; nothing is held for it - A multi-line order with one line short: all lines are planned first, so no earlier line stays held
- The same order placed twice (a network retry): the second call returns the same order and the same holds
- Payment after the hold lapsed:
confirmfails, the order isEXPIRED, the caller refunds; the units went back to stock and may already be sold - A payment callback delivered twice: the second sees
PAIDand does nothing - The shelf falls below the promise:
adjustcan makeon_hand < reserved;available()clamps to zero so nothing new is sold, andshipsends what is there - Cancel after shipping: not in the table, so it fails loudly; returning shipped goods is a different flow
- A line of zero or negative units: reject at the edge; the allocator assumes
qty > 0
Step 7: Follow-ups and the interview checklist#
- "Backorders: sell what we don't have yet." Allow a reservation against expected stock:
Stockgetsincoming, and the strategy may allocate fromon_hand + incomingfor products marked backorderable. The order carries an estimated date, andshipalready handles "owed but not on the shelf". - "Keep safety stock."
available()subtracts a per-SKU floor, so the shop stops selling a few units before zero. One line; the invariant is unchanged. - "Cancel the rest of a partly shipped order." Add
(PARTIALLY_SHIPPED, "cancel_rest") → SHIPPED, release the remaining reservations, refund the unshipped lines. Another row in the table and a refund amount computed from what is still owed. - "Returns." A new flow: a return adds units back with
receiveafter inspection, and a refund rule decides the money. Don't try to run it backwards through the order's states. - "A thousand buyers for one flash-sale item." The single lock serialises them correctly but may become the bottleneck; shard the counter (many small
Stockrows summed foravailable) or queue the requests per SKU. Say the trade-off: sharding makes the last few units harder to hand out exactly. - "Several services, not one process." The reservation becomes a row with a TTL in a database,
confirma conditional update, and the expiry a sweeper job. The same hold-then-confirm shape appears in the seat holds of LLD-05.
Interview checklist#
Tick these off before you say done.
- I asked whether units are interchangeable, how long a hold lasts, and whether an order may split across warehouses.
- I kept two counters per SKU per warehouse,
on_handandreserved, and defined available as the difference. - I made check-then-reserve one step under a lock, and said the database form is a conditional update.
- I planned every line of an order before touching a counter, so a short line holds nothing.
- I put warehouse choice behind
AllocationStrategyand named two rules (nearest first, fewest shipments). - I expired holds lazily on a passed-in clock, with no timer thread, and made payment fail cleanly when the hold has lapsed.
- I wrote the order's legal moves as a transition table and made cancel-after-ship an error.
- I made
reserve,placeand the payment callback idempotent, so a retry changes nothing. - I handled the shelf falling below the promise: clamp available at zero, ship what is there, keep the rest owed.
- I said what changes at scale: sharded counters, a row per reservation with a TTL, and a sweeper job.
Common mistakes#
- A single
quantityfield andquantity -= 1at checkout, so nothing can be held, expired or partly shipped - Check and write in two steps, so two buyers pass the check for the last unit
- Reserving line by line and leaving earlier lines held when a later one fails
- A background timer thread for expiry that can't be tested, instead of a time parameter
- Shipping what was promised instead of what is on the shelf, driving
on_handnegative - State as scattered
ifchecks, so cancel-after-ship is allowed by accident - Warehouse choice hard-coded into
reserve
Inventory and order management
Q1/4 A SKU has on_hand = 10 and reserved = 7. What does available() return, and why keep both counters?
Answer & explanation
Answer: B
Reserving raises
reservedonly. Shipping lowers both. Keeping two numbers is what lets you hold stock for an unpaid order and still know what the shelf holds.Q2/4 A payment arrives 100 s after the 300 s hold lapsed. What should the system do?
Answer & explanation
Answer: C
Expired holds are released, so the units are no longer promised to this order. Confirming would sell something that is no longer set aside. The money goes back.
Q3/4 Two lines of an order: line 1 fits, line 2 is short. Why plan both lines before changing any counter?
Answer & explanation
Answer: C
Plan first, apply second: all or nothing. Otherwise you need to remember to undo line 1, and the one path where that is forgotten leaks stock.
Q4/4 The shelf is found two units short after they were promised. What does ship() do?
Answer & explanation
Answer: B
Reality wins over the promise. A partial shipment is a normal state in the table, and the remainder ships when stock is received.
this lesson in shorts · 3
play them all →One idea from this lesson in about a minute each, made for your phone.
New lessons by email
New lessons, side quests and case files as they ship, at most once a week. No spam, unsubscribe anytime.
Discussion · …
Public: everyone reading this lesson sees comments. Be kind and keep it on the material.


