Skip to main content

epics_base_rs/server/database/
record_lock.rs

1//! Database-level record locking — the Rust counterpart of the
2//! C-EPICS `dbScanLock` / `dbScanLockMany` machinery and pvxs's
3//! `ioc::DBManyLock` / `ioc::DBManyLocker`.
4//!
5//! C EPICS / pvxs background
6//! -------------------------
7//! Every `dbPutField` / `dbProcess` in C EPICS takes the target
8//! record's `dbCommon::lock` mutex via `dbScanLock(precord)`
9//! (`dbLock.c`). A multi-record transaction — a QSRV *atomic group*
10//! operation, or a pvalink *atomic* scan-on-update set — must apply,
11//! read or scan several records as one indivisible unit, so pvxs
12//! builds a `DBManyLock` over every member record and holds a
13//! `DBManyLocker` across the whole member loop:
14//!
15//! * `epics-base/modules/database/src/ioc/db/dbLock.c:349` —
16//!   `dbLockerAlloc` builds a locker over a fixed record set.
17//! * `epics-base/modules/database/src/ioc/db/dbLock.c:384` —
18//!   `dbScanLockMany` sorts the lock sets and acquires every one,
19//!   skipping duplicates.
20//! * `pvxs/ioc/groupconfigprocessor.cpp:1165` `initialiseDbLocker` /
21//!   `pvxs/ioc/groupsource.cpp:444,569` — atomic group GET/PUT.
22//! * `pvxs/ioc/pvalink_channel.cpp:386,422` — `DBManyLock` /
23//!   `DBManyLocker` over the atomic pvalink scan-target records.
24//!
25//! `DBManyLock` locks the member records in a deadlock-free canonical
26//! order (`dbLock.c` sorts the lock set), and because those are the
27//! *same* mutexes a plain `dbPutField` takes, a direct CA/PVA write
28//! to a backing member record cannot interleave with the transaction.
29//!
30//! Rust port
31//! ---------
32//! `epics-base-rs` stores each record behind its own
33//! `parking_lot::RwLock<RecordInstance>`, but the put/process helpers
34//! (`put_record_field_from_ca`, `put_pv`, `process_record`,
35//! `process_record_with_links`) acquire that `RwLock` *internally*
36//! and recurse into link targets, so a caller cannot hold N
37//! `write_owned()` guards across the member loop without dead-locking
38//! the recursive link processing.
39//!
40//! This module adds the missing layer: a single per-record
41//! **advisory write gate** registry keyed by canonical record name.
42//! It is the direct analogue of `dbCommon::lock`:
43//!
44//! * A plain CA/PVA write (`put_record_field_from_ca`, `put_pv`,
45//!   `process_record`) takes the single record's gate for the
46//!   duration of the write via [`PvDatabase::lock_record`].
47//! * A multi-record transaction — the QSRV atomic group PUT/GET
48//!   and the pvalink atomic scan-on-update epoch —
49//!   takes *all* member-record gates up-front via
50//!   [`PvDatabase::lock_records`], sorted by canonical record name so
51//!   two overlapping transactions acquire shared records in the same
52//!   order and cannot deadlock.
53//!
54//! Because every path takes the same gate from the same registry, a
55//! direct backing-record write blocks until the transaction owning
56//! that record finishes, and a QSRV atomic group PUT and a pvalink
57//! atomic scan can never interleave on a shared record — restoring
58//! the `DBManyLock` exclusion that earlier review found missing.
59//!
60//! The gate is *advisory*: it does not replace the per-record
61//! `parking_lot::RwLock<RecordInstance>` that still guards the record's data. It
62//! is an additional serialization layer that the multi-record
63//! transaction owner and the single-record writers both honour,
64//! exactly as `dbScanLock` is a layer above the record's own field
65//! storage.
66//!
67//! What the gate *is* — a blocking priority-inheritance mutex
68//! ----------------------------------------------------------
69//! The gate is a [`crate::runtime::sync::PriorityInheritanceMutex`], the
70//! same primitive L46 (`registration_mutex`), L8a (`simple_pvs`) and L8b
71//! (one `scan_index` bucket) already use, and [`PvDatabase::lock_record`] /
72//! [`PvDatabase::lock_records`] are plain synchronous `fn`s returning RAII
73//! guards. This is `doc/rtems-priority-locks-design.md` §5 steps 5–6, and it
74//! is the parity shape rather than a Rust-side invention: C's `dbScanLock`
75//! takes `precord->mlok`, a plain `epicsMutex`, and on the RTEMS arm
76//! base compiles the POSIX implementation
77//! (`configure/toolchain.c:31-35` selects `OS_API = posix` for
78//! `__RTEMS_MAJOR__ >= 5`; `os/RTEMS-posix/osdMutex.c:8` is one `#include
79//! "../posix/osdMutex.c"`), whose `globalAttrInit`
80//! (`os/posix/osdMutex.c:71-88`) builds every `epicsMutex` with
81//! `PTHREAD_PRIO_INHERIT` — probing it once and silently degrading to
82//! `PTHREAD_PRIO_NONE` if the target refuses. `PriorityInheritanceMutex` is
83//! that same construction on that same API, including the probe.
84//!
85//! ### The band-ordered wait queue is gone, and why that is not a loss
86//!
87//! Until §5 step 4 this gate was an async lock, and between steps 2 and 5 it
88//! was a hand-rolled `PriorityGate` whose waiters were parked in a
89//! `BTreeMap` keyed by the waiter's declared EPICS band, highest band first,
90//! FIFO among equals. That queue existed for exactly one reason: while the
91//! gate was async, both ends of a contention pair were *tasks* parked on a
92//! userspace queue the kernel could not see, so nothing but our own code
93//! could order them (`doc/rtems-priority-locks-design.md` §0 finding 4, §2
94//! option C). It was the async bridge, not the target design.
95//!
96//! With a blocking PI mutex the waiters are real threads blocked in
97//! `pthread_mutex_lock`, so the *OS* orders the queue — by thread priority,
98//! which on the RTEMS backend is the EPICS band the thread declared through
99//! `enter_ioc_thread` — and additionally boosts a preempted low-band holder
100//! to the highest waiting band. The band-ordered wake order is therefore
101//! replaced by the kernel's PI wait order, which is strictly stronger: it is
102//! what closes handoff §8.0 **gap 4** (priority inheritance), which no
103//! userspace queue could close at all. `PriorityGate`, its `BTreeMap` wait
104//! queue, `GateAcquire` and the `DECLARED_BAND` thread-local that fed it are
105//! deleted with this flip.
106//!
107//! ### Where the ordering actually holds — [`crate::runtime::sync::is_pi_mutex_active`]
108//!
109//! Priority inheritance is a property of the *build and the target*, and the
110//! function above is the single place that answers whether this process got
111//! it:
112//!
113//! * **RTEMS** — PI, and the answer is a *probe* result rather than a `cfg!`,
114//!   matching C's own degrade path (`os/posix/osdMutex.c:77-85`, reported by
115//!   `epicsMutexShowAll` at `:199-205`).
116//! * **Linux with the `linux-rt` Cargo feature** — PI unconditionally.
117//! * **every other build, including a default hosted Linux `cargo test`** —
118//!   `parking_lot::Mutex`, which has **no** priority inheritance and no
119//!   priority ordering. The host suite therefore verifies the *exclusion*
120//!   this module provides, never its ordering; ordering is on-target
121//!   territory (`doc/rtems-priority-locks-design.md` §5 step 7).
122//!
123//! Read as a claim about *this* gate: on the host, `lock_record` excludes and
124//! nothing more, and no host test can be written that would catch a lost
125//! inversion. Two further conditions have to hold on target before the
126//! ordering is real, and neither is this module's to enforce — the probe must
127//! have returned `PTHREAD_PRIO_INHERIT`, and the contending threads must
128//! actually carry distinct scheduling priorities, which requires
129//! `RtPolicy::AllowRealtime` in [`crate::runtime::task`]. With the RT switch
130//! off, every thread is one priority and PI has nothing to inherit.
131//!
132//! Acquisition order — MUST
133//! ------------------------
134//! `doc/rtems-priority-locks-design.md` §3's cross-check requires this to be
135//! written down, because every lock in the chain is now *blocking* and a
136//! cycle would wedge a thread rather than a task. The order below is the
137//! one the code actually takes, not an aspiration — it was derived by reading
138//! every nesting site, and the bypass audit is in the commit that added it.
139//!
140//! > **A thread MUST acquire these in this order and MUST NOT acquire any of
141//! > them while holding one that appears later:**
142//! >
143//! > 0. **L33** — `epics-bridge-rs`' `GroupPvDef::atomic_write_lock`, the
144//! >    QSRV per-group atomic-PUT gate (`PriorityInheritanceMutex`). Outside
145//! >    this crate, and only the atomic group PUT takes it — see the L33
146//! >    section below.
147//! > 1. **L1** — the per-record advisory gate ([`PvDatabase::lock_record`] /
148//! >    [`PvDatabase::lock_records`]), *this* module
149//! >    (`PriorityInheritanceMutex`).
150//! > 2. **L46** — `PvDatabaseInner::registration_mutex`
151//! >    (`PriorityInheritanceMutex`).
152//! > 3. the leaves, none of which is ever held while another lock is taken:
153//! >    **L8a** `simple_pvs`, **L8b** one `scan_index` bucket and **L7**
154//! >    `ProcessVariable::subscribers` (all `PriorityInheritanceMutex`), plus
155//! >    the `records` map, `aliases`, and a record's own
156//! >    `RwLock<RecordInstance>` (`parking_lot::RwLock` — C has no
157//! >    reader-writer lock to be PI-faithful to, §5.3 addendum).
158//! >
159//! > Every rung is a blocking lock. There is no async lock left anywhere on
160//! > the put/process path, which is what makes the order a MUST rather than a
161//! > preference: a cycle wedges a thread.
162//!
163//! [`RecordLockRegistry`]'s own map mutex (a `std::sync::Mutex`, unchanged by
164//! this flip) is *not* a rung of that order: it is taken and released inside
165//! [`RecordLockRegistry::gate_for`], strictly before the record gate it
166//! returns is locked, and no other lock is ever taken while it is held.
167//!
168//! **Owner/Gate:** [`PvDatabase::update_scan_index`] is the **only** production
169//! function that takes L46 from inside an L1-held window, and therefore the
170//! single owner of the whole L1 → L46 → L8b chain. Every other L46 holder
171//! (`add_pv`, `add_pv_with_hooks_full`, `remove_simple_pv`,
172//! `add_loaded_record`, `remove_record`, `add_alias`, `add_breaktables`) is a
173//! registration entry point reached from `.db` load, iocsh or the gateway,
174//! never from inside a put/process cycle — verified with `rg` over those
175//! symbols in `field_io.rs`, `processing.rs`, `links.rs`, `qsrv/group.rs` and
176//! `pvalink/integration.rs`, where every hit is inside a `#[cfg(test)]`
177//! module. A second function that nests L46 under L1 is a second owner of
178//! this chain: route it through `update_scan_index` instead, or the order
179//! above stops being checkable by reading one function.
180//!
181//! ### The rule's teeth are structural, and now they cover L1 too
182//!
183//! Every guard in the list above is `!Send`. A `!Send` value held across an
184//! `.await` makes the enclosing future `!Send`, which the compiler rejects at
185//! every `tokio::spawn` / `runtime::task::spawn` site in this workspace — so
186//! "no suspension point inside a gate window" is a build error rather than a
187//! review convention. That is the structural guarantee `doc/rtems-priority-locks-design.md`
188//! §5 steps 5–6 (holders H1–H9) were staged to make reachable: each holder
189//! was first rewritten so its gate-held region contained zero `.await`s, and
190//! only then did the gate become a type that refuses to be held across one.
191//!
192//! `!Send`ness is deliberate on both arms of [`crate::runtime::sync::PriorityInheritanceMutex`],
193//! and on the PI arm it is also a correctness requirement, not only a
194//! lint: POSIX requires a mutex to be unlocked by the thread that locked it,
195//! so a guard that could migrate between threads would call
196//! `pthread_mutex_unlock` from a non-owner.
197//!
198//! The compiler only *reports* it at a spawn site, though, so the standing
199//! check is a direct one — for every binding of a gate guard, read forward to
200//! the end of its drop scope and find no `.await`:
201//!
202//! ```text
203//! rg -n 'let (mut )?\w+ = .*\.(lock_record|lock_records|acquire_put_gate)\(' crates/
204//! ```
205//!
206//! ### L33 — the QSRV atomic-PUT group lock, relative to L1
207//!
208//! `epics-bridge-rs`' `GroupPvDef::atomic_write_lock` (`qsrv/group_config.rs`)
209//! is a group-vs-group serialization aid: it lives in a different crate and
210//! has no nesting relationship with L46/L8a/L8b, but it *is* held across L1
211//! and so occupies rung 0 of the order above. It is acquired in
212//! `GroupChannel::put`'s atomic branch **before** [`PvDatabase::lock_records`]
213//! — first so a conversion failure in the up-front value-conversion phase
214//! aborts the whole atomic PUT before any member-record gate is even
215//! requested, second so two atomic PUTs to the *same* group serialize before
216//! either reaches L1 at all.
217//!
218//! It is a `PriorityInheritanceMutex`. It was a `tokio::sync::Mutex` for
219//! exactly as long as L1 was async: its window contains `lock_records`, which
220//! used to be a genuine suspension point, and a `!Send` guard across that
221//! await would not compile at the connection-task spawn site. That window is
222//! now the conversion phase, a synchronous `lock_records`, and a synchronous
223//! member loop — zero `.await`s — so the reason to keep it async is gone.
224
225// No RTEMS-EXEC-MODEL-ALLOW marker: this file's tests are all plain `#[test]`s
226// now that the gate is a blocking lock (a contender has to be a real thread),
227// so none of them needs a reactor and there is nothing to account for.
228
229use std::collections::HashMap;
230
231use crate::runtime::sync::{PriorityInheritanceMutex, PriorityInheritanceMutexGuard};
232
233use super::PvDatabase;
234
235/// One record's advisory write gate, and the lifetime it is handed out with.
236///
237/// `&'static` rather than `Arc`: [`RecordLockRegistry`] never removes an
238/// entry (see its doc), so a gate's lifetime already *is* the process's, and
239/// the PI mutex — a `pthread_mutex_t` on the target arms — has no owned-guard
240/// API to build an `Arc`-carrying guard from. Leaking one allocation per
241/// record at first use makes the guards genuinely `'static` with no `unsafe`
242/// and no reference counting on the write hot path, and frees exactly as much
243/// memory as the previous `Arc`-in-a-never-pruned-map did: none.
244type Gate = &'static PriorityInheritanceMutex<()>;
245
246/// Registry of per-record advisory write gates.
247///
248/// Lazily allocates one gate per canonical record name on first use.
249/// Entries are never removed: an EPICS database is loaded once at IOC init
250/// and the record set is effectively static, so the map size is bounded by
251/// the record count and removing entries would reintroduce a TOCTOU race
252/// with a concurrent locker.
253#[derive(Default)]
254pub(crate) struct RecordLockRegistry {
255    gates: std::sync::Mutex<HashMap<String, Gate>>,
256}
257
258impl RecordLockRegistry {
259    /// Return the advisory gate for `record`, creating it on first use.
260    ///
261    /// `record` must already be the canonical (alias-resolved) name;
262    /// [`PvDatabase::lock_record`] / [`PvDatabase::lock_records`]
263    /// resolve aliases before calling this so an alias and its target
264    /// always share one gate.
265    ///
266    /// The map lock is released before the caller locks the gate — it is not
267    /// a rung of the acquisition order, and holding it across a gate
268    /// acquisition would serialise every record in the database behind one
269    /// writer.
270    fn gate_for(&self, record: &str) -> Gate {
271        let mut map = self
272            .gates
273            .lock()
274            .expect("record-lock registry mutex poisoned");
275        match map.get(record) {
276            Some(gate) => gate,
277            None => {
278                let gate: Gate = Box::leak(Box::new(PriorityInheritanceMutex::new(())));
279                map.insert(record.to_string(), gate);
280                gate
281            }
282        }
283    }
284}
285
286/// RAII guard for a single record's advisory write gate.
287///
288/// Held for the duration of a plain CA/PVA write. Equivalent to the
289/// `dbScanLock`+`dbScanUnlock` pair around one `dbPutField` in C
290/// EPICS. `!Send`, so the compiler refuses to let it live across an
291/// `.await` in any spawned future — see the module doc.
292#[must_use = "the write gate is released as soon as the guard is dropped"]
293pub struct RecordWriteGuard {
294    _guard: PriorityInheritanceMutexGuard<'static, ()>,
295}
296
297/// RAII guard for an ordered set of record advisory write gates — the
298/// `DBManyLocker` equivalent.
299///
300/// Acquired by [`PvDatabase::lock_records`] over every member record
301/// of a multi-record transaction (QSRV atomic group PUT/GET, pvalink
302/// atomic scan-on-update epoch); held across the whole member loop.
303/// While alive, every plain CA/PVA write to any of those records — and
304/// every other multi-record transaction sharing any of them — blocks.
305/// `!Send`, exactly as [`RecordWriteGuard`] is.
306#[must_use = "the locked epoch ends as soon as the guard is dropped"]
307pub struct ManyRecordWriteGuard {
308    // Guards drop in vector order; order does not matter for release.
309    _guards: Vec<PriorityInheritanceMutexGuard<'static, ()>>,
310}
311
312impl PvDatabase {
313    /// Acquire the advisory write gate for a single record.
314    ///
315    /// This is the `dbScanLock(precord)` analogue. The plain CA/PVA
316    /// write path holds this for the duration of one record write so
317    /// it cannot interleave with a multi-record transaction that owns
318    /// the same record's gate via [`Self::lock_records`].
319    ///
320    /// `record` is alias-resolved internally, so an alias and its
321    /// target always map to the same gate as [`Self::lock_records`]
322    /// keys them.
323    ///
324    /// **Blocks the calling thread** if the gate is held. The gate is not
325    /// reentrant — a caller that already owns it (a transaction owner inside
326    /// its own [`Self::lock_records`] epoch) MUST use the `_already_locked`
327    /// entry points instead, or it deadlocks against itself.
328    pub fn lock_record(&self, record: &str) -> RecordWriteGuard {
329        let canonical = self
330            .resolve_alias(record)
331            .unwrap_or_else(|| record.to_string());
332        let gate = self.inner.record_locks.gate_for(&canonical);
333        RecordWriteGuard {
334            _guard: gate.lock(),
335        }
336    }
337
338    /// Acquire the advisory write gates for a set of records — the
339    /// `DBManyLock` / `DBManyLocker` equivalent.
340    ///
341    /// Every name is alias-resolved to its canonical record name, the
342    /// set is sorted and de-duplicated, then the per-record gates are
343    /// acquired in that sorted order. Sorting guarantees two
344    /// overlapping multi-record transactions acquire their shared
345    /// records in the same global order and cannot deadlock (mirrors
346    /// pvxs `DBManyLock` sorting the lock set in `dbLock.c`); the
347    /// dedup means a record bound by more than one member link is
348    /// locked exactly once. That canonical order is load-bearing in a way
349    /// it was not while the gate was async: an out-of-order acquisition now
350    /// wedges two threads rather than two tasks.
351    ///
352    /// The returned [`ManyRecordWriteGuard`] must be held for the
353    /// whole transaction. While it is alive, a concurrent plain write
354    /// to any of those records blocks on [`Self::lock_record`], and a
355    /// concurrent overlapping transaction blocks on this method.
356    ///
357    /// Names that do not resolve to a record still get a gate (keyed
358    /// by the post-alias name) — matching `dbLockerAlloc`, which
359    /// accepts the record pointers it is given without a liveness
360    /// re-check.
361    pub fn lock_records<I, S>(&self, records: I) -> ManyRecordWriteGuard
362    where
363        I: IntoIterator<Item = S>,
364        S: AsRef<str>,
365    {
366        // Alias-resolve every name so two links naming the same record
367        // via different aliases share one gate.
368        let mut names: Vec<String> = Vec::new();
369        for record in records {
370            let record = record.as_ref();
371            names.push(
372                self.resolve_alias(record)
373                    .unwrap_or_else(|| record.to_string()),
374            );
375        }
376        // Deadlock-free canonical order: sort + dedup so the same
377        // record is locked once and overlapping transactions share an
378        // acquisition order.
379        names.sort_unstable();
380        names.dedup();
381
382        let mut guards = Vec::with_capacity(names.len());
383        for name in &names {
384            guards.push(self.inner.record_locks.gate_for(name).lock());
385        }
386        ManyRecordWriteGuard { _guards: guards }
387    }
388}
389
390#[cfg(test)]
391mod tests {
392    use super::*;
393    use std::sync::Arc;
394    use std::sync::atomic::{AtomicUsize, Ordering};
395    use std::time::Duration;
396
397    // These are plain `#[test]`s, not `#[tokio::test]`s, and that is forced
398    // rather than stylistic: the gate blocks the calling *thread*, so a
399    // contending waiter has to be a real thread. Parking one on a
400    // `current_thread` runtime's only worker would wedge the runtime instead
401    // of demonstrating exclusion. Being reactor-free they also run under
402    // `--features rtems-exec-model` and add no site to this file's
403    // RTEMS-EXEC-MODEL-ALLOW census.
404
405    /// Long enough that a non-blocking (broken) gate would have let the
406    /// contender through, short enough not to dominate the suite.
407    const SETTLE: Duration = Duration::from_millis(50);
408
409    /// A single-record gate excludes a concurrent same-record locker.
410    #[test]
411    fn lock_record_excludes_same_record() {
412        let db = PvDatabase::new();
413        let order = Arc::new(AtomicUsize::new(0));
414
415        let g = db.lock_record("ai:1");
416
417        let db2 = db.clone();
418        let order2 = order.clone();
419        let h = std::thread::spawn(move || {
420            let _g2 = db2.lock_record("ai:1");
421            // This must observe the first holder having released (1).
422            order2.fetch_add(10, Ordering::SeqCst);
423        });
424
425        // Give the spawned thread time to block on the gate.
426        std::thread::sleep(SETTLE);
427        // First holder still owns the gate: counter untouched.
428        assert_eq!(order.load(Ordering::SeqCst), 0);
429        order.fetch_add(1, Ordering::SeqCst);
430        drop(g);
431
432        h.join().unwrap();
433        assert_eq!(order.load(Ordering::SeqCst), 11);
434    }
435
436    /// `lock_records` blocks a plain single-record write to a member.
437    #[test]
438    fn lock_records_excludes_single_member_write() {
439        let db = PvDatabase::new();
440        let many = db.lock_records(["g:a", "g:b", "g:c"]);
441
442        let db2 = db.clone();
443        let acquired = Arc::new(AtomicUsize::new(0));
444        let acquired2 = acquired.clone();
445        let h = std::thread::spawn(move || {
446            // Plain write to a member must block until `many` drops.
447            let _g = db2.lock_record("g:b");
448            acquired2.store(1, Ordering::SeqCst);
449        });
450
451        std::thread::sleep(SETTLE);
452        assert_eq!(
453            acquired.load(Ordering::SeqCst),
454            0,
455            "single-member write must block while ManyRecordWriteGuard is held"
456        );
457
458        drop(many);
459        h.join().unwrap();
460        assert_eq!(acquired.load(Ordering::SeqCst), 1);
461    }
462
463    /// Two overlapping `lock_records` sets acquire in canonical order
464    /// and therefore cannot deadlock even with reversed input order.
465    ///
466    /// With blocking gates a violated order wedges both threads outright,
467    /// which is why this runs the two sets on real threads and joins them
468    /// under a bounded wait rather than trusting a scheduler yield.
469    #[test]
470    fn lock_records_overlapping_sets_no_deadlock() {
471        let db = PvDatabase::new();
472        let done = Arc::new(AtomicUsize::new(0));
473
474        let handles: Vec<_> = [["x", "y", "z"], ["z", "y", "x"]]
475            .into_iter()
476            .map(|set| {
477                let db = db.clone();
478                let done = done.clone();
479                std::thread::spawn(move || {
480                    for _ in 0..500 {
481                        // Reversed input order on one side — sort makes the
482                        // real acquisition order identical, so no deadlock.
483                        let _g = db.lock_records(set);
484                        std::thread::yield_now();
485                    }
486                    done.fetch_add(1, Ordering::SeqCst);
487                })
488            })
489            .collect();
490
491        let deadline = std::time::Instant::now() + Duration::from_secs(10);
492        while done.load(Ordering::SeqCst) < 2 {
493            assert!(
494                std::time::Instant::now() < deadline,
495                "overlapping lock_records sets must not deadlock"
496            );
497            std::thread::sleep(Duration::from_millis(10));
498        }
499        for h in handles {
500            h.join().unwrap();
501        }
502    }
503
504    /// An epoch over a record set excludes a second epoch that shares
505    /// any record until the first guard drops — and overlapping sets
506    /// listed in opposite orders never deadlock (sorted acquisition).
507    #[test]
508    fn overlapping_epochs_are_mutually_exclusive_and_deadlock_free() {
509        let db = PvDatabase::new();
510        let a = vec!["RECA".to_string(), "RECB".to_string()];
511        // Opposite order on purpose — sorted acquisition must still
512        // make this safe.
513        let b = vec!["RECB".to_string(), "RECC".to_string()];
514
515        let guard_a = db.lock_records(&a);
516
517        // A second epoch sharing RECB must not be acquirable while
518        // `guard_a` is alive.
519        let db2 = db.clone();
520        let entered = Arc::new(AtomicUsize::new(0));
521        let entered2 = entered.clone();
522        let handle = std::thread::spawn(move || {
523            let _guard_b = db2.lock_records(&b);
524            entered2.store(1, Ordering::SeqCst);
525        });
526
527        std::thread::sleep(SETTLE);
528        assert_eq!(
529            entered.load(Ordering::SeqCst),
530            0,
531            "epoch B must block on shared RECB"
532        );
533
534        drop(guard_a);
535        handle.join().expect("epoch B thread");
536        assert_eq!(entered.load(Ordering::SeqCst), 1);
537    }
538
539    /// Two non-overlapping epochs run concurrently — no false
540    /// serialisation. Taking the second on *this* thread while the first is
541    /// still held is the assertion: a gate keyed too coarsely would
542    /// self-deadlock here rather than merely being slow.
543    #[test]
544    fn disjoint_epochs_do_not_block_each_other() {
545        let db = PvDatabase::new();
546        let _g1 = db.lock_records(&["X1".to_string()]);
547        // Disjoint set: must acquire immediately without blocking.
548        let _g2 = db.lock_records(&["X2".to_string()]);
549    }
550
551    /// An alias and its target share one gate — the property every caller
552    /// relies on when it hands `lock_record` a client-supplied name and
553    /// `lock_records` a link-derived one.
554    #[test]
555    fn alias_and_target_share_one_gate() {
556        let db = PvDatabase::new();
557        let registry = &db.inner.record_locks;
558        assert!(
559            std::ptr::eq(registry.gate_for("REC:A"), registry.gate_for("REC:A")),
560            "the same canonical name must map to the same gate instance"
561        );
562        assert!(
563            !std::ptr::eq(registry.gate_for("REC:A"), registry.gate_for("REC:B")),
564            "distinct records must not share a gate"
565        );
566    }
567}