Kavryx Dev
← All products
Financial SystemsPROTOTYPE

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

FeatureWhy it matters
🎯Single-step atomic reservationQuota 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 restockAbandoned carts return to stock within milliseconds via Redis keyspace notifications, with a sorted-set sweeper as the correctness backstop for dropped events.
🛡️Idempotent checkoutA 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 durabilityRedis 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 load1,000 concurrent goroutines against 20 units: exactly 20 win, 980 get OUT_OF_STOCK, 0 quota violations, 0 unexpected errors.

🏗️ System Architecture

Rendering diagram…

Component Breakdown

cmd/reservation_api — the synchronous hot path · :8080

ComponentResponsibility
Reservation Gatewaychi 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 LimitLua-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 SweeperA second sweep instance runs alongside the worker, replaying reservations:expiry every 5 s.

cmd/expiry_worker — TTL compensation

ComponentResponsibility
Expiry ListenerSubscribes 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 SweeperThe 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

ComponentResponsibility
Batch FlusherDebounces 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

StoreRoleConfiguration
Redis 7Authoritative 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 16Audit 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

KeyTypePurpose
item:{sku}:stockStringAvailable units. Decremented only inside Lua.
item:{sku}:user:{user_id}:reservedStringPer-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}StringDurable reservation record. No TTL — survives hold expiry so the ledger always knows the outcome.
hold:{token}StringTTL mirror. Drives the keyspace notification; deleted on commit so no release path can fire.
reservations:expirySorted setExpiry index, score = expiry unix seconds. Written atomically with the reservation.
item:{sku}:tokensSetLive holds, replayed by the flusher.
item:{sku}:terminalSetFinalised holds still awaiting one durable write. Without it, released units would never reach the ledger.

🔄 Reservation Lifecycle

Rendering diagram…

Step by Step

  1. Claim (fused). claimOrReplayScript reads 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.
  2. Guard. Fixed-window rate limiting keyed on the authenticated principal, or the remote address when anonymous.
  3. Reserve (atomic). One EVALSHA performs, in order: argument validation (-2), duplicate-token check (-3), per-user quota check (-1), stock check (0), then DECRBY stock, INCRBY user hold, durable record write, TTL mirror, ZADD expiry index, and SADD token index — returning 1. The response can never precede the deduction.
  4. Respond. 201 Created with the token and expires_at, or a typed 409. 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.
  5. Expire. When hold:{token} times out, the keyspace listener restocks within milliseconds.
  6. Reconcile. The 5 s sweeper independently replays reservations:expiry, so a dropped notification cannot leak a held unit.
  7. Persist. The flusher writes reservation rows and allocated counters to Postgres under an optimistic version guard.

Lua Result Codes

CodeMeaningHTTP
1Granted201
0Out of stock409 OUT_OF_STOCK
-1Per-user quota exceeded409 QUOTA_EXCEEDED
-2Invalid arguments400 INVALID_QUANTITY
-3Duplicate token409 ALREADY_FINAL

🧰 Tech Stack

LayerTechnologyRole
LanguageGo 1.27Runtime; goroutine-per-request, low allocation profile
HTTPnet/http, chi v5Routing, request ID, real-IP, panic recovery, timeouts
Hot-path storeRedis 7 (go-redis/v9)Atomic Lua reservation, keyspace notifications, pooled client
Durable storePostgreSQL 16 (pgx/v5)Audit ledger, optimistic-concurrency versioning
IDsgoogle/uuidReservation tokens
Testingtestify, go test -race25 integration tests against live Redis + Postgres
ToolingDocker Compose, GNU MakePostgres 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

MethodPathPurposeSuccess
POST/api/v1/inventory/reserveAtomically hold stock, return a TTL token201
POST/api/v1/inventory/commitMake the hold permanent (idempotent)200
POST/api/v1/inventory/cancelVoluntary release of a hold200
GET/api/v1/inventory/{sku}Available units + per-user cap200
GET/healthzLiveness200

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).

SuiteCovers
zero_oversell_test.go1,000 goroutines vs 20 units (exactly 20 win); 500 goroutines with mixed 1–5 quantities; 400 workers with concurrent commit/cancel
lua_reservation_test.goAtomic decrement, quota enforcement, invalid quantities, commit permanence, release-once
ttl_auto_release_test.goFull restock after TTL, committed holds survive expiry, sweeper ignores future expiries
write_behind_test.goLedger reconciliation, post-expiry replay, commit under load
load_write_behind_test.goWrite-behind under load without connection-pool exhaustion
gateway_test.goEnd-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:

  1. cmd/expiry_worker/main.go:51 passes 5 as a time.Duration. The parameter is interval 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:65 correctly uses 5*time.Second.
  2. cmd/persistence_sync/main.go never calls RegisterSKU. registerKnownSKUs only counts rows, so initialStock(sku) is 0 for every SKU and Flush skips stock reconciliation via the initial <= 0 branch. The reservation-row path still works, and the tests pass because they register baselines directly.
  3. Event channels are in-process per binary. reservation_api emits DomainEvents into a local channel that the flusher never consumes, so gateway events do not currently reach persistence_sync — which depends on ActiveTokens replay instead. The transport for cross-process delivery is not yet wired.
  4. 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.
  5. gateway.log, gateway.err, and tmp_out.txt are 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.