Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
102 changes: 102 additions & 0 deletions .dokugen-backup.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
# FairQueue

A virtual queue and inventory allocation system for high-demand live events in Nigeria. Built to handle the moment 50,000 people try to buy 5,000 tickets at exactly the same time — without overselling, without crashes, and without bots.

## The Problem

When a high-demand event goes on sale, three things happen simultaneously:

- The website crashes under the sudden spike in traffic
- Bots grab the inventory in milliseconds before real fans get a chance
- Payment failures silently lose tickets that were already claimed

FairQueue solves all three. It absorbs the traffic spike into a virtual queue, admits customers at a controlled rate, and guarantees that inventory allocation is atomic — two people can never get the same ticket.

## Quick Start

```bash
git clone https://github.com/DanielPopoola/fairqueue
cd fairqueue
cp .env.example .env # fill in your Paystack keys; everything else works out of the box
docker compose up --build
```

API is available at `http://localhost:8080`. Swagger UI is at `http://localhost:8080/swagger/index.html`.

## How It Works

A customer's journey through the system has four stages:

**1. Queue** — When the sale opens, everyone hits `POST /events/{id}/queue` at once. This is a cheap Redis write (O(log N)), so it absorbs any traffic volume without touching the database. Each customer gets a queue position.

**2. Admission** — A background worker runs every 5 seconds. It pops the next batch of customers from the waiting queue, moves them to the admitted set, and pushes them a signed admission token via WebSocket. Customers who miss the push can poll `GET /events/{id}/queue/position` to retrieve their token.

**3. Claim** — An admitted customer presents their token to `POST /events/{id}/claims`. The system atomically checks and decrements the Redis inventory counter. If that succeeds, it inserts a claim row in Postgres. A unique constraint on `(event_id, customer_id)` is the last line of defence against any race condition.

**4. Payment** — The customer calls `POST /claims/{id}/payments`, which writes a payment record to Postgres *before* calling Paystack. This means a crash can never produce a charge with no record. The reconciliation worker finds and heals any payments stuck in intermediate states.

## Key Design Decisions

**PostgreSQL is the only source of truth.** Redis holds nothing that cannot be reconstructed from Postgres. If Redis is wiped, the startup recovery function rebuilds the queue and the reconciliation worker heals the inventory count. Redis makes things fast; Postgres makes things correct.

**Two-layer concurrency shield.** A Redis `SET NX` lock stops concurrent claim attempts before they reach the database. A Postgres unique constraint on `(event_id, customer_id)` is the inviolable correctness guarantee that holds even if the lock is unavailable. Both layers must fail for an oversell to occur.

**Outbox pattern for payment safety.** A `Payment` row is always written in `INITIALIZING` state before the Paystack API is called. A crash at any point leaves a recoverable record. The reconciliation worker finds stale `INITIALIZING` records and retries the gateway call.

**Postgres-first writes.** The Redis inventory counter is only decremented *after* the Postgres insert commits. If the server crashes between the commit and the Redis write, Redis shows more tickets than exist — the reconciliation worker heals this within 30 seconds. The alternative (Redis first) risks permanently locking out valid users. Temporary inflation is the only acceptable failure mode.

For the full reasoning behind every decision, see [TRADEOFFS.md](./TRADEOFFS.md).

## Architecture

See [ARCHITECTURE.md](./ARCHITECTURE.md) for a component diagram showing how all the pieces fit together.

## Project Structure

```
cmd/api/ Entry point and dependency wiring
internal/
domain/ State machines and domain errors — no infrastructure dependencies
service/ Business logic: claims, queue, payments, events
store/
postgres/ PostgreSQL store implementations
redis/ Redis store implementations (inventory, queue, lock)
worker/ Background workers: admission, expiry, reconciliation, recovery
api/ HTTP handlers, middleware, WebSocket hub
gateway/paystack/ Paystack payment gateway adapter
auth/ JWT tokenizers (organizer + customer), argon2id password hashing
config/ Environment-based configuration loading and validation
infra/
migrate/ Embedded SQL migrations
retry/ Generic retry with exponential backoff
```

## Running Tests

```bash
# Domain logic only — fast, no infrastructure required
make test-unit

# Service and worker tests — spins up real Postgres and Redis via testcontainers
make test-integration

# Full end-to-end flow tests
make test-e2e

# Everything
make test
```

Integration tests run against real infrastructure. The only mock in the codebase is the Paystack gateway — because mocking the database tells you nothing about whether the unique constraint fires.

## Stack

| Layer | Technology |
|---|---|
| Language | Go |
| Database | PostgreSQL 16 |
| Cache / Queue | Redis 7 |
| Payment | Paystack |
| HTTP router | Chi |
| WebSocket | coder/websocket |
| Container | Docker + Compose |
88 changes: 88 additions & 0 deletions .dokugen-cache.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
{
"version": "1.0",
"files": {
"TRADEOFFS.md": "49c3f91e8e0cb9d7bac4a913840febf13b438f5b554825da72cd4f7d4f69f767",
".env.example": "3674e82e92d7574e25e0af688e94955aa6212816d100d7070a4ff596d6cab180",
"ARCHITECTURE.md": "db3d568f9f1797b2ee83de6a987290b938db7989671c6821cc54e8cfa6b7c5b6",
".golangci.yaml": "bdba2d37a6025a453817bdbdf40ac7db7770fc84e9de9d088f230a1ffa8ff861",
".dokugen-backup.md": "c31e1b0f42fd8fa93efea49d346f57369f2cf87f89a6b1ded068359393ae8761",
"Dockerfile.loadtest": "0fae4c563cdacb5c33f5efe771ab77d90c93e937d6adcad48e322d0bffb29fed",
"go.sum": "4f95f7cedeb1093d3ae091a766dba6c99d1af143c268d6c9dd7221b56ea4914c",
".env.loadtest.example": "7a8afd167da23961804dccbda4b0eca01aa8564ae3489df3017e211f807e5417",
"README.md": "20e49c4c5b598e4a2b4fa03ba36ab4bde268b8d4ad70570663d320a51e54956c",
"docker-compose.loadtest.yml": "8ce66568198bb7139fd29bc032ac1d56815e2da77f19a95010ae117039b0c0b5",
"go.mod": "9b6c6a466f01c034922d29f88f9af29eb7341aae03930feed2c788ca66e5da07",
"internal/store/redis/lock.go": "08a0edc238b91be2ac4c64217ba37097601f67ca839b4300130bc30eaef3186d",
"internal/store/redis/lock_test.go": "0aa952229fce8931810464dfafc8e91dc9528cd1b96ba061c77ea15e541649e9",
"internal/store/redis/queue.go": "2bdb997e0de52790b9e8655b368eeb316d194c7607ada07ec8b0af6bdfec83e9",
"internal/store/redis/client.go": "0335a6afd5ffd5381080de296c2a508da55976133776a52c32545e52eb5ba996",
"internal/store/redis/queue_test.go": "0ba9fe659ffaf4168573707bf9a4acc2298e666fe533d4830df746444cf67e31",
"internal/store/redis/inventory.go": "e69b194435d584f8abe3d477bc6ebf4af2dd3a6adb1ba13ed7cd98f77001f6d6",
"internal/store/postgres/organizer_store.go": "9ef054940f4d1f274b8ac1d355c0e3d4620609979a422aa8d5464bade64a4c02",
"internal/store/postgres/db.go": "8dea17b5ca426d804918be44c2477845ae4c654715d8509b5054de6b4254251b",
"internal/store/postgres/customer_store.go": "40436ea1d1ca84e91cb413c151677b3652f37cb975d00dcd4594862b57bde1ef",
"internal/store/postgres/db_test.go": "7f21d28ef7160e3a6b55205dfdb48d235c1db378267cf99b817c2ff8f23a7198",
"internal/store/postgres/payment_store.go": "f9a773af8d7d93d241b56b3e591ce37b8fe114986c73e93c2b1c9d889026a7c0",
"internal/store/postgres/claim_store_test.go": "10f317667905e9a4dfce3d9581467b91169d9aaf94ff13f749e3b3c759b6ecda",
"internal/store/postgres/event_store.go": "9d3658d58c0405a70679442865cf0e84b047001a88a1491dc248e67992e0eea2",
"internal/store/postgres/queue_store.go": "450f34eca3063ce48d4793b536c382f09ce446ccec1b810337252d1915f484c7",
"internal/store/postgres/claim_store.go": "95256d3744a1e036fe1b723eb459e4d2588ffecf6dd5c16af1c64803503edb38",
"internal/store/testhelpers/fixtures.go": "f46436a698b51f6d4741297fd7fdb39ec18aeb0869120c60b6254fd151ee2bbc",
"internal/store/testhelpers/migrations.go": "7008ec78983e39371e1b0a3f6f4f3a8e64808256dc00dc1a8c1c17aabf66950d",
"internal/store/testhelpers/containers.go": "e05d4b2be9717d629787af4726300000c069270a2d9b5af5520f084452e150cb",
"internal/store/testhelpers/helpers.go": "e3c1a2f2b64862c2c6eda92e13762f05e981e2fbab6aa554f784e19544fe16db",
"internal/metrics/metrics.go": "f69ef99091818e61e68bb584e6dc413a90e6d2f06a0e2f42d09936edaade2aaf",
"internal/infra/migrate/migrate.go": "2b80872daa1646eecd7ade9119ba79e69693a8cbde98d99cbc8ef86ccf5e2501",
"internal/infra/migrate/sql/000001_initial_schema.up.sql": "ff1266d881b1616d6451669476323d5c6a8d075ea0194fc67772d4a8a714520d",
"internal/infra/migrate/sql/000001_initial_schema.down.sql": "fe332643b9597da1b737dc96317b36dabffde6b03c53696f81c28514bb475db1",
"internal/infra/retry/retry.go": "fa34b680037827a28f8e49292143334a88e52cb6cbacf6eb7ea5625056790cbf",
"internal/api/hub.go": "295d4de395b27a673ab04f6f0f08019b4833799d826f06be10b47b5cab7a9723",
"internal/api/middleware.go": "80f90aa736d1e481f58685a5d03f4525ba75ee0cc221da879323f0722908973c",
"internal/api/handlers.go": "4ab7af0654e8c74621363424c698b3b1f93cbb83f8eaf9eb227fcb3d4a1352d2",
"internal/api/server.go": "5011f57911b2105ccaa961b031a319386387a2df5d56a8b0bb7de04ccf70e2fc",
"internal/service/queue_coordinator.go": "84169ff051c40d8fd8e1675ddead2e7d3c9bc104672769d266d5be3f7d173a00",
"internal/service/payments_test.go": "325b0de142b3154dfadff67d56f49cad91f3cc873b867cb9018f5aa30b4c4c86",
"internal/service/claims_test.go": "a213f685a4375f8b05265eeeabb33106497b34d888118c93d45091498f6866f7",
"internal/service/queue.go": "c77ff5bf2e0cbe31b8301af62c2d2e915cac58b3801d44a0d9bff53e0f443bbc",
"internal/service/event.go": "2b7b95dd3edad587fd794c1d77abf5129ea128846ecbaf0c8839a37b84890fd3",
"internal/service/claims.go": "700bceef2cc1772a647f580d0529a62ed38f39aa85f3f12d183ed9ad699ac71a",
"internal/service/inventory_coordinator.go": "61d6e7bde486c158158b64a2d144ead792deac7ed5f9535cb76659f11a752bcc",
"internal/service/payments.go": "0c256f3b104a0cb1e5c16b058f6ebe389f85ab2cc9acc7a6c109567b6b8c508b",
"internal/service/queue_test.go": "49ed2dc2dd96df50ba594feca2edd85955ab323315d20094fb2cb615de699e40",
"internal/domain/organizer.go": "6aa634161c0e311e9dbe55068e870497eb52791ce0c215c22a3a4c4f0e92b634",
"internal/domain/payment.go": "7e06e194de550371086b34652dec4a63f7b80b86a474832e6e779175b29caced",
"internal/domain/queue.go": "76bb51d91d6684a7268f84a807991bed725c04cedb08c549c166c89f96c1f127",
"internal/domain/event_test.go": "dbd21afc97c5a71e2a5ef2931c97a1fd71ff964572f1e2abb120e5e7e3e11caa",
"internal/domain/event.go": "39079ddbd282319a6cce4d9d7bd374928f212a593edc49a750ee2394cfc252c8",
"internal/domain/errors.go": "65b082c44f58e3a5afcf1ddd4c0c39fc5d59b19e4909c0584719bcaf717fc69c",
"internal/domain/customer.go": "f880080e655c7df5a7fc0940ed5dd146aeab7f5dca9dbf822c6204ffd04ff498",
"internal/domain/claim.go": "098f6624dc92b2fc529dbbabd6f91ec72a199c7a5ed43a2dba72ff09007c25ef",
"internal/domain/payment_test.go": "0439d01a6df7f9461e28c44727d262b3fca2f612b82314fcf2b6b2e1a23e95ff",
"internal/domain/claim_test.go": "c46d073d1786f4152c856fd8bdcca1dc89456edec44a1a33e3f45b26115e4a7d",
"internal/domain/queue_test.go": "cc0a5d5addbb24b9f329ac3776c0c73988580fc623c7b1c440a5d0c04097e2fd",
"internal/worker/reconciliation.go": "e91974823323f3effe681ba2e0d2c07b735013f2b39ec036a85d786a62e63cd9",
"internal/worker/expiry.go": "c684c95cead985dde84f0151d8f89e9f8bbbb7af91def1606569c0ab8c065961",
"internal/worker/scheduler.go": "91b3cd36f732d51417134de88a5eee308d9a24adce3d2db2b2edad8550331ee8",
"internal/worker/admission.go": "fe3082d7712c8fe3701cf9c5e93e97bbe8bae6c0ffc56bff7c79d9f30aecc819",
"internal/worker/workers_test.go": "92e0f5f74895ba64e92735294187ff9146e2a4a0a124b9d16a575cbefa91fef1",
"internal/worker/recovery.go": "121bbce850501b792766920bd094e71c9efa118198017b1938fcb5310c08f850",
"internal/auth/token.go": "c16e64bbf9f057a04b5ee6560e706e8b59c0df12727e2b3cd67bf69630d6987c",
"internal/auth/auth_additions.go": "05d7e3346632e350c7a418c0a5ce891ebc20bce0bce9dcb195ae5917d80d2ceb",
"internal/auth/token_test.go": "d8d5cce50669939bfafeae5609bae5140520bf1213c6a4c82e0b7e01dc528ed9",
"internal/gateway/payment_gateway.go": "9bc02318242b987c2fc5efcaafae10aa7e5bb8247cbcfef3899c3a8eab7f77d5",
"internal/gateway/mock/gateway.go": "dd65f4c4d622d0240765c28ea0e1c3291d472772624344df544edbd883654263",
"internal/gateway/paystack/dto.go": "494a997f18fa608f8d931599314d6abb15a3e792030be95016455722bea2f6ff",
"internal/gateway/paystack/errors.go": "9ea7afe4285764eb52c667df53e0671a8ea392e67a1fe41f15a62749af56a324",
"internal/gateway/paystack/client.go": "0946c12ceb20511bc274f7430ed6a23ea5aecd73bee72a65df708452c4aa5253",
"internal/gateway/mocks/PaymentGateway.go": "bdda40678ea04156cfbeee6e1b496ea4a6751dd9ca531b47e748f3e40d264264",
"loadtest/03_spike.js": "2d8fa4ee0733bfc4f549278e855bed97e28919b4ef325fe58b682c793fe35042",
"loadtest/generate_tokens.js": "b848d15895bc6789d934b507ce2a3e48a06571f4de06c42dd552d436a6fa65ce",
"loadtest/06_polling.js": "b5e1dd933f3ea42189ad842e30f7b18a3671bfdf141200e7a89e50d83cd5685d",
"loadtest/04_breakpoint.js": "28e6e2802694a8eb65e481e19b3422275fc5a940101a501f2b11e8f80158a4e9",
"loadtest/05_contention.js": "a428f9761fbcc5f2d8f8fc259281dabcd9ba951b13f7b2790866d24185d1d26c",
"loadtest/02_sustained.js": "f8c3eadb21f5169321d5d92e0365672a7c01f8009c6f45a9762a4995f10f822e",
"loadtest/01_baseline.js": "94909be18d4ee83f8b6fcd7cea213b8d64dfe7c36fc794382234debb7cb32384",
"cmd/api/main.go": "9e03f988644138715c31091a3107b456d2d0895cb04a4c685e2bcdeaf514d540",
"cmd/loadtest/main.go": "a78ff5b5ed837607b963c8f59f633cc58396e9d6af0055794bfba8879f7b11c9"
}
}
Loading
Loading