Same Invariant, Same Lock
Jun 30, 2026
When several different code paths write to the same shared invariant, making one of them safe in isolation does almost nothing. The invariant is only as protected as its least careful writer — and "atomic" means very little if each writer is atomic in its own separate way.
The problem
We had a shared ordering invariant: a set of items whose positions had to stay dense, unique, and correctly sequenced within their owner. An earlier piece of work had already built a safe way to insert into that ordering — a single database transaction that took a lock, checked an ownership predicate, inserted at the right position, and ran a dense repair pass so the sequence never developed gaps or collisions.
The catch was that not every writer used it. Several insert paths predated the safe model and were never brought into it:
- One single-item insert read the current maximum position, added one, and inserted — three separate statements, no lock.
- A bulk insert wrote raw positions directly, with no lock and no repair.
- A copy path used an unlocked "highest position plus one."
Each of these looked fine on its own and worked nearly all the time. But they all wrote to the same invariant as the safe path, and they coordinated through nothing. Under concurrency, two of them could read the same maximum position and both claim it. It was tempting to frame the fix as "insert at the right position." That framing was wrong. The real problem was that a shared invariant had several owners and no shared coordination.
What actually happened
The first instinct was to make each broken writer "atomic" on its own — wrap each one in its own transaction, take a lock, done. But that quietly reintroduces the same bug in a subtler form. If each writer locks a different thing — a different key, a different row, a different scope — then they never actually serialize against each other. You get three atomic writers that still race. Atomicity is not a property of one writer. For a shared invariant it is a property of all the writers coordinating through the same lock.
So the accepted fix didn't invent a new safe path. It routed every in-scope writer through the model that already existed: the same transaction boundary, the same lock key, the same ownership predicate, and the same dense repair step. Same invariant, same lock — literally the same one, not an equivalent-looking one.
Two decisions made this harder than a find-and-replace.
Scope is part of the invariant. Not every insert that looked similar belonged in the protected model. Child items, a different kind of grouping, and inserts that weren't part of the shared ordering all had their own, legitimately different semantics. Pulling those into the shared lock would have been as wrong as leaving the real writers out. The work was as much about deciding which writers don't join as which ones do — and saying so explicitly, with one canonical sentence describing the predicate that defines membership.
The bulk path had a second, hidden bug. Fixing positions wasn't enough, because the bulk insert also mapped its results back to inputs by array index — "the third created row corresponds to the third input." The moment inserts are reordered or repaired inside the database, position in the returned array stops being a reliable identity. Correct ordering and correct identity are two different problems; solving one exposed the other. The fix needed a stable identifier carried through, not a positional guess.
The lesson
A shared invariant has no partial owners. If five code paths can write to the same ordered set, then the safety of that set is defined by all five, coordinating through one mechanism — not by whichever one you happened to harden most recently. Making a single writer transactional feels like progress and often isn't, because the failure mode you actually care about is two writers interleaving, and a writer that locks its own private thing doesn't interleave any more safely than before.
"Same lock" has to be taken literally. Two writers that each take a lock, correctly, inside their own transaction, are still unsynchronized if those locks are different. A shared invariant needs a shared coordination key — owned in one place, used by everyone who touches it.
The broader principle
When you find one unsafe writer to a shared piece of state, resist the urge to fix only that writer. Inventory every writer first. Then bring all the in-scope ones onto identical machinery — same transaction boundary, same lock, same ownership check, same repair — and be just as deliberate about which writers stay out. A safe path that only some writers use is not a safe path; it's a safe path plus a set of unguarded back doors.
Two honest caveats from doing this:
- Static review is not a concurrency proof. You can read the code and confirm that every writer now takes the same lock, and still not have proven serialization under real, simultaneous load. That is a separate piece of verification — keep it on the list rather than letting "the code looks right" stand in for "we proved it races safely."
- Bundled problems should stay separate even when you fix them together. The copy path had both an ordering bug and a separate question about keeping links consistent in both directions. Fixing the ordering doesn't fix the linkage, and pretending the two are one problem just hides the one you didn't solve.
How to apply it
- When you find one unsafe writer to a shared invariant, list all the writers before fixing any of them. The bug is usually "this set is written from more places than anyone tracked," not "this one function is wrong."
- Pick one canonical sentence that defines the invariant and who it covers. Use that exact predicate in the code, the tests, and the review — including to justify which writers are deliberately excluded.
- Route every in-scope writer through the same transaction and the same lock key. Not equivalent logic — the same mechanism, owned in one place.
- Be explicit about exclusions. Adjacent writers that look similar but follow different rules should stay out, and that decision should be written down, not implied.
- Separate ordering from identity. If a bulk operation maps results back to inputs, map by a stable identifier, never by position in a returned array.
- Don't let "the code now takes the right lock" substitute for proving it serializes under real concurrent load. Static review and a live race test are different evidence.
- When a flow carries two problems — say, ordering and link consistency — fix what's in scope and name the rest as separate work, rather than letting one fix imply the other was handled.