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
Arcthat 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§
- Write
Transaction Guard - RAII token proving “I hold the write-transaction slot for this store.” See the module doc for the full rationale.