Skip to content

Repository files navigation

⚡ FlashX

High-Concurrency Flash Sale & Inventory Engine

Selling 20 units to 20,000 people in the same second — without ever overselling one.

Java Spring Boot Next.js React

Redis RabbitMQ PostgreSQL Docker TypeScript Tailwind

Progress Oversell Tests Services

🚀 Quickstart · 🏗 Architecture · 🔬 The Core Idea · 📡 API · 🎛 Admin Console · 🧯 Troubleshooting · 📄 Full Report


🎯 The Problem

An online store built around one hard problem: a flash sale drops 20 units, and 20,000 people click Buy inside the same second.

The textbook approach — a SELECT … FOR UPDATE row lock inside a transaction — is correct, and it is also a throughput cliff:

🐢 Naive SELECT … FOR UPDATE ⚡ FlashX
Where stock is decided PostgreSQL row lock Redis, single atomic Lua script
Concurrency shape Every request serialises behind one lock Single-threaded Redis, no lock contention
Connection pool Exhausted under load Never touched on the hot path
Request latency Grows with queue depth Bounded by one Redis round trip
DB write Inside the request Async, via RabbitMQ
Response 200 OK after commit 202 Accepted + correlation id

FlashX moves the contended decision out of the database. Inventory is reserved by a single atomic Lua script in Redis; the order is persisted to PostgreSQL asynchronously through RabbitMQ. The customer waits only for the Redis reservation.

📄 Read the full engineering report → — architecture decisions, failure modes, verification results, and every environment gotcha found the hard way.


🚀 Quickstart (one command)

The entire nine-process stack — 3 backing services, 5 Spring Boot apps, 1 Next.js server — comes up from the repository root:

docker compose up -d --build

Then open http://localhost:3000. First boot takes ~2 minutes: Flyway creates the auth, catalog, and orders schemas, seeds six products, and catalog-service pre-warms the Redis counters automatically.

🔗 WhatWhereCredentials
🛍 Storefrontlocalhost:3000register any account
🎛 Admin consolelocalhost:3000/adminadmin@flashx.local / flashx-admin
🗂 Eureka registrylocalhost:8761
🐰 RabbitMQ UIlocalhost:15672flashx / flashx
🚪 API gatewaylocalhost:8080JWT bearer
docker compose ps        # every service should read (healthy)
docker compose down      # stop, keep data
docker compose down -v   # stop + wipe volumes → fresh migration and reseed

Tip

Prefer running the Spring Boot apps from your IDE with a debugger attached? Bring up only the backing services with docker compose up -d postgres redis rabbitmq, then see Running it the long way.


🏗 Architecture

flowchart TB
    subgraph client["🖥  Client"]
        UI["Next.js 16 Storefront + Admin<br/><b>:3000</b><br/><i>Server Components · Server Actions</i>"]
    end

    subgraph edge["🚪  Edge"]
        GW["api-gateway<br/><b>:8080</b><br/><i>Spring Cloud Gateway · CORS · routing</i>"]
        EUR["eureka-server<br/><b>:8761</b><br/><i>service registry</i>"]
    end

    subgraph svc["⚙️  Services"]
        AUTH["auth-service<br/><b>:8081</b><br/>JWT · BCrypt · Google OAuth"]
        CAT["catalog-service<br/><b>:8082</b><br/>products · sales · pre-warming"]
        ORD["order-service<br/><b>:8083</b><br/>atomic checkout · consumers"]
    end

    subgraph data["💾  State"]
        RD[("⚡ Redis 7<br/>stock counters<br/>Lua reservation")]
        MQ[["🐰 RabbitMQ 4<br/>order queue + DLQ"]]
        PG[("🐘 PostgreSQL 16<br/>auth · catalog · orders")]
    end

    UI --> GW
    GW -.discovers.-> EUR
    AUTH -.registers.-> EUR
    CAT -.registers.-> EUR
    ORD -.registers.-> EUR

    GW --> AUTH
    GW --> CAT
    GW --> ORD

    AUTH --> PG
    CAT --> PG
    CAT ==>|pre-warms| RD
    ORD ==>|<b>hot path</b>| RD
    ORD -->|publishes| MQ
    MQ ==>|consumer writes| PG

    classDef hot fill:#DC382D,stroke:#8b0000,color:#fff,stroke-width:2px
    classDef queue fill:#FF6600,stroke:#a04000,color:#fff,stroke-width:2px
    classDef db fill:#4169E1,stroke:#1a3a8f,color:#fff,stroke-width:2px
    class RD hot
    class MQ queue
    class PG db
Loading

The request waits only for the Redis reservation. Redis runs commands on a single thread, so concurrent callers each receive a distinct value — no two buyers can claim the same unit. Persistence happens off the request path, and checkout returns 202 Accepted with a correlation id rather than pretending an order already exists.

The checkout path, step by step

sequenceDiagram
    autonumber
    participant B as 🧑 Buyer
    participant G as 🚪 Gateway
    participant O as ⚙️ order-service
    participant R as ⚡ Redis
    participant Q as 🐰 RabbitMQ
    participant P as 🐘 PostgreSQL

    B->>G: POST /api/v1/flash-sale/checkout<br/>Bearer JWT + idempotencyKey
    G->>O: route via Eureka
    Note over O,R: identity comes from the JWT subject,<br/>never the request body
    O->>R: EVAL reserve_stock.lua
    activate R
    Note right of R: single-threaded · indivisible<br/>window ✓ user cap ✓<br/>idempotency ✓ DECRBY
    R-->>O: remaining ≥ 0 — reserved
    deactivate R
    O->>Q: publish OrderCreatedEvent
    O-->>B: 202 Accepted { orderId, PENDING_PERSISTENCE }
    Note over B: ⏱ the request ends here
    Q-->>P: consumer INSERTs the order as CONFIRMED
    P-->>R: DEL reservation:{orderId}
    B->>G: GET /api/v1/orders/{id} — polled every 1.5s
    G-->>B: CONFIRMED
Loading

Note

A 404 right after checkout is not an error. The 202 returns before the queue worker has written the row, so /orders/{id} legitimately has nothing to show for a moment. The UI treats a missing order as "still coming" when it arrived from a checkout, and as "not found" on a cold visit — different copy for what is genuinely a different situation.


🔬 The core idea: one Lua script

Everything that makes overselling impossible lives in reserve_stock.lua. Redis executes Lua on its single execution thread, so every check and every write below happens as one indivisible step. Nothing can interleave between the stock check and the decrement — which is precisely the race that causes overselling.

📜 See the reservation logic (click to expand)
-- KEYS[1] stock counter   flash_sale:stock:{saleId}
-- KEYS[2] buyers hash     flash_sale:buyers:{saleId}    userId -> qty bought
-- KEYS[3] sale metadata   flash_sale:meta:{saleId}
-- KEYS[4] reservation     reservation:{orderId}
-- KEYS[5] idempotency     flash_sale:idem:{idempotencyKey}

local meta = redis.call('HGETALL', KEYS[3])            -- -2  unknown / never pre-warmed
if meta['status'] ~= 'ACTIVE'          then return -3 end   -- -3  not open …
if now < startTime or now >= endTime   then return -3 end   --     … or outside its window

local already = redis.call('HGET', KEYS[2], ARGV[1])   -- -4  per-user cap exceeded
if already + quantity > max_per_user   then return -4 end

local stock = redis.call('GET', KEYS[1])               -- -1  sold out
if tonumber(stock) < quantity          then return -1 end

local claimed = redis.call('SET', KEYS[5], orderId, 'NX', 'EX', ttl)
if not claimed                         then return -5 end   -- -5  duplicate → original id

-- past this point the reservation is granted; all three writes land together
local remaining = redis.call('DECRBY',  KEYS[1], quantity)
                  redis.call('HINCRBY', KEYS[2], ARGV[1], quantity)
                  redis.call('SET',     KEYS[4], ARGV[1], 'EX', reservation_ttl)
return remaining                                       -- >= 0  success

Three design decisions worth calling out:

Decision Why
A request that cannot be satisfied never touches the counter Replaces the DECR-then-INCR-to-compensate pattern: two round trips, a transiently negative counter, and a crash between the calls permanently loses a unit of stock.
The idempotency claim is the last check Claiming it before the stock and window tests would burn the key on an attempt that was then rejected — and the customer's retry after a restock would be refused as a duplicate.
It returns a plain integer, not a table Mixed-type Lua tables come back through Spring Data Redis's serializer as a List of Longs and byte arrays that must be picked apart by position. A status code is far harder to misread.

Validating the sale window inside the script means checkout needs no database read and no call to catalog-service: the entire accept/reject decision is this one script.

Idempotency, proven

Sending the same idempotencyKey twice returns the same orderId and takes no additional stock. Verified end to end on 2026-08-17:

checkout #1  →  {"orderId":"808c514b-…","status":"PENDING_PERSISTENCE"}
checkout #2  →  {"orderId":"808c514b-…","status":"PENDING_PERSISTENCE"}   ← same id
stock        →  49  then  48                                              ← exactly one unit
GET /orders  →  1 order, CONFIRMED, 79.00

✅ Status

~78% complete. The backend engine is built, tested, and working end to end. Zero-oversell is verified in CI. The admin console is complete and the storefront runs entirely on live API data. Load testing, live order updates, and the storefront redesign remain.

Phase Deliverable Progress
1 — Foundations Docker infra, APIs, storefront
2 — Redis engine Pre-warming, Lua atomic decrement, locks
3 — Async queueing RabbitMQ consumers, SSE live updates
4 — Admin console Products, sales, orders, users, metrics
5 — Load testing k6 at 1,000+ VUs, Grafana, tuning
6 — Storefront wiring Live catalogue, real checkout, real orders
7 — Storefront redesign Marketplace visual language

Important

Verified: 60 simultaneous checkouts against 20 units of stock produce exactly 20 confirmed orders, every time — covered by 21 Testcontainers integration tests plus a manual end-to-end run against the full stack.

Not yet measured: throughput and latency. The 5,000 RPS and <15 ms targets are goals, not results, until Phase 5.


🧰 Tech stack

Layer Technology
Frontend Next.js 16 (App Router), React 19, Tailwind CSS 4, TypeScript 5, Zod 4
HTTP client axios — one server-only instance, see below
Backend Spring Boot 4.1, Java 21 LTS, Spring Cloud 2025.1.2
Discovery / edge Eureka, Spring Cloud Gateway (WebMVC)
Hot state Redis 7 — atomic Lua reservation
Message broker RabbitMQ 4 — order queue with dead-letter queue
Database PostgreSQL 16, Flyway, schema per service
Auth Spring Security, Nimbus HS256 JWT, BCrypt, Google OAuth 2.0
Testing JUnit 5, Testcontainers (Postgres + Redis + RabbitMQ)

Warning

Java 21, not 25. Boot 4.1's baseline is Java 17 and 21 is the newest LTS installed here. The Initializr scaffold's <java.version>25</java.version> could not build on this machine.


📁 Repository layout

.
├── 🐳 docker-compose.yaml        # the whole stack: infra + 5 services + frontend
├── 📄 docs/PROJECT-REPORT.md     # living engineering report — decisions & failure modes
├── 🖥 frontend/                  # Next.js 16 storefront + admin console
│   ├── app/admin/                # ops console routes (dark rail, dynamic)
│   ├── app/actions/admin.ts      # every admin mutation, each re-checking the role
│   ├── app/lib/http.ts           # the one axios instance; all calls go through it
│   ├── app/lib/catalog.ts        # public storefront reads
│   ├── app/lib/orders.ts         # the signed-in customer's orders
│   ├── app/lib/admin-api.ts      # authenticated gateway client (server-only)
│   ├── app/lib/admin-data.ts     # read queries; every endpoint path appears once
│   └── components/admin/         # console-specific UI, separate from the shop's
└── ☕ backend/
    ├── pom.xml                   # aggregator; Boot parent, Java 21, Spring Cloud BOM
    ├── run-all.ps1               # one-shot local launcher (finds JDK 21, tracks PIDs)
    ├── common/                   # error contract, JWT config, Redis keys, event types
    ├── eureka-server/            # :8761  service registry
    ├── api-gateway/              # :8080  single entry point + CORS
    ├── auth-service/             # :8081  register, login, JWT, Google OAuth
    ├── catalog-service/          # :8082  products, sales, Redis pre-warming
    └── order-service/            # :8083  atomic checkout, consumers, order history

🔧 Running it the long way (IDE / jars)

Prerequisites — and the one failure everybody hits
Tool Version Check
Docker Desktop any recent docker --version
JDK 21 (not 25 — see note above) java -version
Maven 3.9+ mvn -version
Node.js 20+ node --version

[!CAUTION] The single most common failure is JAVA_HOME. Maven reads JAVA_HOME, not the java on your PATH. If they disagree, the build dies with a confusing invalid target release: 21. Check with mvn -version — the "Java version:" line it prints is the one that matters. run-all.ps1 finds and sets a JDK 21 for you; do it manually and you must set it yourself.

Step 1 — Start Docker Desktop

The docker CLI can exist while the engine is stopped. If you see open //./pipe/dockerDesktopLinuxEngine: The system cannot find the file specified, the engine is not running.

Start-Process "C:\Program Files\Docker\Docker\Docker Desktop.exe"

Wait until docker info succeeds (30–60 s on a cold start).

Step 2 — Backing services only, and why the ports are odd
docker compose up -d postgres redis rabbitmq
docker compose ps          # wait until all three say (healthy)
Container Host port Container port Notes
flashx-postgres 5433 5432 Avoids a locally installed PostgreSQL
flashx-redis 16379 6379 Avoids a Windows reserved port range
flashx-rabbitmq 5672 / 15672 same UI at http://localhost:15672

Both non-standard host ports are deliberate, and both were chosen after the obvious port failed on a real machine:

Postgres on 5433. A locally installed PostgreSQL service commonly owns 5432. When it does, Docker's published port silently loses and the services connect to your Postgres instead — which has no flashx user, so they fail with password authentication failed for user "flashx". That reads like a credentials bug and is actually a port collision.

Redis on 16379. Windows/Hyper-V reserves shifting blocks of TCP ports. On this machine 6285-6384 is reserved, which swallows the conventional 6379 — Docker then fails to bind with "An attempt was made to access a socket in a way forbidden by its access permissions" and, worse, the container still reports Running with no published port at all. Check your machine's blocks with:

netsh interface ipv4 show excludedportrange protocol=tcp
Step 3 — Backend: the quick way (PowerShell)

Finds JDK 21, starts infrastructure if it isn't up, and opens each service in its own window so you can read the logs:

cd backend
.\run-all.ps1 -Build       # first run, or after code changes
.\run-all.ps1              # subsequent runs (jars already built)
Flag Effect
-Build mvn clean install first (stops running services so the jars aren't locked)
-GatewayPort 8090 Run the gateway elsewhere if something owns 8080
-Logs Write each service to backend/logs/*.log instead of opening a window — use this when you need to grep a stack trace
-Stop Stop the five services; leaves Docker running

The script records the PIDs it launched in .run-all-pids.json and -Stop kills only those, matching on start time so a recycled PID is never hit. It deliberately does not stop "whatever java is on port 8080" — that is how you take down an unrelated application.

Step 3b — Backend: the manual way
$env:JAVA_HOME = "C:\Program Files\Eclipse Adoptium\jdk-21.0.11.10-hotspot"
cd backend
mvn clean install

Then start all five, registry first — the others register with it on boot:

java -jar eureka-server/target/eureka-server-0.0.1-SNAPSHOT.jar     # :8761
java -jar auth-service/target/auth-service-0.0.1-SNAPSHOT.jar       # :8081
java -jar catalog-service/target/catalog-service-0.0.1-SNAPSHOT.jar # :8082
java -jar order-service/target/order-service-0.0.1-SNAPSHOT.jar     # :8083
java -jar api-gateway/target/api-gateway-0.0.1-SNAPSHOT.jar         # :8080

Each blocks its terminal, so use a separate window per service (or Start-Process).

On first boot Flyway creates the auth, catalog, and orders schemas and seeds six products; catalog-service then pre-warms the Redis counters automatically. Allow ~40 seconds.

Step 4 — Frontend
cd frontend
npm install                       # first time only
Copy-Item .env.example .env.local # first time only
npm run dev

.env.local must contain the gateway's address:

API_BASE_URL=http://localhost:8080

If you started the gateway on 8090, change this to match. It has no NEXT_PUBLIC_ prefix on purpose — only Server Actions call the backend, so the URL never reaches the browser bundle.

Open http://localhost:3000 for the storefront, or http://localhost:3000/admin for the ops console.

First run: both the storefront and the admin console read the live API. An empty catalogue means the database is genuinely empty, not that the UI is unwired — create a product and a flash sale in the console, then activate it.

Step 5 — Verify, and stopping cleanly
curl http://localhost:8761/actuator/health     # eureka   -> {"status":"UP"}
curl http://localhost:8080/actuator/health     # gateway  -> {"status":"UP"}
curl http://localhost:8080/api/v1/flash-sales  # -> 6 sales via the gateway

The Eureka dashboard at http://localhost:8761 should list 4 registered services.

cd backend
.\run-all.ps1 -Stop     # the five services (or just Ctrl+C each window)

cd ..
docker compose down     # containers, keeps data
docker compose down -v  # containers + volumes; forces a fresh Flyway migration and reseed

Ctrl+C in the npm run dev terminal stops the frontend.


🔐 Optional: Google sign-in

Setup — and why it is all-or-nothing

Off by default. The service starts fine without credentials and the button reports 503, which the frontend renders as disabled.

1. Create the OAuth client. Google Cloud console → APIs & ServicesCredentialsCreate credentialsOAuth client IDWeb application. Under Authorised redirect URIs add this exact string:

http://localhost:8080/login/oauth2/code/google

That is the gateway's port, not auth-service's 8081. The browser starts the flow on the gateway origin, and coming back on a different origin risks arriving without the session cookie holding the pending authorization request. A trailing slash or the wrong port is a redirect_uri_mismatch, not a warning. No Authorised JavaScript origin is needed — the code-for-token exchange is server-to-server and the client secret never reaches a browser.

2a. Running from jars:

$env:GOOGLE_CLIENT_ID="..."; $env:GOOGLE_CLIENT_SECRET="..."
java -jar auth-service/target/auth-service-0.0.1-SNAPSHOT.jar --spring.profiles.active=google

2b. Running under compose, or via run-all.ps1: copy .env.example to .env at the repo root and fill in:

GOOGLE_CLIENT_ID=...
GOOGLE_CLIENT_SECRET=...

That is the whole switch. There is no separate "enable" flag: the google Spring profile is derived from GOOGLE_CLIENT_SECRET being non-empty, by docker-compose.yaml and backend/run-all.ps1 alike, so a compose run and a local jar run cannot disagree. Compose reads .env natively; run-all.ps1 loads the same file and prints one line at startup saying whether Google sign-in came up enabled. With no .env at all, it stays off and the button renders disabled.

Both halves or nothing, because neither half alone fails cleanly. The registration lives in application-google.yaml rather than the main config because declaring it with an empty client-id does not mean "disabled" — Boot validates it and auth-service refuses to start, failing its health check and taking the stack with it. A client id with no secret is worse in a quieter way: the service starts, and the flow only dies at Google's token endpoint with invalid_client, three redirects later, surfacing on the storefront as a generic /login?error=google.

What the flow actually does
sequenceDiagram
    autonumber
    participant S as 🛍 Storefront :3000
    participant G as 🚪 Gateway :8080
    participant A as 🔐 auth-service
    participant Go as 🌐 Google

    S->>S: POST signInWithGoogle<br/>park ?next in a short-lived cookie
    S->>G: browser redirect → /api/v1/auth/oauth2/google
    G->>A: 302 /oauth2/authorization/google
    A->>Go: browser redirect → consent screen
    Go->>G: browser redirect → /login/oauth2/code/google
    G->>A: OAuthSuccessHandler
    Note right of A: upsert user · mint FlashX JWT
    A->>S: redirect /auth/callback?token=…&expiresIn=…
    S->>S: token → httpOnly cookie, redirect by role
Loading

Both OAuth legs are routed through the gateway (auth-service-oauth2 in its application.yaml); without those routes the relative redirect in step 3 lands on a gateway 404 and the flow dead-ends before it ever reaches Google.

The token crosses the last hop as a query parameter, which is the weak point of the design — see D1 in docs/PROJECT-REPORT.md. /auth/callback moves it into the cookie and redirects to a clean path immediately, so it never reaches a rendered page or a Referer header.


📡 API reference

Everything goes through the gateway on :8080. Errors are uniform: { "message": "...", "code": "..." }.

Public & customer

Method Endpoint Auth Returns
POST /api/v1/auth/register 201 { token, expiresIn } · 409 if email taken
POST /api/v1/auth/login 200 { token, expiresIn } · 401 otherwise
GET /api/v1/auth/oauth2/google 302 to Google · 503 if unconfigured
GET /api/v1/auth/me 🔑 current user
GET /api/v1/flash-sales all sales, storefront shape
GET /api/v1/flash-sales/{sku} one sale
POST /api/v1/flash-sales/prewarm 🔑 loads Redis counters
POST /api/v1/flash-sale/checkout 🔑 202 { orderId, PENDING_PERSISTENCE }
GET /api/v1/orders 🔑 order history
GET /api/v1/orders/{id} 🔑 one order

Important

Checkout takes the buyer's identity from the JWT subject, never the request body — accepting a userId field would make the per-customer cap trivially bypassable.

🎛 Admin API — every route requires role: ADMIN

Enforced twice — by hasRole('ADMIN') on the URL and by @PreAuthorize on the handler — so a controller added to the package without the annotation is still not reachable by a customer. A customer's token gets 403; no token gets 401.

Method Endpoint Does
GET /api/v1/admin/products Paged, ?search= over SKU/title/category
POST /api/v1/admin/products Create; 409 on duplicate SKU
PUT /api/v1/admin/products/{id} Update
DELETE /api/v1/admin/products/{id} 409 if a SCHEDULED or ACTIVE sale references it
GET /api/v1/admin/flash-sales All sales + live Redis counter and units sold
POST /api/v1/admin/flash-sales Schedule; validates discount < base price, allocation ≤ inventory
PUT /api/v1/admin/flash-sales/{id} Edit — SCHEDULED only
PUT /api/v1/admin/flash-sales/{id}/status Lifecycle; activating pre-warms Redis, ending tears it down
POST /api/v1/admin/flash-sales/prewarm Repair metadata for live sales without resetting counters
GET /api/v1/admin/orders Paged; filter by status, flashSaleId, search
GET /api/v1/admin/orders/reconciliation/{saleId} 🔍 the zero-oversell proof (?allocatedStock=N)
GET /api/v1/admin/metrics Order totals, revenue, queue depth, DLQ depth, consumers
GET /api/v1/admin/users Paged, ?search=
PUT /api/v1/admin/users/{id}/role Promote/demote; refuses self-demotion and last-admin

Default admin: admin@flashx.local / flashx-admin, seeded by V2__add_roles_and_seed_admin.sql. Override with FLASHX_ADMIN_PASSWORD; the service logs a loud warning at startup while the seeded password is still in use.

Sale lifecycle — not a free-form field

stateDiagram-v2
    direction LR
    [*] --> SCHEDULED
    SCHEDULED --> ACTIVE: activate — pre-warms Redis
    SCHEDULED --> ENDED: cancel before opening
    ACTIVE --> ENDED: end — writes true unsold count back
    ACTIVE --> EXHAUSTED: stock hits zero
    ENDED --> [*]
    EXHAUSTED --> [*]
Loading

Reactivating an ENDED sale is refused — it would republish a counter for a sale whose orders are already closed.


🧪 Smoke test

Drive a real checkout end to end:

GW=http://localhost:8080

TOKEN=$(curl -s -X POST $GW/api/v1/auth/register -H 'Content-Type: application/json' \
  -d '{"name":"Ada","email":"ada@example.com","password":"Sup3rSecret!"}' | jq -r .token)

curl -s $GW/api/v1/flash-sales | jq '.[] | {id, sku, status, remainingStock}'

curl -s -X POST $GW/api/v1/flash-sale/checkout \
  -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
  -d '{"flashSaleId":1,"quantity":1,"idempotencyKey":"demo-1"}'

docker exec flashx-redis    redis-cli GET flash_sale:stock:1
docker exec flashx-postgres psql -U flashx -d flashx -c 'select * from orders.orders;'

Run the checkout twice with the same key — same orderId, one unit of stock.

Running the test suite

cd backend
mvn test      # unit tests only — no Docker needed
mvn verify    # + 21 Testcontainers integration tests (~2 min, needs Docker)
cd frontend
npm run build  # production build, also typechecks
npm run lint
Suite Covers
OversellIT 60 simultaneous checkouts vs 20 units → exactly 20 confirmed
CheckoutFlowIT Reservation → queue → Postgres, end to end
ReservationServiceIT Every Lua return code, per-user caps, idempotency

🎛 Admin console

http://localhost:3000/admin — a dark-rail operations panel, deliberately not mistakable for the storefront at a glance, since the same browser may have both open and this one can end a live sale.

Sign in at /login with the seeded admin account, or go straight to /admin and you will be sent to sign in and returned there afterwards.

Screen What it does
/admin Order totals and revenue, persistence-queue depth, DLQ, consumer count, recent orders, and every sale currently open. Optional 5-second live refresh.
/admin/products Paged, searchable catalogue. Create, edit, delete.
/admin/sales The sale board, filterable by status. Lifecycle buttons and metadata repair.
/admin/sales/{id} One sale: lifecycle, stock reconciliation, its orders, and the edit form while SCHEDULED.
/admin/orders Every checkout, filterable by status and sale. Read-only.
/admin/users Who can sign in and who can reach this console. Promote and demote.
Three design decisions worth knowing about

1 · The sale board shows two stock figures side by side. live is the Redis counter — the only number customers are actually buying against. remaining is the Postgres projection, which is allowed to trail during a sale. Showing both makes the lag visible instead of leaving an operator to guess which one is lying. A sale that is ACTIVE with no live counter is flagged in red: it will reject every checkout, and that is invisible from the storefront.

2 · Orders are read-only, by design rather than omission. An order is the record of a completed financial event, and the reconciliation below is only meaningful because nothing in the UI can quietly adjust one. Corrections belong in a refund flow that writes a new row.

3 · Reconciliation is shown as arithmetic, not a verdict:

allocated  =  confirmed  +  live  +  in-flight

A green "healthy" badge would be worth very little — the entire claim of this architecture is that the identity holds under concurrent load, so the numbers themselves are the evidence. in-flight is stock reserved in Redis but not yet written to Postgres: normal while the queue drains, a problem only if it never settles to zero. oversold means more units were confirmed than ever existed, which is a correctness defect rather than a reporting lag.

🛡 How authorisation is layered — and what a forged token actually gets you

The console decodes the JWT to decide what to render, without verifying its signature — the signing secret does not belong in the web tier. That is UI gating, not access control. Every /api/v1/admin/** call carries the token and the backend verifies it, so a hand-crafted cookie claiming role: "ADMIN" gets someone the sidebar and a wall of session-expired panels, and no data. Verified:

Session Result
None 307/login?next=/admin
Customer 307/dashboard?denied=admin
Forged ADMIN claim, bad signature Shell renders, every panel empty, zero data leaked
Real admin Full access

Every mutating Server Action re-checks the role itself. An action is a public POST endpoint reachable without the layout ever rendering, so a layout-only check would secure the navigation and nothing else.


🔌 How the frontend talks to the backend

Every call goes through one axios instance in app/lib/http.ts, pointed at the API gateway. No component ever addresses a service directly, so a change in backend topology is a gateway config edit rather than a frontend one.

Server Component / Server Action
  └─▶ app/lib/{catalog,orders,admin-data}.ts   endpoint paths live here, once each
      └─▶ app/lib/http.ts                      axios: base URL, timeout, error shape
          └─▶ API gateway :8080
              └─▶ auth-service │ catalog-service │ order-service

Nothing runs in the browser. http.ts imports server-only, so an accidental import into a Client Component is a build error rather than a token silently shipped to the browser in the RSC payload. API_BASE_URL has no NEXT_PUBLIC_ prefix for the same reason.

Errors are normalised at the boundary. axios rejects on any non-2xx, which would make "this sale sold out" (a 409 the UI must render) look the same as "the network died". Every call returns ApiResult<T> instead:

const catalogue = await listFlashSales();
if (!catalogue.ok) return <EmptyState description={catalogue.message} />;

Transport failures get sentinel statuses so they can be told apart from HTTP ones: -1 not configured, 0 unreachable, -2 timed out.

⚠ The one real cost of axios — and the bug it caused

Next.js patches global fetch to add request dedup and the cache/revalidate tag system. axios uses Node's http module and gets none of it — which means Next has no idea these pages depend on request-time data and will happily prerender them. That is not theoretical: before this was handled, next build marked / and /sales as static, freezing live stock at build time.

The fix is await connection() inside the catalogue functions, following the Next.js docs' guidance for synchronous database drivers. Putting it in the data layer rather than on each page means every caller is excluded from prerendering automatically and a new page cannot forget to opt out.

Nothing here is cached, deliberately. remainingStock and status are the entire subject of the page and both move during a sale; a cache measured in seconds would invite a customer to click Reserve on something already gone.

What the storefront does today

Screen Data
/ and /sales GET /api/v1/flash-sales, sliced into live and scheduled
/sales/{sku} GET /api/v1/flash-sales/{sku} — a real 404 for an unknown SKU, an error panel for an unreachable backend
Cart Still localStorage, but only skus and quantities. Prices and stock are joined against the live catalogue on every render, so nothing stale is ever served from storage
/checkout POST /api/v1/flash-sale/checkout — one call per cart line
/orders, /dashboard GET /api/v1/orders, scoped to the JWT subject
/orders/{id} GET /api/v1/orders/{id}, polled every 1.5 s while the write is outstanding

Note

Checkout is a loop, not a transaction. The endpoint reserves one sale at a time — the shape the Lua script and the per-user cap are built around. A three-line cart is three reservations, issued sequentially so a partial failure is legible. There is deliberately no rollback: returning stock from a successful reservation because a different sale sold out would take back inventory a queued order already depends on. Lines that succeed are removed from the cart; a line that fails stays there with its reason.


🧯 Troubleshooting

The table of things that will actually go wrong
Symptom Cause Fix
invalid target release: 21 JAVA_HOME points at an older JDK $env:JAVA_HOME="...jdk-21...", or use run-all.ps1
open //./pipe/dockerDesktopLinuxEngine Docker engine not running Start Docker Desktop, wait for docker info
password authentication failed for user "flashx" A local Postgres owns 5432 Already handled — the container publishes 5433
Connection refused … :6379 while docker exec redis-cli ping works Redis port not published to the host See Ports that vanish below
bind: An attempt was made to access a socket in a way forbidden… Port sits in a Windows reserved range Pick a port outside netsh interface ipv4 show excludedportrange protocol=tcp
Port 8080 was already in use Something else owns the port .\run-all.ps1 -GatewayPort 8090 and update API_BASE_URL in .env.local
Unable to access jarfile C:\Users\...\Individual Unquoted path with spaces Fixed in run-all.ps1; quote the jar if launching by hand
Checkout returns SALE_NOT_OPEN on a live-looking sale Seeded sale windows expired, or clock drift See Stale sale windows below
Health says DOWN but endpoints work A dependency's health indicator is failing .\run-all.ps1 -Logs, then grep the log — Redis and RabbitMQ are the usual suspects
Frontend says "Could not reach the authentication service" API_BASE_URL unset or wrong port Check frontend/.env.local, restart npm run dev
Gateway returns 503 for a route Service not registered with Eureka yet Wait ~30 s; confirm at http://localhost:8761
👻 Ports that vanish after a Docker restart

After a Docker Desktop restart a container can come back Running with its port mapping silently missingdocker compose ps shows 6379/tcp instead of 0.0.0.0:16379->6379/tcp. docker compose up -d will not fix it, because the config hash still matches and Compose reuses the container. Force it:

docker compose up -d --force-recreate redis

If that then fails with the socket-permissions error, the host port has fallen inside a Windows reserved range — pick another one. (This is why Redis is on 16379.)

⏰ Stale sale windows → every checkout returns SALE_NOT_OPEN

The seed migration sets sale windows relative to migration time, so a database left running for a day has no live sales. Real windows come from the admin path, not a migration. To refresh the demo data in place:

docker exec flashx-postgres psql -U flashx -d flashx -c \
  "UPDATE catalog.flash_sales SET start_time = now() - INTERVAL '1 hour',
                                  end_time   = now() + INTERVAL '6 hours'
    WHERE status = 'ACTIVE';"

docker exec flashx-redis redis-cli --scan --pattern 'flash_sale:meta:*' \
  | tr -d '\r' | xargs -r -n1 docker exec flashx-redis redis-cli DEL

curl -X POST http://localhost:8080/api/v1/flash-sales/prewarm -H "Authorization: Bearer $TOKEN"

prewarm reporting salesWarmed: 0 is correct here — it refreshes the metadata but uses SETNX on the counters, so a sale already in progress is never reset to full stock.

A less common variant is genuine clock drift: Flyway seeds windows using Postgres's clock while the reservation script judges them against the service's. Compare docker exec flashx-postgres psql -U flashx -d flashx -t -c "select now();" against the host. If they disagree, wsl --shutdown and restart Docker Desktop.

Or just reset everything:

docker compose down -v   # wipes volumes, forces a fresh migration and reseed
docker compose up -d

🗺 Roadmap

  • Redesign the storefront — the data is live now; the visual language is still the original one, and a marketplace layout is the remaining half
  • SSE order-status stream — replaces both the order page's 1.5 s poll and the console's
  • Idempotency keys minted before the first attempt and reused across retries. They are currently generated per attempt, which dedupes nothing on a lost response
  • Sweeper for expired reservations (failure mode F1)
  • k6 load test — 1,000+ VUs against a 500-unit sale, then reconcile
  • Extend the test suite to auth-service and catalog-service
  • Browser tests — the Server Actions are verified by driving them directly, which skips React's form serialisation

Full detail in report §14.


📚 Further reading

Engineering report — architecture decisions, failure modes, verification results
reserve_stock.lua — the heart of it, heavily commented


An exercise in the thing that is easy to describe and hard to do:
being correct while being fast.

About

⚡ High-concurrency flash sale platform built with Spring Boot, Next.js, Redis, RabbitMQ & PostgreSQL to explore scalable, reliable order and inventory processing.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages