common/database/write_guard.rs
1// Generated by Qleany v1.11.0 from write_guard.tera
2
3//! Enforces the single-write-transaction-per-store invariant.
4//!
5//! ## Why this exists
6//!
7//! [`Transaction::begin_write_transaction`](super::transactions::Transaction::begin_write_transaction)
8//! takes an unconditional WHOLE-STORE savepoint via
9//! `HashMapStore::create_savepoint`, whose own doc comment states the
10//! assumption directly: taken "on the single writer thread with no
11//! concurrent writer". `rollback()` and the `Drop` safety net on
12//! `Transaction` both restore that savepoint wholesale — not just the rows
13//! the transaction itself touched. That is intended behaviour for a single
14//! writer; it silently breaks the moment two write transactions on the same
15//! store are ever open at once. If a second write transaction were opened
16//! concurrently with one already in flight, the second transaction's own
17//! `commit`/`rollback`/`Drop` would silently roll back to a savepoint taken
18//! *before* the first transaction's edits ever existed, erasing them with no
19//! error and no event.
20//!
21//! [`WriteTransactionGuard`] turns that assumption into an assertion. Every
22//! generated `CommandUnitOfWork::begin_transaction` acquires one for the
23//! entire span its write transaction may be open (see `commit`/`rollback` on
24//! any generated `*WriteUoW`/`*UnitOfWork`, which clear it). A second
25//! concurrent acquisition **on the same store** panics in debug builds —
26//! loud, immediate, naming the call site — and returns an error in release
27//! builds, so a shipped build degrades to a reported failure instead of
28//! silently corrupting the store.
29//!
30//! Because the acquire/release is generated into every write-transaction
31//! call site's `begin_transaction`/`commit`/`rollback` (both the per-entity
32//! `direct_access` `WriteUoW`s and the per-use-case feature `UnitOfWork`s),
33//! this coverage never drifts out of sync with the rest of the generated
34//! backend the way a hand-maintained list of call sites would: a new entity
35//! or use case picks up the guard the moment it is generated, with nothing
36//! left for the application developer to remember.
37//!
38//! ## Why scoped per store, not one bare global flag
39//!
40//! A production app constructs exactly one `DbContext` (hence one store) for
41//! the whole process, so a single process-wide flag would be an equally
42//! correct proxy for "a write transaction is open on THE store" there. But
43//! tests often build a fresh, independent `DbContext`/store per `#[test]`
44//! fn, and `cargo test`'s default runner executes those fns concurrently on
45//! separate threads — two fns opening a write transaction on their OWN
46//! unrelated stores can legitimately overlap in wall-clock time. A bare
47//! process-wide flag would make those spuriously trip each other's guard.
48//! Keying by the store's `Arc` pointer identity models the real invariant
49//! (one writer *per store*, not one writer *ever*) without that flakiness —
50//! and needs no test-only carve-out to stay correct.
51//!
52//! ## Holding the `Arc` — no stale-pointer ABA
53//!
54//! The map is keyed by `Arc::as_ptr(db_context.get_store())`, a raw address —
55//! not by anything that keeps that address meaningful on its own. Storing
56//! only that `usize` without cloning the `Arc` would be unsound: if the
57//! `DbContext` a guard was acquired from is dropped (its last other clone
58//! going out of scope) while the guard still lives, the allocator is free to
59//! reuse that exact address for an unrelated, brand-new `HashMapStore`. The
60//! new store's *first* real write transaction would then find the address
61//! already marked as held — tripping the guard for a store that never had a
62//! concurrent writer — and worse, when the stale guard eventually dropped it
63//! would remove the *new* store's entry, silently re-opening the door the
64//! guard exists to close.
65//!
66//! [`WriteTransactionGuard`] therefore clones the `Arc<HashMapStore>` itself
67//! (the `store` field below) and holds that clone for its entire lifetime, in
68//! addition to using its address as the map key. Holding the clone keeps the
69//! allocation alive — the address cannot be freed, let alone reused for a
70//! different store, for as long as any guard referencing it exists. The
71//! pointer is still fine to use as a `HashMap` key (it is still unique among
72//! *currently live* stores); what changed is that the guard now makes that
73//! uniqueness durable for its own lifetime instead of merely observing it at
74//! acquire time.
75//!
76//! ## Diagnosing contention: naming both the current holder and the new claimant
77//!
78//! Each slot records not just "held", but who holds it: the owning thread's
79//! [`ThreadId`] and name, plus the `site` string passed
80//! to `acquire`. A contending `acquire` call reads that record before failing,
81//! so the panic/error names **both** parties — "`{new_site}` on thread {new}
82//! collided with `{holder_site}` still held by thread {holder}" — not just the
83//! new claimant. That is enough to `grep` straight to the offending call site
84//! without attaching a debugger, which is the entire point of a guard whose
85//! job is to fail loudly instead of silently.
86//!
87//! This module deliberately does **not** add any machinery to reclaim a slot
88//! left held by a leaked or `mem::forget`-ed guard (a timeout, a generation
89//! counter, a "steal the lock" escape hatch). Two things make that
90//! unnecessary rather than merely deferred:
91//!
92//! * The ABA fix above already rules out the scenario that would make a stale
93//! key *dangerous* — a leaked guard cannot cause a *different, live* store to
94//! be wrongly blocked, because it holds the `Arc` that keeps its own store's
95//! address from ever being handed to a different store while the leak
96//! persists. A leak only ever poisons the one store it actually leaked on.
97//! * For that one store, "a write-transaction slot that is held and never
98//! released" and "a write transaction that is still legitimately in
99//! progress" are the same observable state from this module's point of view.
100//! Any reclaim heuristic (e.g. "assume held-for-N-seconds means leaked") is a
101//! race against a legitimately long transaction and would trade a loud,
102//! honest failure for an occasional silent one — reintroducing exactly the
103//! failure mode this guard exists to prevent. The recorded thread id/site
104//! above turn that failure into an immediately diagnosable one (which call
105//! site leaked it), which is the correct fix for a bug, not a runtime
106//! work-around for one.
107//!
108//! ## Re-entrant `begin_transaction` on the same unit of work is diagnosed honestly
109//!
110//! Every generated `CommandUnitOfWork::begin_transaction` follows the same
111//! shape:
112//!
113//! ```ignore
114//! self.write_guard = Some(WriteTransactionGuard::acquire(&self.context, "some_use_case")?);
115//! ```
116//!
117//! Assignment evaluates its right-hand side — the whole `acquire` call —
118//! *before* dropping whatever `self.write_guard` held previously. So if
119//! `begin_transaction` were ever called again on the *same* `CommandUnitOfWork`
120//! instance while its own previous guard is still `Some` (a re-entrant call, or
121//! a retry loop that calls `begin_transaction` again without going through
122//! `commit`/`rollback` first — both of which clear the field), the new
123//! `acquire` runs while the old guard is still registered as held. Naively that
124//! looks identical to a genuine second writer, and the resulting panic/error
125//! would misidentify a single UoW talking to itself as two concurrent writers
126//! — plausible-sounding, and wrong, which is worse than no diagnostic at all.
127//!
128//! `acquire` tells the two apart using exactly the holder record described
129//! above: if the slot is already held **by the current thread**, the message
130//! says so explicitly — "this thread already holds this store's slot,
131//! acquired at `{holder_site}`, this looks like a re-entrant/retried
132//! `begin_transaction`, not a second writer" — instead of the generic
133//! cross-thread contention wording. It still fails (the underlying invariant
134//! violation — two live guards on one store — is exactly as real as in the
135//! cross-thread case, and this module cannot see into the caller's struct to
136//! silently drop the stale one for it), but the diagnostic now points at the
137//! actual bug (a UoW re-entering `begin_transaction` without clearing its own
138//! previous guard) instead of a phantom concurrent writer.
139//!
140//! ## RAII — cannot be defeated by an early return or a panic
141//!
142//! [`WriteTransactionGuard`] is a plain owned struct with only a `Drop` impl to
143//! release its slot; there is no separate "release" method to forget to call.
144//! Binding it to a named local for the whole span a write transaction may be
145//! open means the slot is freed on every exit path — the happy path, an early
146//! `?` return, or an unwinding panic — exactly like `Transaction`'s own `Drop`
147//! safety net that it sits beside.
148
149use std::collections::HashMap;
150use std::sync::{Arc, Mutex};
151use std::thread::ThreadId;
152
153use crate::database::db_context::DbContext;
154use crate::database::hashmap_store::HashMapStore;
155
156/// Who currently holds a store's write-transaction slot — enough to name them
157/// in a contention panic/error (see the module doc's "Diagnosing contention"
158/// section) instead of just saying "someone already holds it".
159#[derive(Clone, Debug)]
160struct Holder {
161 thread_id: ThreadId,
162 thread_name: Option<String>,
163 site: &'static str,
164}
165
166impl Holder {
167 fn describe(&self) -> String {
168 match &self.thread_name {
169 Some(name) => format!("{name:?} ({:?})", self.thread_id),
170 None => format!("<unnamed> ({:?})", self.thread_id),
171 }
172 }
173}
174
175/// Which stores (keyed by `Arc<HashMapStore>` pointer identity) currently have a
176/// write transaction in flight, and who holds each one. `None` until the first
177/// acquire; lazily initialised rather than built from a const-unfriendly
178/// `HashMap::new()` in a `static` initializer.
179static WRITERS: Mutex<Option<HashMap<usize, Holder>>> = Mutex::new(None);
180
181/// RAII token proving "I hold the write-transaction slot for this store." See
182/// the module doc for the full rationale.
183#[must_use = "the slot is released when this drops — binding it to `_` drops it \
184 immediately and defeats the guard; bind it to a named local (e.g. \
185 `write_guard`) that lives for the whole write-transaction span"]
186#[derive(Debug)]
187pub struct WriteTransactionGuard {
188 key: usize,
189 /// Never read directly — its only job is to keep the store's allocation
190 /// (and hence `key`'s validity as a pointer identity) alive for as long as
191 /// this guard exists. See the module doc's "Holding the `Arc`" section.
192 #[allow(dead_code)]
193 store: Arc<HashMapStore>,
194}
195
196impl WriteTransactionGuard {
197 /// Claim the write-transaction slot for `db_context`'s store. `site` names
198 /// the call site, used only in the panic/error message.
199 ///
200 /// # Panics
201 /// In debug builds, panics immediately if this store's slot is already
202 /// held — some other write transaction is already open on it. Turns the
203 /// silent-data-loss failure mode this guards against into a loud one, at
204 /// the exact call site that introduced the second writer (or, for a
205 /// same-thread re-entry, the call site that never released its own
206 /// previous guard — see the module doc).
207 ///
208 /// # Errors
209 /// In release builds, returns an error instead of panicking — a shipped
210 /// build should degrade to a reported failure, not abort the process.
211 pub fn acquire(db_context: &DbContext, site: &'static str) -> anyhow::Result<Self> {
212 // Clone (not just dereference) the `Arc`: the guard keeps this clone
213 // alive for its whole lifetime so `key` can never be handed out to a
214 // different store by the allocator while a guard referencing it
215 // exists. See the module doc's "Holding the `Arc`" section.
216 let store = Arc::clone(db_context.get_store());
217 let key = Arc::as_ptr(&store) as usize;
218
219 let current_thread = std::thread::current();
220 let current_thread_id = current_thread.id();
221 let current_thread_name = current_thread.name().map(str::to_owned);
222
223 // Take the lock, mutate, and drop it again *before* possibly panicking —
224 // panicking while holding a `std::sync::Mutex` poisons it for every
225 // other store's acquire/drop for the rest of the process, which would
226 // turn one store's legitimate bug into spurious failures for every
227 // other, wholly unrelated store.
228 let existing_holder = {
229 let mut writers = WRITERS.lock().unwrap_or_else(|e| e.into_inner());
230 let map = writers.get_or_insert_with(HashMap::new);
231 match map.get(&key) {
232 Some(holder) => Some(holder.clone()),
233 None => {
234 map.insert(
235 key,
236 Holder {
237 thread_id: current_thread_id,
238 thread_name: current_thread_name,
239 site,
240 },
241 );
242 None
243 }
244 }
245 };
246
247 if let Some(holder) = existing_holder {
248 let new_thread_desc = current_thread.name().map_or_else(
249 || format!("{current_thread_id:?}"),
250 |n| format!("{n:?} ({current_thread_id:?})"),
251 );
252
253 let message = if holder.thread_id == current_thread_id {
254 format!(
255 "WriteTransactionGuard: `{site}` (thread {new_thread_desc}) tried to open a \
256 write transaction on a store THIS SAME THREAD already holds the slot for — \
257 acquired earlier at `{holder_site}` and never released. This is NOT a \
258 second concurrent writer; it is a re-entrant or retried `begin_transaction` \
259 on the same (or a nested) unit of work whose previous \
260 `WriteTransactionGuard` was never dropped — either a missing \
261 commit()/rollback() before the retry, or `self.write_guard = Some(new)` \
262 built `new` (running `acquire` again) before the old guard's `Drop` had a \
263 chance to run. Drop the previous guard (commit/rollback it, or explicitly \
264 `self.write_guard = None;`) before acquiring a new one.",
265 holder_site = holder.site,
266 )
267 } else {
268 format!(
269 "WriteTransactionGuard: a second write transaction started at `{site}` \
270 (thread {new_thread_desc}) while another was already open on this store, \
271 held by thread {holder} since `{holder_site}`. \
272 Transaction::begin_write_transaction takes a WHOLE-STORE savepoint \
273 (common/src/database/transactions.rs) — a second concurrent writer's \
274 rollback/Drop would silently erase the first writer's committed edits. \
275 Fix the caller; do not remove or weaken this guard.",
276 holder = holder.describe(),
277 holder_site = holder.site,
278 )
279 };
280
281 if cfg!(debug_assertions) {
282 panic!("{message}");
283 }
284 anyhow::bail!(message);
285 }
286
287 Ok(WriteTransactionGuard { key, store })
288 }
289}
290
291impl Drop for WriteTransactionGuard {
292 fn drop(&mut self) {
293 if let Some(map) = WRITERS.lock().unwrap_or_else(|e| e.into_inner()).as_mut() {
294 map.remove(&self.key);
295 }
296 }
297}
298
299#[cfg(test)]
300mod tests {
301 use super::*;
302
303 fn ctx() -> DbContext {
304 DbContext::new().unwrap()
305 }
306
307 /// Extracts the panic payload as a string regardless of whether it was
308 /// boxed as `&'static str` (a literal) or `String` (an interpolated
309 /// `format!`/`panic!("{message}")` payload, which is what this module's
310 /// contention messages are).
311 fn panic_message(payload: &(dyn std::any::Any + Send)) -> String {
312 if let Some(s) = payload.downcast_ref::<&str>() {
313 (*s).to_string()
314 } else if let Some(s) = payload.downcast_ref::<String>() {
315 s.clone()
316 } else {
317 panic!("panic payload was neither &str nor String");
318 }
319 }
320
321 /// Drive a contending `acquire` and return its failure message, whichever
322 /// way this build reports failure.
323 ///
324 /// `acquire` deliberately fails *differently* per profile — it panics
325 /// under `debug_assertions` and returns an error without them — while
326 /// producing an identical message either way. Tests that hard-coded the
327 /// debug behaviour would fail the moment a generated app ran
328 /// `cargo test --release`, and since this module is generated, a
329 /// downstream developer could not fix those tests without their edit
330 /// being erased by the next `qleany generate`. Absorbing the difference
331 /// here keeps the suite green under both profiles and, more usefully,
332 /// means the release path is genuinely exercised rather than assumed.
333 fn contention_message(db: &DbContext, site: &'static str) -> String {
334 let outcome = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
335 WriteTransactionGuard::acquire(db, site)
336 }));
337
338 match outcome {
339 Err(payload) => {
340 // `if !cfg!(..) { panic!(..) }` rather than `assert!(cfg!(..), ..)`:
341 // the condition is a compile-time constant, which clippy's
342 // `assertions_on_constants` rejects under `-D warnings`. The
343 // check is worth keeping — it is what proves the profile and the
344 // observed behaviour agree — so it is restated, not removed.
345 if !cfg!(debug_assertions) {
346 panic!(
347 "`{site}`: a release build must report contention by returning an \
348 error, not by panicking"
349 );
350 }
351 panic_message(payload.as_ref())
352 }
353 Ok(Err(error)) => {
354 if cfg!(debug_assertions) {
355 panic!(
356 "`{site}`: a debug build must report contention by panicking loudly \
357 at the offending call site, not by returning an error"
358 );
359 }
360 format!("{error}")
361 }
362 Ok(Ok(_)) => panic!(
363 "`{site}` acquired a slot that was already held — the guard did not fire \
364 at all, which is the exact silent failure it exists to prevent"
365 ),
366 }
367 }
368
369 #[test]
370 fn sequential_acquire_release_never_trips_the_guard() {
371 let db = ctx();
372 for _ in 0..3 {
373 let g = WriteTransactionGuard::acquire(&db, "test").unwrap();
374 drop(g);
375 }
376 }
377
378 #[test]
379 fn two_different_stores_can_each_acquire_independently() {
380 let a = ctx();
381 let b = ctx();
382 let _ga = WriteTransactionGuard::acquire(&a, "a").unwrap();
383 let _gb = WriteTransactionGuard::acquire(&b, "b").unwrap();
384 // No panic reaching here is the assertion: two unrelated stores must
385 // never contend on each other's slot.
386 }
387
388 #[test]
389 fn dropping_the_first_guard_frees_the_slot_for_a_second_acquire() {
390 let db = ctx();
391 let first = WriteTransactionGuard::acquire(&db, "first").unwrap();
392 drop(first);
393 let second = WriteTransactionGuard::acquire(&db, "second");
394 assert!(
395 second.is_ok(),
396 "the slot must be free once the first guard dropped"
397 );
398 }
399
400 #[test]
401 fn a_second_concurrent_acquire_on_the_same_store_fails() {
402 let db = ctx();
403 let _first = WriteTransactionGuard::acquire(&db, "first").unwrap();
404 let message = contention_message(&db, "second");
405 assert!(
406 message.contains("second"),
407 "message must name the call site that was refused: {message}"
408 );
409
410 // The failure must not have poisoned the global map for anyone else: a
411 // fresh, unrelated store can still acquire immediately afterward.
412 let other = ctx();
413 assert!(WriteTransactionGuard::acquire(&other, "other").is_ok());
414 }
415
416 #[test]
417 fn the_guard_keeps_the_store_allocation_alive_after_the_db_context_drops() {
418 let db = ctx();
419 let store_ref = Arc::clone(db.get_store());
420 // `db` itself + our `store_ref` clone.
421 assert_eq!(Arc::strong_count(&store_ref), 2);
422
423 let guard = WriteTransactionGuard::acquire(&db, "test").unwrap();
424 // Acquiring must have taken its own clone: three owners now.
425 assert_eq!(
426 Arc::strong_count(&store_ref),
427 3,
428 "WriteTransactionGuard::acquire must clone the store's Arc, not just read its pointer"
429 );
430
431 drop(db);
432 // With the DbContext gone, only the guard's clone (+ ours) keep the
433 // allocation alive. If the guard did NOT hold a clone, this would
434 // drop to 1 and the address would become free for the allocator to
435 // hand to an unrelated, brand-new store — the ABA scenario the
436 // module doc describes.
437 assert_eq!(
438 Arc::strong_count(&store_ref),
439 2,
440 "the guard must still be holding the store alive once the DbContext drops"
441 );
442
443 drop(guard);
444 assert_eq!(
445 Arc::strong_count(&store_ref),
446 1,
447 "the guard's clone must be released when the guard drops"
448 );
449 }
450
451 #[test]
452 fn cross_thread_contention_names_both_the_holder_and_the_new_claimant() {
453 let db = ctx();
454 let _first = WriteTransactionGuard::acquire(&db, "first_site_xyz").unwrap();
455
456 let db_for_thread = db.clone();
457 let handle = std::thread::Builder::new()
458 .name("contender-thread".to_string())
459 .spawn(move || contention_message(&db_for_thread, "second_site_xyz"))
460 .unwrap();
461
462 let message = handle
463 .join()
464 .expect("the contending thread must fail through the guard, not around it");
465
466 assert!(
467 message.contains("first_site_xyz"),
468 "message must name the current holder's call site: {message}"
469 );
470 assert!(
471 message.contains("second_site_xyz"),
472 "message must name the new claimant's call site: {message}"
473 );
474 assert!(
475 message.contains("contender-thread"),
476 "message must name the new claimant's thread: {message}"
477 );
478 assert!(
479 !message.contains("SAME THREAD"),
480 "a genuine cross-thread writer must not be misdiagnosed as same-thread \
481 re-entry: {message}"
482 );
483 }
484
485 #[test]
486 fn same_thread_reacquire_is_diagnosed_as_self_reentry_not_a_phantom_second_writer() {
487 let db = ctx();
488 let _first = WriteTransactionGuard::acquire(&db, "begin_transaction_first_call").unwrap();
489
490 let message = contention_message(&db, "begin_transaction_retry");
491
492 assert!(
493 message.contains("begin_transaction_first_call"),
494 "message must name the site that still holds the slot: {message}"
495 );
496 assert!(
497 message.contains("begin_transaction_retry"),
498 "message must name the re-entrant call site: {message}"
499 );
500 assert!(
501 message.contains("SAME THREAD") || message.contains("re-entrant"),
502 "message must say this is the same thread re-entering, not blame a second \
503 writer: {message}"
504 );
505 assert!(
506 !message.contains("Fix the caller; do not remove or weaken this guard"),
507 "must not reuse the generic cross-thread-second-writer wording for a \
508 same-thread self re-entry: {message}"
509 );
510 }
511}