Flash-Sale Inventory Engine
Atomic stock decrements under flash-sale load: Redis Lua on the hot path, PostgreSQL as a write-behind ledger.
At a glance
- Redis Lua decrement makes oversell impossible under concurrency
- PostgreSQL write-behind ledger reconciles every reservation
- Idempotent request handling for retries and duplicate taps
- Documented failure modes for partial Redis/Postgres availability
- Docker Compose stack with Postgres and Redis
From the repository
README
A zero-oversell reservation engine where stock verification and deduction are inseparably atomic — Redis Lua on the hot path, PostgreSQL on a write-behind ledger.
Go 1.27 · Redis 7 + Lua · PostgreSQL 16 · chi v5 · pgx v5 · zero external services
📖 Overview
On sale day, 10,000 buyers hit the same SKU and 20 units exist. The naive implementation reads stock, decides it is available, and writes it back — and two goroutines both decide they won the last unit.
This engine removes the gap between deciding and deducting. Every mutation
runs inside a single Redis Lua script, so comparison, validation, and write
happen in one atomic step that no concurrent request can interleave with. There
is no SELECT-then-UPDATE window, because there is no SELECT.
The relational database never sits on that path. It is an audit ledger fed by a debounced write-behind worker, so durability is decoupled from latency.
∑ Reserved + ∑ Settled ≤ S_initial (always, under any concurrency)
✨ Key Features
| Feature | Why it matters | |
|---|---|---|
| 🎯 | Single-step atomic reservation | Quota check, stock check, decrement, hold record, and expiry-index insert all run in one EVALSHA. Stock cannot go negative — not probabilistically, structurally. |
| ⏱️ | TTL holds with guaranteed restock | Abandoned carts return to stock within milliseconds via Redis keyspace notifications, with a sorted-set sweeper as the correctness backstop for dropped events. |
| 🛡️ | Idempotent checkout | A Lua-fused claim/replay turns double-clicks and retried payment webhooks into one reservation. Confirmed commits are permanent and cannot be double-charged. |
| ⚡ | Write-behind durability | Redis answers availability; Postgres records the ledger on a 500 ms / 500-row debounce with optimistic version guards. A slow database can never stall a sale. |
| 🧪 | Proven under load | 1,000 concurrent goroutines against 20 units: exactly 20 win, 980 get OUT_OF_STOCK, 0 quota violations, 0 unexpected errors. |
🏗️ System Architecture
Component Breakdown
cmd/reservation_api — the synchronous hot path · :8080
| Component | Responsibility |
|---|---|
| Reservation Gateway | chi router exposing reserve / commit / cancel / stock read. Body size capped at 64 KB, unknown JSON fields rejected, 3 s request timeout, graceful shutdown. |
| Idempotency & Rate Limit | Lua-fused claimOrReplay gives cluster-wide mutual exclusion; completeClaim stores the response and drops the claim together so no reader sees a claim-free state without a cached result. Fixed-window per-client rate limit protects the Redis pool. |
| Fallback Sweeper | A second sweep instance runs alongside the worker, replaying reservations:expiry every 5 s. |
cmd/expiry_worker — TTL compensation
| Component | Responsibility |
|---|---|
| Expiry Listener | Subscribes to __keyevent@0__:expired. On hold:{token} expiry it runs the release script and restocks. Unrelated key families are filtered out, and one malformed payload never kills the stream. |
| Fallback Sweeper | The correctness backstop. Redis drops keyspace events under memory pressure and whenever no subscriber is connected, so the sweeper walks the expiry sorted set — written inside the same atomic unit as the reservation — and releases anything overdue. |
Both call the same re-entrant release script, so an expiry observed twice restocks exactly once.
cmd/persistence_sync — write-behind ledger
| Component | Responsibility |
|---|---|
| Batch Flusher | Debounces events and flushes reservation rows plus per-SKU allocated counters every 500 ms / 500 rows. Reads a freshly-loaded version, and a rejected optimistic guard leaves that SKU dirty for retry. |
Data stores
| Store | Role | Configuration |
|---|---|---|
| Redis 7 | Authoritative for availability. Holds stock counters, per-user quota holds, durable reservation records, TTL mirrors, and the expiry sorted set. | Pool 500 / min-idle 50 · 500 ms R/W timeout · --notify-keyspace-events Ex · AOF on · maxmemory-policy noeviction |
| PostgreSQL 16 | Audit ledger. flash_sales, sale_products (with version for optimistic concurrency), stock_reservations. A CHECK (allocated_stock <= initial_stock) constraint backs the invariant at the durable layer too. | max_connections=300 · pool 20 / min-idle 2 |
Redis Keyspace
| Key | Type | Purpose |
|---|---|---|
item:{sku}:stock | String | Available units. Decremented only inside Lua. |
item:{sku}:user:{user_id}:reserved | String | Per-user held quantity. Its TTL is refreshed on every new hold, so the key outlives the most recent one; it is deleted outright once the count reaches zero. |
reservation:{token} | String | Durable reservation record. No TTL — survives hold expiry so the ledger always knows the outcome. |
hold:{token} | String | TTL mirror. Drives the keyspace notification; deleted on commit so no release path can fire. |
reservations:expiry | Sorted set | Expiry index, score = expiry unix seconds. Written atomically with the reservation. |
item:{sku}:tokens | Set | Live holds, replayed by the flusher. |
item:{sku}:terminal | Set | Finalised holds still awaiting one durable write. Without it, released units would never reach the ledger. |
🔄 Reservation Lifecycle
Step by Step
- Claim (fused).
claimOrReplayScriptreads any cached response and claims the key in one round trip. Two round trips would leave a window where a request reads "no cached response", then claims a key the previous owner just released — and a duplicate click creates a second hold. - Guard. Fixed-window rate limiting keyed on the authenticated principal, or the remote address when anonymous.
- Reserve (atomic). One
EVALSHAperforms, in order: argument validation (-2), duplicate-token check (-3), per-user quota check (-1), stock check (0), thenDECRBYstock,INCRBYuser hold, durable record write, TTL mirror,ZADDexpiry index, andSADDtoken index — returning1. The response can never precede the deduction. - Respond.
201 Createdwith the token andexpires_at, or a typed409. The write-behind event is emitted non-blocking: durability is never a gate on the hot path, so a full queue drops observability, not sales. - Expire. When
hold:{token}times out, the keyspace listener restocks within milliseconds. - Reconcile. The 5 s sweeper independently replays
reservations:expiry, so a dropped notification cannot leak a held unit. - Persist. The flusher writes reservation rows and
allocatedcounters to Postgres under an optimisticversionguard.
Lua Result Codes
| Code | Meaning | HTTP |
|---|---|---|
1 | Granted | 201 |
0 | Out of stock | 409 OUT_OF_STOCK |
-1 | Per-user quota exceeded | 409 QUOTA_EXCEEDED |
-2 | Invalid arguments | 400 INVALID_QUANTITY |
-3 | Duplicate token | 409 ALREADY_FINAL |
🧰 Tech Stack
| Layer | Technology | Role |
|---|---|---|
| Language | Go 1.27 | Runtime; goroutine-per-request, low allocation profile |
| HTTP | net/http, chi v5 | Routing, request ID, real-IP, panic recovery, timeouts |
| Hot-path store | Redis 7 (go-redis/v9) | Atomic Lua reservation, keyspace notifications, pooled client |
| Durable store | PostgreSQL 16 (pgx/v5) | Audit ledger, optimistic-concurrency versioning |
| IDs | google/uuid | Reservation tokens |
| Testing | testify, go test -race | 25 integration tests against live Redis + Postgres |
| Tooling | Docker Compose, GNU Make | Postgres 16 + Redis 7 with health checks |
📁 Project Structure
.
├── cmd/
│ ├── reservation_api/ # HTTP gateway (:8080) — the hot path
│ ├── expiry_worker/ # Keyspace listener + fallback sweeper
│ └── persistence_sync/ # Write-behind daemon → PostgreSQL
│
├── internal/
│ ├── domain/ # Reservation, StockSnapshot, events, invariants
│ ├── redis/ # Client pool, Lua scripts, distributed lock
│ ├── repository/
│ │ ├── inventory_cache.go # Redis-side reservation engine
│ │ └── postgres/ # Product + reservation repos, pgx pool
│ ├── transport/http/ # Handlers, middleware, router
│ └── worker/ # Expiry listener, fallback sweeper, batch flusher
│
├── migrations/ # Schema, applied on Postgres container init
├── simulator/ # flash_sale_hammer.go — concurrent load driver
├── tests/ # 25 integration tests
│
├── docker-compose.yml # postgres:16-alpine + redis:7-alpine
├── Makefile # up / build / test / test-race / simulate
└── go.mod
🚀 Quick Start
Prerequisites
- Go 1.27+
- Docker with Compose v2
1. Clone and start infrastructure
git clone <your-fork-url> flash-sale-inventory-engine
cd flash-sale-inventory-engine
make up
Or directly:
docker compose up -d
docker compose ps
Ports are set in .env — Postgres 55000, Redis 6390 — to avoid
clashing with local installs.
2. Verify the schema loaded
The migration is mounted into the Postgres init directory and runs automatically on first boot.
docker compose exec postgres psql -U flash -d flashsale -c '\dt'
3. Run the three services
In separate terminals (the Makefile requires bash — Git Bash, WSL, or Linux):
make api # gateway on :8080
make expiry # TTL restock worker
make persistence # write-behind → Postgres
Cross-platform equivalents (PowerShell / cmd)
$env:REDIS_ADDR="localhost:6390"
go run ./cmd/reservation_api
$env:REDIS_ADDR="localhost:6390"
go run ./cmd/expiry_worker
$env:REDIS_ADDR="localhost:6390"; $env:PG_DSN="postgres://flash:flash@localhost:55000/flashsale"
go run ./cmd/persistence_sync
4. Exercise the API
# Health
curl http://localhost:8080/healthz
# Seed a sale (via the simulator's seeder, or redis-cli)
docker compose exec redis redis-cli SET item:DEMO-001:stock 20
docker compose exec redis redis-cli SET item:DEMO-001:max_per_user 2
# Reserve
curl -i -X POST http://localhost:8080/api/v1/inventory/reserve \
-H 'Content-Type: application/json' \
-H 'Idempotency-Key: demo-abc' \
-d '{"sku":"DEMO-001","user_id":"user-42","quantity":1}'
# → 201 {"token":"…","expires_at":"…"}
Commit with the returned token, or simply let the 600 s TTL restock it:
curl -i -X POST http://localhost:8080/api/v1/inventory/commit \
-H 'Content-Type: application/json' -d '{"token":"<token>"}'
curl -i -X POST http://localhost:8080/api/v1/inventory/cancel \
-H 'Content-Type: application/json' -d '{"token":"<token>"}'
curl -s http://localhost:8080/api/v1/inventory/DEMO-001
# → {"sku":"DEMO-001","available":19,"max_per_user":2,...}
🔌 API Reference
| Method | Path | Purpose | Success |
|---|---|---|---|
POST | /api/v1/inventory/reserve | Atomically hold stock, return a TTL token | 201 |
POST | /api/v1/inventory/commit | Make the hold permanent (idempotent) | 200 |
POST | /api/v1/inventory/cancel | Voluntary release of a hold | 200 |
GET | /api/v1/inventory/{sku} | Available units + per-user cap | 200 |
GET | /healthz | Liveness | 200 |
Idempotency — send Idempotency-Key, or rely on the compound fallback:
X-User-Id + X-SKU for reserve, X-Reservation-Token for commit/cancel.
A rejected claim returns 409 with Retry-After; a completed retry replays the
original response with Idempotent-Replay: true.
🧪 Testing
make test # go test -v ./...
make test-race # containerised -race run
The suite runs against live Redis and PostgreSQL, not mocks — it starts its own keyspace-notification subscribers and asserts on real reconciled rows.
Current status: 25/25 passing (go1.27.0 windows/amd64, ~17 s).
| Suite | Covers |
|---|---|
zero_oversell_test.go | 1,000 goroutines vs 20 units (exactly 20 win); 500 goroutines with mixed 1–5 quantities; 400 workers with concurrent commit/cancel |
lua_reservation_test.go | Atomic decrement, quota enforcement, invalid quantities, commit permanence, release-once |
ttl_auto_release_test.go | Full restock after TTL, committed holds survive expiry, sweeper ignores future expiries |
write_behind_test.go | Ledger reconciliation, post-expiry replay, commit under load |
load_write_behind_test.go | Write-behind under load without connection-pool exhaustion |
gateway_test.go | End-to-end reserve/commit/cancel, 409 paths, idempotent replay, concurrent-duplicate collapse, unknown-field rejection |
Load simulation
make simulate
# 1,000 requests · 200 concurrent · 20 units · SKU-001
Prints throughput, p50/p95/p99 latency, and exits non-zero if a single unit is oversold.
⚠️ Known Limitations
These are real gaps found while auditing the code — documented rather than quietly papered over:
cmd/expiry_worker/main.go:51passes5as atime.Duration. The parameter isinterval time.Duration, so the untyped constant becomes 5 nanoseconds, not 5 seconds, and the sweeper spins as fast as the ticker allows.cmd/reservation_api/main.go:65correctly uses5*time.Second.cmd/persistence_sync/main.gonever callsRegisterSKU.registerKnownSKUsonly counts rows, soinitialStock(sku)is0for every SKU andFlushskips stock reconciliation via theinitial <= 0branch. The reservation-row path still works, and the tests pass because they register baselines directly.- Event channels are in-process per binary.
reservation_apiemitsDomainEvents into a local channel that the flusher never consumes, so gateway events do not currently reachpersistence_sync— which depends onActiveTokensreplay instead. The transport for cross-process delivery is not yet wired. - The project is not yet a git repository, so Archify diagrams could not be pinned to a commit revision and were authored as native Mermaid instead.
gateway.log,gateway.err, andtmp_out.txtare stray runtime artifacts and should be gitignored.
💡 Design Notes
Why Lua, and why one script?
GET then DECRBY is two operations and a race. A Lua script is executed to
completion by Redis before any other command runs, so the read and the write
cannot be separated. Extending the script is cheap — the current one does eight
things in one atomic step.
Why a hold: mirror key?
The durable record must outlive the TTL so the ledger knows what happened. But
Redis only notifies on key expiry. A separate hold:{token} key with a TTL gives
notifications a clean trigger while reservation:{token} persists forever.
Committing deletes the mirror and its expiry-index entry, so no release path
can fire for a paid unit.
Why both a listener and a sweeper? Redis keyspace notifications are best-effort by design — they are dropped under memory pressure and whenever no subscriber is connected. Treating them as the source of correctness would mean silently leaking stock. The sweeper walks an index written inside the same atomic step, so every hold is discoverable regardless of delivery. The listener is the fast path; the sweeper is the guarantee.
Why does the flusher keep failed SKUs dirty? A partial Redis read must never shrink the ledger. Rather than writing a short batch, the SKU stays dirty and the next tick replays the entire token index — correctness over throughput.
Why optimistic versioning instead of a lock?
sale_products.version means the flusher never blocks a writer; it detects that
another writer moved first and retries. No distributed lock, no lock expiry
problem.
Built with Go, Redis and Postgres. Diagrams render natively on GitHub via Mermaid.js.