Skip to content

Latest commit

 

History

History
229 lines (175 loc) · 9.22 KB

File metadata and controls

229 lines (175 loc) · 9.22 KB

Allo Inventory — Multi-Warehouse Reservation System

A production-quality inventory reservation system built with Next.js App Router that prevents overselling through concurrency-safe atomic stock management across multiple warehouses.

✨ Features

  • Product Listing — Browse products with real-time per-warehouse stock levels
  • Concurrency-Safe Reservations — Atomic stock locking prevents two users from reserving the last unit
  • Live Countdown Timer — Visual countdown shows time remaining before reservation expires
  • Automatic Expiry — Unreserved items are automatically released back to inventory
  • Idempotency Support — Duplicate requests return the original response (Idempotency-Key header)
  • Error Handling — Clear 409 (conflict) and 410 (expired) error states in the UI

🛠 Tech Stack

Layer Technology
Framework Next.js 16 (App Router)
Language TypeScript (end-to-end)
Database PostgreSQL (Neon / Supabase)
ORM Prisma 7 with PrismaPg driver adapter
Validation Zod
Styling Tailwind CSS v4 + shadcn/ui
Deployment Vercel-ready with cron configuration

🚀 Local Setup

Prerequisites

  • Node.js 20+
  • A PostgreSQL database (recommended: Neon free tier)

Steps

# 1. Clone and install
git clone <repo-url>
cd allo
npm install

# 2. Configure environment
cp .env.example .env
# Edit .env and paste your DATABASE_URL

# 3. Push schema to database
npm run db:push

# 4. Seed demo data
npm run db:seed

# 5. Start development server
npm run dev

Open http://localhost:3000 to see the app.

⚙️ Environment Variables

Variable Required Default Description
DATABASE_URL PostgreSQL connection string (e.g., Neon pooled URL)
RESERVATION_TTL_SECONDS 600 How long a reservation is held before expiry (seconds)

📡 API Endpoints

GET /api/products

Returns all products with per-warehouse stock breakdown including available counts.

GET /api/warehouses

Returns all warehouses.

POST /api/reservations

Creates a new reservation. Accepts:

{
  "productId": "string",
  "warehouseId": "string",
  "quantity": 1
}

Headers:

  • Idempotency-Key (optional): Prevents duplicate reservations on retries

Responses:

  • 201 — Reservation created with details and expiresAt
  • 400 — Validation error
  • 409 — Insufficient stock (with human-readable message)

POST /api/reservations/:id/confirm

Confirms a pending reservation, permanently deducting stock.

  • 200 — Confirmed
  • 410 — Expired (reservation was released)

POST /api/reservations/:id/release

Cancels a pending reservation, returning stock to inventory.

  • 200 — Released

🔒 Concurrency Strategy

The core problem: Two users simultaneously try to reserve the last unit. Both read available = 1, both try to reserve. Without protection, both succeed → oversold.

The solution: Atomic conditional UPDATE with row-level locking.

UPDATE "Inventory"
SET "reserved" = "reserved" + $quantity
WHERE "productId" = $productId
  AND "warehouseId" = $warehouseId
  AND ("quantity" - "reserved") >= $quantity

This single SQL statement is the entire concurrency mechanism:

  1. Atomicity — PostgreSQL guarantees the UPDATE is atomic
  2. Row-level locking — When two transactions hit the same row, PostgreSQL serializes them
  3. Conditional check — The WHERE clause ensures the second request sees the updated reserved value from the first
  4. Affected row count — If updated === 0, we know stock was insufficient and return 409

This approach is simpler than SELECT FOR UPDATE and equally safe because the conditional UPDATE both checks and modifies in one operation.

⏰ Reservation Expiry

Reservations are automatically released back to available inventory using a Strictly Lazy Cleanup Strategy.

How it works:

Before executing any stock-sensitive operation, the system automatically checks for and releases all expired reservations globally:

  1. GET /api/products: Cleans up expired reservations before calculating inventory, ensuring the storefront displays correct stock levels.
  2. POST /api/reservations: Cleans up expired reservations before verifying stock availability, ensuring new reservations can occupy previously held slots immediately.
  3. POST /api/reservations/:id/confirm: Cleans up expired reservations before confirming, preventing late confirmations of expired stock.

Why Lazy Expiry?

  • Self-Contained & Cost-Effective: Eliminates dependencies on background worker processes, paid cron services, or external HTTP triggers (like Vercel Cron).
  • Low Overhead: Uses indexed queries (WHERE status = 'PENDING' AND expiresAt < NOW()) which are extremely fast and add negligible latency to requests.
  • Serverless-Friendly: Fits serverless scaling profiles perfectly, since it runs inline during the request lifecycle.

🔑 Idempotency Strategy

The POST /api/reservations and POST /api/reservations/:id/confirm endpoints support idempotency via the Idempotency-Key header.

How it works:

  1. Client sends Idempotency-Key: <unique-key> in the request header
  2. Before processing, the server checks the IdempotencyKey table for this key
  3. If found, the cached response (status + body) is returned with x-idempotent-replay: true header
  4. If not found, the request is processed normally and the response is stored

The frontend generates keys using ${action}-${id}-${timestamp} to ensure uniqueness per action attempt while allowing retries of the same attempt.

📊 Database Schema

Product ──┐
           ├── Inventory (quantity, reserved) ──┐
Warehouse ─┘                                     │
                                                  │
Reservation (productId, warehouseId, quantity,    │
             status, expiresAt) ──────────────────┘

IdempotencyKey (key, response, statusCode)
  • Inventory.quantity: Total physical stock
  • Inventory.reserved: Units held by PENDING reservations
  • Available = quantity - reserved (computed, not stored)
  • When confirmed: both quantity and reserved are decremented (stock leaves the warehouse)
  • When released/expired: only reserved is decremented (stock returns to available)

🎯 Trade-offs & Future Improvements

Current trade-offs

  • Lazy expiry adds latency to product listing requests, but ensures accurate stock. In production, the cron handles most expirations proactively.
  • No authentication — This is a demo. In production, reservations would be tied to user sessions.
  • In-memory Prisma client — In serverless (Vercel), each cold start creates a new connection. Consider connection pooling (PgBouncer / Neon pooler) for production scale.
  • Idempotency keys are not garbage-collected — In production, add a TTL-based cleanup job.

What I'd improve with more time

  • WebSocket/SSE for real-time stock updates across browsers
  • Rate limiting on reservation creation
  • Reservation queue with fair ordering for high-demand products
  • Multi-item cart with all-or-nothing reservation
  • Admin dashboard for warehouse managers
  • Distributed locking (Redis) for multi-region deployments
  • Comprehensive test suite (unit + integration + concurrency stress tests)
  • OpenAPI specification for the API
  • Structured logging with correlation IDs

📁 Project Structure

allo/
├── prisma/
│   ├── schema.prisma     # Database schema
│   ├── seed.ts           # Demo data seeder
│   └── migrations/       # Migration history
├── prisma.config.ts      # Prisma 7 configuration
├── src/
│   ├── app/
│   │   ├── api/
│   │   │   ├── products/route.ts        # GET /api/products
│   │   │   ├── warehouses/route.ts      # GET /api/warehouses
│   │   │   ├── reservations/
│   │   │   │   ├── route.ts             # POST /api/reservations
│   │   │   │   └── [id]/
│   │   │   │       ├── confirm/route.ts # POST confirm
│   │   │   │       └── release/route.ts # POST release

│   │   ├── globals.css
│   │   ├── layout.tsx
│   │   └── page.tsx                     # Main page
│   ├── components/
│   │   ├── product-card.tsx             # Product display
│   │   ├── reservation-modal.tsx        # Reserve dialog
│   │   ├── checkout-view.tsx            # Checkout with countdown
│   │   └── ui/                          # shadcn/ui primitives
│   └── lib/
│       ├── prisma.ts                    # DB client singleton
│       ├── expiry.ts                    # Expiry cleanup logic
│       ├── validations.ts              # Zod schemas
│       ├── types.ts                     # API response types
│       └── utils.ts                     # shadcn utilities

├── .env.example                         # Env template
└── package.json

License

MIT