Skip to main content

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}