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.
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
for a single unit
for 100 units
and invariant gate
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
Reserve Done
All-or-nothing holds, specific and any-N allocation, the reserve route, concurrency tests, and the invariant gate.
Holds with expiry Next
BullMQ delayed jobs plus a database sweeper. The hard part is the release-versus-confirm race at the expiry boundary, not the timer itself.
Checkout + saga Planned
Orders, persisted idempotency keys, a mock payment provider, confirm/compensate paths, and crash recovery.
Outbox + webhooks Planned
Signed at-least-once events, retries, a dead-letter queue, replay, secret rotation, and SSRF-safe delivery.
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.
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
- Sold units never exceed event capacity.
- No unit belongs to two active holds or orders.
- Every sold unit belongs to a converted hold and a paid order.
- An expired or released hold owns no units; expiry checks status and time under the same lock.
- Tenant IDs match across the event, hold, order, and unit.
- Duplicate expiry jobs, payment callbacks, and webhook deliveries are safe no-ops where required.
- No external service is called inside a database transaction.
- The M1 invariant gate stays green after every milestone.
Current limits
- No expiry worker, queue, or sweeper yet; holds can remain active until M2.
- No checkout, orders, payments, or idempotency yet.
- No outbox or webhooks yet.
- No auth, API keys, or RLS. The tenant in the request body is not safe for a public deployment.
- No waiting room, tickets, observability, or demo UI.
- Capped any-N retries can return a false conflict under extreme contention; they do not oversell.
- M1 is a microbenchmark, not a capacity claim. Before M2, I'm revisiting it through oral checks and break-it tests so I can explain and rebuild the core, not just run it.