Ankit Sinha All projects

Currently building / 03

HoldFast

A multi-tenant reservation and limited-drop engine. All-or-nothing holds, deterministic row locking, and a roadmap toward expiry, idempotent checkout, and signed webhooks.

M1 complete · M2 next TypeScript Node.js Express PostgreSQL Drizzle Docker Vitest

The problem

Reserving a seat, appointment slot, or limited-drop item is a small promise: this unit is yours for a few minutes. Making that promise hold at scale is a concurrency problem. Two buyers cannot both hold the same unit, a hold cannot outlive its TTL, a retry cannot charge twice, and an organizer needs to know what happened.

HoldFast is a multi-tenant engine for that whole flow. A unit can be a seat, an appointment slot, or a sale item. The project is also a deliberate learning vehicle: row locks, transaction boundaries, retries, queues, idempotency, outbox delivery, and multi-tenant isolation.

Today: M1 holds

M1 is the reservation core. A client can request specific units or any N units. Both paths are all-or-nothing: the hold row and every unit update commit together, or nothing does. The API is Express, the data layer is Postgres with Drizzle, and the tests run on Vitest.

Client
  │  POST /holds
  ▼
Express API
  │
  ├── holdSeats
  │     ORDER BY id ... FOR UPDATE
  │     availability pre-check + guarded update
  │
  └── holdAnySeats
        LIMIT N ... FOR UPDATE SKIP LOCKED
        count hint + capped jittered retries
  │
  ▼
PostgreSQL
  ├─ tenants / events / holds / units
  ├─ holds.expires_at written with now()
  ├─ guarded UPDATE ... WHERE status = 'available'
  └─ check constraints + partial indexes

Specific-unit allocation uses fixed-order locks

The requested units are locked in ORDER BY id, then checked for availability. The hold row is inserted, and a guarded update changes only units that are still available. If the updated row count is short, the transaction rolls back. Deadlocks (40P01) retry the whole transaction, up to a small cap with jitter.

Any-N allocation uses SKIP LOCKED with a backstop

The any-N path grabs available rows with SKIP LOCKED, then verifies the update count. A short result is retried only when a non-locking count says enough units might still be available. The count is a hint; the guarded update is the correctness check. Capped retries can produce a false conflict under extreme contention, but never an oversell.

The database owns time and the transaction boundary

expires_at is computed with the database clock, not Date.now(). The hold row is created inside the same transaction as the unit updates, so a failed attempt leaves no orphan hold and no unit pointing at missing state.

Tenant scoping comes before authentication

Every query is already scoped by tenant ID. For now the tenant comes from the request body, which is a temporary development decision, not authentication. M5 replaces it with API keys, a non-owner database role, and row-level security.

The tests are part of the proof

Concurrency tests run buyers in parallel with a pool large enough to interleave. An advisory lock serializes test cases without serializing the buyers inside a test. The invariant checker verifies the database state after each scenario, and the M1 gate runs each invariant test repeatedly.

What M1 proves

1winner with 200 buyers
for a single unit
100winners with 1,000 buyers
for 100 units
14tests plus typecheck
and invariant gate
7 × 50invariant runs before
the gate passes

Local benchmark for 1,000 buyers and 100 units: 1,507 ms mean for the specific-unit path and 1,669 ms for any-N, with exactly 100 winners in both. It is a microbenchmark, not a production capacity claim.

The update guard decides the winner, not the lock. A test that passes with and without the guard is not proving anything.

The ship line

M1

Reserve Done

All-or-nothing holds, specific and any-N allocation, the reserve route, concurrency tests, and the invariant gate.

M3

Checkout + saga Planned

Orders, persisted idempotency keys, a mock payment provider, confirm/compensate paths, and crash recovery.

M4

Outbox + webhooks Planned

Signed at-least-once events, retries, a dead-letter queue, replay, secret rotation, and SSRF-safe delivery.

M8

Short proof After M4

One load test and a README of the failure cases. The finish line is M1–M4 plus this short proof, not the full extras list.

M5+

Extras Later

RLS, a waiting room, single-use tickets, full observability, and a demo UI. Useful, but deliberately after the ship line.

Planned architecture

After M4, the core flow writes state and an outbox row in one database transaction. Expiry and retries live outside the API in BullMQ workers, the payment provider is called outside any transaction, and a relay signs webhook deliveries.

Client
  │
  ▼
Express API ─────────────── PostgreSQL
  │                          ├─ holds / orders
  │                          ├─ idempotency_keys
  │                          └─ outbox
  │
  ├── BullMQ + Redis ────── expiry jobs, retries
  │
  ├── mock PSP ───────────── charge / lookup / refund
  │
  └── outbox relay ───────── signed webhooks (HMAC)

Rules
  • short DB transactions
  • no payment or webhook call while holding row locks
  • expiry decisions use database now()
  • release and confirm lock the hold first, then units
  • at-least-once delivery, dedup by event id

Invariants I'm building against

Current limits