An invoice is open. A charging worker checks it and prepares to take payment. At the same time, a write-off operation checks that no payment is in flight and prepares to credit the invoice. Each check can pass before either operation writes its decision.
The result could be money moving against an invoice that has just been closed. This is a failure scenario, not a claim that a customer experienced it. It is the kind of race KRI, the booking SaaS I built for beauty businesses, guards against with a shared per-tenant PostgreSQL advisory lock.
Testing that guard raises a harder question: did the database actually block the competing operation, or did that operation simply start too late to cause trouble?
Three Events a Concurrency Test Must Distinguish
A useful test controls or observes three separate events:
- The first transaction has acquired the lock.
- The competing operation has reached that same lock and is waiting.
- The first transaction changes the state and releases the lock; the competitor resumes and reacts to the new state.
Starting two promises establishes none of those events by itself. Even starting a transaction first does not prove it acquired a connection, began, or reached its lock statement before the next function ran.
KRI’s existing billing suites combine held transactions, provider stubs and short timed waits. Reviewing them exposed two limits: the first lock acquisition needs its own readiness signal, and a timed negative assertion does not establish the second event. The pattern below adds the readiness signal; the following section explains what is still needed for a stronger test. It is an improved test sketch, not a claim that the existing suite already implements every step.
Signal That the Lock Is Held Before Starting the Competitor
A gate is a promise the test resolves itself:
function gate(): { wait: Promise<void>; open: () => void } {
let open!: () => void;
const wait = new Promise<void>((resolve) => {
open = resolve;
});
return { wait, open };
}Use two gates: one reports readiness, the other controls release. The fixture and full charge arguments are omitted here:
const acquired = gate();
const release = gate();
const held = db.transaction(async (tx) => {
await tx.execute(sql`SELECT pg_advisory_xact_lock(hashtext(${"billing:tenant:" + tenantId}))`);
acquired.open();
await release.wait;
});
let charging: ReturnType<typeof chargeInvoice> | undefined;
try {
// If the transaction fails before acquiring the lock, propagate that error.
await Promise.race([acquired.wait, held]);
charging = chargeInvoice(chargeArgs);
// Observe this operation waiting on this lock before changing the fixture.
// The database observation needed here is described below.
await waitForChargeToBlock();
expect(chargeRecurring).not.toHaveBeenCalled();
await db
.update(billingDocuments)
.set({ status: "cancelled", settlementState: "hold" })
.where(eq(billingDocuments.id, documentId));
release.open();
await held;
const result = await charging;
expect(result.kind).toBe("skipped");
expect(chargeRecurring).not.toHaveBeenCalled();
} finally {
release.open();
await Promise.allSettled([held, ...(charging ? [charging] : [])]);
}The fixture update deliberately uses a separate connection. It simulates the state the competing operation must see after the lock is released; it does not exercise the real credit-note function. That function needs its own tests. The finally block ensures an assertion failure does not leave the held transaction parked indefinitely. Database statement and lock timeouts should also bound failures in the test environment.
Observe Blocking Instead of Assuming Enough Time Passed
waitForChargeToBlock() above is a test-harness requirement, not a built-in Drizzle helper. Its implementation must identify the backend running this particular charge transaction. A test-only hook can report its pg_backend_pid() before lock acquisition; another connection can then inspect that backend.
PostgreSQL reports advisory-lock waits through pg_stat_activity as wait_event_type = 'Lock' and wait_event = 'advisory'. Check the target PID and use pg_blocking_pids(target_pid) to confirm that the holder is the test’s transaction. An unrelated session waiting on some other advisory lock is not evidence. Both facilities are documented in PostgreSQL’s monitoring guide and system information functions.
Poll with a deadline and fail if the expected state never appears. A short delay between polls only limits query frequency; it is the observed database state that satisfies the assertion. Size the pool for the holder, the competing transaction and the observer/update connection. With only two connections, the test can exhaust its own pool before it ever releases the gate.
The existing suite instead waits 400 milliseconds and asserts that the provider has not been called. On a slow runner, that can pass because the charge has not reached the lock yet. Its final skipped result can also pass after the fixture changes, even if the lock was removed. A readiness gate fixes the first ordering problem; observing the blocked backend closes this second gap.
As a sanity check, deliberately remove the guard in a disposable test change: the test should fail because the expected blocking never occurs. A concurrency test that stays green when its protection disappears is testing the wrong thing.
The Reverse Direction: Money May Already Be Moving
Once a charge reaches the provider, crediting the invoice must account for the possibility that payment will succeed. Here a provider stub gives the test a precise event to wait for:
const sent = gate();
const answered = gate();
chargeRecurring.mockImplementation(async () => {
sent.open();
await answered.wait;
return providerOpenPaymentFixture;
});
const charging = chargeInvoice(chargeArgs);
try {
await Promise.race([sent.wait, charging]);
expect(chargeRecurring).toHaveBeenCalledTimes(1);
await expect(issueCreditNote(documentId, issuedOn)).rejects.toThrow(/in flight/);
} finally {
answered.open();
await charging;
}KRI records the pending attempt before making that call. The credit-note operation takes the shared lock, reads the attempt and refuses to close the invoice while money may be moving. The stub controls the network boundary while the database operations remain real.
Reconciliation uses another controlled boundary. A stubbed provider-history response can perform a competing settlement write before returning. The test then checks that reconciliation does not overwrite a newly settled attempt with a stale conclusion that no payment was found.
Keep PostgreSQL Real, Keep Stress Tests in Their Place
The lock manager, transaction snapshots and partial unique index that permits one live payment attempt per invoice are PostgreSQL behaviour. A mocked database cannot demonstrate that those protections work together. Stub the provider’s network calls; run the transactions against an isolated real database.
A Promise.allSettled loop is still useful alongside controlled tests. It can check that charging and crediting never both succeed, or that cancellation and renewal produce one allowed outcome. It cannot guarantee that any run reached the dangerous ordering. Ten green runs are evidence about those ten runs, not proof that a narrow race is impossible.
Use this machinery where an ordering mistake has a meaningful consequence: billing, limited inventory or booking slots. A conditional atomic update may remove a particular check-then-write gap, though workflows around it can still race. One administrator or one scheduled job is not proof of serial execution: retries, multiple tabs and overlapping job runs can still produce concurrent requests.
The useful promise of a concurrency test is specific: it establishes the ordering being tested, observes the guard, and checks the result after release. If one of those steps is inferred from elapsed time, say so.
Building a billing or booking feature? Get in touch to scope the failure cases and verification alongside the MVP implementation.
Further reading:








