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}