padosoft-atomic-invariants

Use this skill whenever code enforces something that must hold only once or only so many times — a single-use code or coupon, a nonce, a rate or quota limit, "the first one wins", a stock reservation,

SKILL.md

GitHub

Atomic invariants

Either the invariant is recorded in the same atomic step that checked it, or the invariant does not exist.

Everything else — a check a few lines above the write, a lock released before the update, a "nobody clicks twice" assumption — is a race that shows up under exactly the conditions you care about: a retry, a double tap, a load spike, two workers on the same queue.


1. The shape of the bug

// ❌ two requests both read "not used yet" and both proceed
$coupon = Coupon::whereCode($code)->firstOrFail();
abort_if($coupon->used_at !== null, 409);
$coupon->update(['used_at' => now()]);

The gap between the read and the write is where the second request fits. It does not need to be wide: two requests arriving in the same millisecond is the normal case for a double tap on a phone, a retried mobile request, or two queue workers.

// ✅ read and write in the same transaction, with the row locked
DB::transaction(function () use ($code) {
    $coupon = Coupon::whereCode($code)->lockForUpdate()->firstOrFail();
    abort_if($coupon->used_at !== null, 409);
    $coupon->update(['used_at' => now()]);
});

The lock is held until the invariant is recorded. A lockForUpdate() whose update() happens after the closure returns bought nothing at all.

2. Back it with a constraint where the rule demands it

A transaction is the fast path. A unique constraint is the one that is still true under a deploy, a replica that lags, a second service that writes the same table, or a code path someone adds next year and forgets to wrap.

ALTER TABLE coupon_redemptions ADD UNIQUE KEY uq_coupon_once (coupon_id);

Then treat the constraint violation as a normal outcome, not as a 500: catch it and return the same answer the check would have returned. A constraint you never expect to fire is a constraint you never handled.

The rule of thumb: if two rows would be a business problem, the database says so. If they would only be untidy, the transaction is enough.

3. Check the write actually happened

$affected = Coupon::whereCode($code)->whereNull('used_at')->update(['used_at' => now()]);
if ($affected === 0) {
    // somebody else got there first — this is the answer, not an error
}

A conditional update that returns its affected-row count is often simpler than a lock, and it is atomic by construction: the condition and the write are one statement. Then read the count. Ignoring it turns the whole pattern back into a check-then-act.

Same for ownership checks inside a transaction: verify, write, and confirm the row count before answering success.

4. Idempotency is the same rule, seen from the caller

Mobile networks retry. Queues retry. Users double tap. So a mutating operation that must happen once takes an idempotency key generated by the caller at the moment of intent, and the server records that key in the same transaction as the effect. The second request with the same key returns the first result instead of doing the work again.

A key generated server-side per request is not an idempotency key — it is a request id.

5. Where the check must not be

  • Not in the client. A disabled button, a guard in the UI, a confirmation dialog: those are user experience. The invariant is enforced where the write happens.
  • Not in a cache. A "have we seen this already" lookup in a cache without a durable record is a hint, not a control, and it fails open on a restart or an eviction.
  • Not in a separate service call whose result is used a few lines later — that is a check-then-act with a network hop in the gap.

5b. Locks and windows on a cache store

A cache lock serialises work that has no row to lock, and a cache counter bounds a rate. Five details decide whether they hold.

  • The key is the protected resource. Locks with different keys do not exclude each other, so a key built from the request or the worker protects nothing. Build it from the thing being changed.
  • The TTL must exceed the longest critical section. There is no renewal: a lock that expires mid-work is handed to a second holder while the first is still writing.
  • The block-wait time is a separate, short parameter. Waiting as long as the lock may be held pins a worker for that whole time. Wait briefly, then fail or retry the job.
  • The array store is process-local, so it gives no exclusion across workers. A test that uses it passes and proves nothing; exclusion needs a store the workers share.
  • A rate window is add() then increment(), never get() then put(): the second pair races, and each put() slides the TTL so the window never closes. Two calls still leave one gap: if the key expires between them, increment() recreates it with no TTL and the counter never resets, so a cap, once reached, blocks for good. Repair it atomically: one script that increments and sets the expiry when the counter is new, or, where the store has it, an expire-if-none command after the increment (Redis 7 EXPIRE key ttl NX), which sets the expiry only when there is none and leaves the count alone. Do not repair it by put()-ing the value you observed: it overwrites the increments made in the meantime and lets a third request through a cap of two. increment() can also return false; compared with the cap, false reads as zero and lets everything through.

6. When failing open is the right call — and when it is not

A rate limiter that fails open when its store is unavailable is a deliberate and usually correct choice: rate limiting is an availability control, and failing it closed turns a cache blip into a full outage. Authorization sits upstream and does not depend on it.

A cap on cost or egress — paid calls, outbound sends, bytes leaving the system — looks the same in code and is not the same control: it protects a bill or a third party, so it fails closed.

A single-use invariant that fails open is a defect, always: it means the thing that must happen once can happen twice.

Write down which of the two you are building, next to the code. The difference is not visible from the implementation, and the next person will assume the wrong one.


How to find it in a diff

# a check followed by an update, no transaction in sight
rg -n -B2 -A6 "(firstOrFail|first\(\)|find\()" app/ src/ | rg -n "update\(|save\(" | head
# locks whose transaction boundary is unclear
rg -n "lockForUpdate|FOR UPDATE|SELECT .* FOR SHARE" app/ src/
# update() whose return value is discarded
rg -n "^\s*(\\\$\w+->)?update\(" app/ | rg -v "^\s*\\\$\w+\s*="
# single-use wording without a constraint nearby
rg -n -i "used_at|redeemed|consumed|one[_-]?time|nonce|single[_-]?use" app/ src/ database/

Gotchas

  • A transaction that spans a network call holds locks for the duration of that call. Do the external work outside; keep the invariant's read and write inside.
  • The framework's "unique" validation rule is not a constraint. It is a query before the insert, which is exactly the race this skill is about.
  • Two workers is not a hypothetical. Any queue with concurrency above one, any horizontally scaled app, any retry — the second execution is the normal case, not the edge.
  • A test cannot usually reproduce this alone. Test the pieces: assert the constraint exists, assert the conditional update returns zero on the second call, assert the violation is handled as a business outcome.
  • "It has never happened" is a statement about traffic, not about correctness. It starts happening on the day the campaign goes out.

Checklist

  • The read of the invariant and the write that records it are in one atomic step
  • The lock (if any) is held until after the write, inside the same transaction
  • A unique constraint backs the rule where two rows would be a business problem
  • The constraint violation is handled as an outcome, not as a 500
  • Affected-row counts are read, not discarded
  • Idempotency key generated by the caller, recorded with the effect
  • Cache locks: key is the resource, TTL above the longest section, no exclusion on the array store
  • Rate windows use add() then increment(); a counter that lost its expiry gets it back without its count being rewritten (script, or expire-if-none); the return of increment() is checked
  • Fail-open vs fail-closed is a written decision, next to the code

Final report

Invariant: <what must hold once>
Enforced: <transaction + lock | conditional update + row count | unique constraint>
Constraint present: yes <name> | no, because <reason>
Violation path: <what the caller gets>
Idempotency: <key source, where recorded> | not applicable
Fail mode under store outage: open | closed — and why