2026
Featured ★PayoutEngine — Concurrency-Safe Payout System
Prevents double-spending when two payout requests hit the same merchant balance at the same instant — verified with real threading tests, not mocks.
The Problem
International agencies and freelancers in India receive USD payments and need to withdraw to INR bank accounts. The core failure mode I wanted to eliminate: two simultaneous payout requests both check the balance, both see it as sufficient, both deduct — and the merchant is overdrawn. Failed bank settlements also need to release held funds cleanly, not leak them.
What I Built
I gave merchants no mutable balance field at all — balance is always SUM(credits) − SUM(debits), computed live from an immutable ledger table on every request. Payouts move through a strict state machine I enforce at the model level (pending → processing → completed/failed), and every balance check during payout creation takes a pessimistic row lock (select_for_update) to close the race condition at the database level rather than trying to catch it after the fact.
React Frontend ──▶ Django REST API ──▶ PostgreSQL (Ledger table)
│ ▲
▼ │
Celery Workers ────────── Redis (broker)
│
▼
Bank Settlement (simulated)Key Decisions & Tradeoffs
- I chose pessimistic locking over optimistic — it closes the double-spend race completely, at the cost of reduced throughput under high concurrency. For a payout system, I decided correctness beats speed.
- I built an immutable ledger with live aggregation — every balance read recomputes from the ledger instead of trusting a cached field. More expensive reads, but the balance can never drift out of sync with its own history.
- I used atomic claim-and-set for Celery workers — prevents the same payout from being processed twice if two workers pick it up simultaneously.
- I set a 24-hour idempotency key expiry — protects against duplicate submissions within a session, while still letting a genuinely new attempt go through after a day.
Highlights
- Immutable ledger with no mutable balance field — balance is always SUM(credits) − SUM(debits), computed live on every request
- Pessimistic row locking (select_for_update) closes the double-spend race at the database level
- Verified with real Python threading tests: two simultaneous 6,000 paise requests against 10,000 — exactly one succeeds, no mocks
- Strict state machine enforced at the model level (pending → processing → completed/failed) with atomic claim-and-set for Celery workers
- 24-hour idempotency key expiry protects against duplicate submissions while allowing legitimate retries