Skip to main content

Module write_guard

Module write_guard 

Source
Expand description

Enforces the single-write-transaction-per-store invariant.

§Why this exists

Transaction::begin_write_transaction takes an unconditional WHOLE-STORE savepoint via HashMapStore::create_savepoint, whose own doc comment states the assumption directly: taken “on the single writer thread with no concurrent writer”. rollback() and the Drop safety net on Transaction both restore that savepoint wholesale — not just the rows the transaction itself touched. That is intended behaviour for a single writer; it silently breaks the moment two write transactions on the same store are ever open at once. If a second write transaction were opened concurrently with one already in flight, the second transaction’s own commit/rollback/Drop would silently roll back to a savepoint taken before the first transaction’s edits ever existed, erasing them with no error and no event.

WriteTransactionGuard turns that assumption into an assertion. Every generated CommandUnitOfWork::begin_transaction acquires one for the entire span its write transaction may be open (see commit/rollback on any generated *WriteUoW/*UnitOfWork, which clear it). A second concurrent acquisition on the same store panics in debug builds — loud, immediate, naming the call site — and returns an error in release builds, so a shipped build degrades to a reported failure instead of silently corrupting the store.

Because the acquire/release is generated into every write-transaction call site’s begin_transaction/commit/rollback (both the per-entity direct_access WriteUoWs and the per-use-case feature UnitOfWorks), this coverage never drifts out of sync with the rest of the generated backend the way a hand-maintained list of call sites would: a new entity or use case picks up the guard the moment it is generated, with nothing left for the application developer to remember.

§Why scoped per store, not one bare global flag

A production app constructs exactly one DbContext (hence one store) for the whole process, so a single process-wide flag would be an equally correct proxy for “a write transaction is open on THE store” there. But tests often build a fresh, independent DbContext/store per #[test] fn, and cargo test’s default runner executes those fns concurrently on separate threads — two fns opening a write transaction on their OWN unrelated stores can legitimately overlap in wall-clock time. A bare process-wide flag would make those spuriously trip each other’s guard. Keying by the store’s Arc pointer identity models the real invariant (one writer per store, not one writer ever) without that flakiness — and needs no test-only carve-out to stay correct.

§Holding the Arc — no stale-pointer ABA

The map is keyed by Arc::as_ptr(db_context.get_store()), a raw address — not by anything that keeps that address meaningful on its own. Storing only that usize without cloning the Arc would be unsound: if the DbContext a guard was acquired from is dropped (its last other clone going out of scope) while the guard still lives, the allocator is free to reuse that exact address for an unrelated, brand-new HashMapStore. The new store’s first real write transaction would then find the address already marked as held — tripping the guard for a store that never had a concurrent writer — and worse, when the stale guard eventually dropped it would remove the new store’s entry, silently re-opening the door the guard exists to close.

WriteTransactionGuard therefore clones the Arc<HashMapStore> itself (the store field below) and holds that clone for its entire lifetime, in addition to using its address as the map key. Holding the clone keeps the allocation alive — the address cannot be freed, let alone reused for a different store, for as long as any guard referencing it exists. The pointer is still fine to use as a HashMap key (it is still unique among currently live stores); what changed is that the guard now makes that uniqueness durable for its own lifetime instead of merely observing it at acquire time.

§Diagnosing contention: naming both the current holder and the new claimant

Each slot records not just “held”, but who holds it: the owning thread’s ThreadId and name, plus the site string passed to acquire. A contending acquire call reads that record before failing, so the panic/error names both parties — “{new_site} on thread {new} collided with {holder_site} still held by thread {holder}” — not just the new claimant. That is enough to grep straight to the offending call site without attaching a debugger, which is the entire point of a guard whose job is to fail loudly instead of silently.

This module deliberately does not add any machinery to reclaim a slot left held by a leaked or mem::forget-ed guard (a timeout, a generation counter, a “steal the lock” escape hatch). Two things make that unnecessary rather than merely deferred:

  • The ABA fix above already rules out the scenario that would make a stale key dangerous — a leaked guard cannot cause a different, live store to be wrongly blocked, because it holds the Arc that keeps its own store’s address from ever being handed to a different store while the leak persists. A leak only ever poisons the one store it actually leaked on.
  • For that one store, “a write-transaction slot that is held and never released” and “a write transaction that is still legitimately in progress” are the same observable state from this module’s point of view. Any reclaim heuristic (e.g. “assume held-for-N-seconds means leaked”) is a race against a legitimately long transaction and would trade a loud, honest failure for an occasional silent one — reintroducing exactly the failure mode this guard exists to prevent. The recorded thread id/site above turn that failure into an immediately diagnosable one (which call site leaked it), which is the correct fix for a bug, not a runtime work-around for one.

§Re-entrant begin_transaction on the same unit of work is diagnosed honestly

Every generated CommandUnitOfWork::begin_transaction follows the same shape:

ⓘ
self.write_guard = Some(WriteTransactionGuard::acquire(&self.context, "some_use_case")?);

Assignment evaluates its right-hand side — the whole acquire call — before dropping whatever self.write_guard held previously. So if begin_transaction were ever called again on the same CommandUnitOfWork instance while its own previous guard is still Some (a re-entrant call, or a retry loop that calls begin_transaction again without going through commit/rollback first — both of which clear the field), the new acquire runs while the old guard is still registered as held. Naively that looks identical to a genuine second writer, and the resulting panic/error would misidentify a single UoW talking to itself as two concurrent writers — plausible-sounding, and wrong, which is worse than no diagnostic at all.

acquire tells the two apart using exactly the holder record described above: if the slot is already held by the current thread, the message says so explicitly — “this thread already holds this store’s slot, acquired at {holder_site}, this looks like a re-entrant/retried begin_transaction, not a second writer” — instead of the generic cross-thread contention wording. It still fails (the underlying invariant violation — two live guards on one store — is exactly as real as in the cross-thread case, and this module cannot see into the caller’s struct to silently drop the stale one for it), but the diagnostic now points at the actual bug (a UoW re-entering begin_transaction without clearing its own previous guard) instead of a phantom concurrent writer.

§RAII — cannot be defeated by an early return or a panic

WriteTransactionGuard is a plain owned struct with only a Drop impl to release its slot; there is no separate “release” method to forget to call. Binding it to a named local for the whole span a write transaction may be open means the slot is freed on every exit path — the happy path, an early ? return, or an unwinding panic — exactly like Transaction’s own Drop safety net that it sits beside.

Structs§

WriteTransactionGuard
RAII token proving “I hold the write-transaction slot for this store.” See the module doc for the full rationale.