-
Notifications
You must be signed in to change notification settings - Fork 0
Project Specification
The Goal: Build a backend system capable of handling a "flash sale" scenario (e.g., Taylor Swift tickets release) where thousands of users attempt to purchase a limited number of seats simultaneously.
The Stack (FanKave Alignment):
- Runtime: Node.js (TypeScript)
- Framework: Express or NestJS (NestJS is recommended for structure)
- Database: PostgreSQL (Relational data integrity is non-negotiable here)
- Caching/Queues: Redis & BullMQ
- Containerization: Docker & Docker Compose
Goal: Get the API working. Users can create events and book seats.
Create a normalized schema. Avoid "JSON dumps" in columns; use relations.
Users Table
CREATE TABLE users (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
email VARCHAR(255) UNIQUE NOT NULL,
name VARCHAR(255) NOT NULL
);Events Table
CREATE TABLE events (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name VARCHAR(255) NOT NULL,
total_tickets INT NOT NULL,
available_tickets INT NOT NULL
);Bookings Table
CREATE TABLE bookings (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID REFERENCES users(id),
event_id UUID REFERENCES events(id),
status VARCHAR(50) DEFAULT 'CONFIRMED', -- CONFIRMED, CANCELLED
created_at TIMESTAMP DEFAULT NOW()
);-
POST /events: Create a new event (e.g., "Coldplay Concert", 100 tickets). -
POST /book: The critical endpoint.-
Body:
{ userId: "...", eventId: "..." } -
Logic:
- Find Event.
- Check if
available_tickets > 0. - Decrement
available_tickets. - Create Booking record.
- Return Success.
-
Body:
If you run a load test (Apache JMeter or K6) sending 100 concurrent requests when only 1 ticket is left, your database will likely allow 5-10 people to book that single seat. This is the "Race Condition."
Goal: Fix the race condition using Database ACID properties.
Refactor the POST /book logic to use a Database Transaction.
Logic Change:
await db.transaction(async (trx) => {
// LOCK the row so no one else can read it until I'm done
const event = await trx('events')
.where('id', eventId)
.forUpdate() // <--- CRITICAL: Row-level locking (Pessimistic Lock)
.first();
if (event.available_tickets <= 0) throw new Error('Sold Out');
await trx('events').decrement('available_tickets', 1);
await trx('bookings').insert({ ... });
});
FOR UPDATElocks the database row. If 10,000 users hit this endpoint, the database requests line up one by one. The API becomes incredibly slow (high latency). The database becomes the bottleneck.
Goal: Handle high throughput without crashing the DB. Decouple the "Request" from the "Processing."
Instead of writing to Postgres immediately, the API pushes a "job" to a Queue.
- User hits
POST /book. - API validates request structure -> Pushes job to Redis (BullMQ) -> Returns
"202 Accepted"(Booking Pending). - Worker Service (separate Node process) pulls job from Redis -> Process DB transaction.
- User receives final booking status through server sent events
Even with a queue, if you have 5 workers running in parallel, you still have race conditions.
Solution: Use Optimistic Concurrency Control (Versioning).
Schema Change: Add a version column to the events table.
Logic:
UPDATE events
SET available_tickets = available_tickets - 1, version = version + 1
WHERE id = $1 AND version = $2; -- Check if version matches what we readIf the update affects 0 rows, it means someone else modified the record in the split second between read and write. The worker should retry or fail.
Goal: Make the system bulletproof.
What if the user clicks "Book" twice? What if the network fails after the API receives the request but before the response reaches the client?
-
Requirement: Client sends a unique
Idempotency-Keyheader (e.g., a UUID generated on the frontend). - Implementation: Store this Key in Redis with a TTL (Time To Live). If a request comes with a known Key, return the previous result immediately without processing the queue again.
For extreme precision (e.g., specific seat selection like "Row A, Seat 1"), Optimistic locking isn't enough.
- Use Redis to create a temporary lock on a specific resource ID (
lock:event:123:seat:A1). - If the lock exists, reject the request immediately before touching the database.
Organize as a Monorepo (using Turborepo or simple folder structure):
/apps
/api (Express/NestJS - handles HTTP requests)
/worker (Node.js - processes BullMQ jobs)
/packages
/database (Shared Prisma/TypeORM client)
/types (Shared TS interfaces)
You must include a script that proves your system works.
- Create a script
load-test.ts. - It spawns 1000 promises.
- Each promise hits
POST /book. -
Expected Output:
"Sold 100 tickets. Rejected 900 requests. Database integrity: 100% match."
Your docker-compose.yml must spin up:
- Postgres
- Redis
- API Service
- Worker Service
- Day 1-2: Set up NestJS, Postgres, and Docker. Build Level 1 (Naive CRUD).
- Day 3: Write a test script that breaks your Level 1 code (overselling tickets).
- Day 4-5: Implement BullMQ. Move the booking logic into a Worker Processor.
- Day 6: Implement Optimistic Locking (Versioning) or Redlock.
- Day 7: Add the Load Test script to the README and record a loom video showing it handling 1k requests per second.