Testing Postgres race conditions with synchronization barriers

Harnessing Postgres race conditions Harnessing Postgres race conditions Without race condition tests, every possible race condition in your system is one refactor away from hitting production. Synchronization barriers let you write those tests with confidence. What a race condition looks like You have a function that credits an account. It reads the current balance, adds an amount, and writes the new value back. When two requests run this concurrently — two $50 credits to an account with a $100 balance — the timing can line up like this: P1: SELECT balance → 100 P2: SELECT balance → 100 ── both read 100, now both write based on it ── P1: UPDATE balance = 150 P2: UPDATE balance = 150 Both read 100. Both compute 150. Both write 150. Final balance: $150 instead of $200. One $50 credit vanished. No error was raised. No transaction was rolled back. The database did exactly what it was told. This is the shape of every write race condition: two operations read the same stale value, then both write based on it. The second write overwrites the first. In a system that handles money, that’s a customer with a wrong balance and no error in any log to explain it. The testing challenge Your test suite runs one request at a time. The interleaving above never happens. The test passes whether your code handles concurrency correctly or not. Put the crediting logic in a function and run two calls concurrently: // Naive implementation — no transaction, no lock const credit = async ( accountId : number , amount : number ) => { const [ row ] = await db. execute ( sql `SELECT balance FROM accounts WHERE id = ${ accountId }` , ); const newBalance = row.balance + amount; await db. execute ( sql `UPDATE accounts SET balance = ${ newBalance } WHERE id = ${ accountId }` , ); }; await Promise . all ([ credit ( 1 , 50 ), credit ( 1 , 50 )]); expect (result.balance). toBe ( 200 ); // passes — but we know the code has a race condition You could add sleep() between the two queries to try to force the overlap. This buys you a slow, flaky test that sometimes catches the bug and sometimes doesn’t. You could run the test a thousand times and hope the timing lines up at least once. Both approaches are the same bet — you’re not testing concurrency, you’re rolling dice. What you need is a way to force two operations to read the same stale value before either writes. Every time. Not probabilistically. You know this pattern exists. You know it’s dangerous. The problem isn’t knowledge. It’s proof. Synchronization barriers A barrier is a synchronization point for concurrent operations. You tell it how many tasks to expect. Each task runs independently until it hits the barrier, then waits. When the last task arrives, all of them are released at once. function createBarrier ( count : number ) { let arrived = 0 ; const waiters : (() => void )[] = []; return async () => { arrived ++ ; if (arrived === count) { waiters. forEach (( resolve ) => resolve ()); } else { await new Promise < void >(( r

Source: Hacker News | Original Link