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//! `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//! `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
67use std::collections::HashMap;
68use std::sync::Arc;
69
70use tokio::sync::{Mutex, OwnedMutexGuard};
71
72use super::PvDatabase;
73
74/// Registry of per-record advisory write gates.
75///
76/// Lazily allocates one `Arc<Mutex<()>>` per canonical record name on
77/// first use. Entries are never removed: an EPICS database is loaded
78/// once at IOC init and the record set is effectively static, so the
79/// map size is bounded by the record count and removing entries would
80/// reintroduce a TOCTOU race with a concurrent locker.
81#[derive(Default)]
82pub(crate) struct RecordLockRegistry {
83    gates: std::sync::Mutex<HashMap<String, Arc<Mutex<()>>>>,
84}
85
86impl RecordLockRegistry {
87    /// Return the advisory gate for `record`, creating it on first use.
88    ///
89    /// `record` must already be the canonical (alias-resolved) name;
90    /// [`PvDatabase::lock_record`] / [`PvDatabase::lock_records`]
91    /// resolve aliases before calling this so an alias and its target
92    /// always share one gate.
93    fn gate_for(&self, record: &str) -> Arc<Mutex<()>> {
94        let mut map = self
95            .gates
96            .lock()
97            .expect("record-lock registry mutex poisoned");
98        map.entry(record.to_string())
99            .or_insert_with(|| Arc::new(Mutex::new(())))
100            .clone()
101    }
102}
103
104/// RAII guard for a single record's advisory write gate.
105///
106/// Held for the duration of a plain CA/PVA write. Equivalent to the
107/// `dbScanLock`+`dbScanUnlock` pair around one `dbPutField` in C
108/// EPICS.
109pub struct RecordWriteGuard {
110    _guard: OwnedMutexGuard<()>,
111}
112
113/// RAII guard for an ordered set of record advisory write gates — the
114/// `DBManyLocker` equivalent.
115///
116/// Acquired by [`PvDatabase::lock_records`] over every member record
117/// of a multi-record transaction (QSRV atomic group PUT/GET, pvalink
118/// atomic scan-on-update epoch); held across the whole member loop.
119/// While alive, every plain CA/PVA write to any of those records — and
120/// every other multi-record transaction sharing any of them — blocks.
121/// The guards are `'static` (`OwnedMutexGuard`) so the set can be held
122/// across `.await` points in the transaction loop.
123#[must_use = "the locked epoch ends as soon as the guard is dropped"]
124pub struct ManyRecordWriteGuard {
125    // Guards drop in vector order; order does not matter for release.
126    _guards: Vec<OwnedMutexGuard<()>>,
127}
128
129impl PvDatabase {
130    /// Acquire the advisory write gate for a single record.
131    ///
132    /// This is the `dbScanLock(precord)` analogue. The plain CA/PVA
133    /// write path holds this for the duration of one record write so
134    /// it cannot interleave with a multi-record transaction that owns
135    /// the same record's gate via [`Self::lock_records`].
136    ///
137    /// `record` is alias-resolved internally, so an alias and its
138    /// target always map to the same gate as [`Self::lock_records`]
139    /// keys them.
140    pub async fn lock_record(&self, record: &str) -> RecordWriteGuard {
141        let canonical = self
142            .resolve_alias(record)
143            .await
144            .unwrap_or_else(|| record.to_string());
145        let gate = self.inner.record_locks.gate_for(&canonical);
146        RecordWriteGuard {
147            _guard: gate.lock_owned().await,
148        }
149    }
150
151    /// Acquire the advisory write gates for a set of records — the
152    /// `DBManyLock` / `DBManyLocker` equivalent.
153    ///
154    /// Every name is alias-resolved to its canonical record name, the
155    /// set is sorted and de-duplicated, then the per-record gates are
156    /// acquired in that sorted order. Sorting guarantees two
157    /// overlapping multi-record transactions acquire their shared
158    /// records in the same global order and cannot deadlock (mirrors
159    /// pvxs `DBManyLock` sorting the lock set in `dbLock.c`); the
160    /// dedup means a record bound by more than one member link is
161    /// locked exactly once.
162    ///
163    /// The returned [`ManyRecordWriteGuard`] must be held for the
164    /// whole transaction. While it is alive, a concurrent plain write
165    /// to any of those records blocks on [`Self::lock_record`], and a
166    /// concurrent overlapping transaction blocks on this method.
167    ///
168    /// Names that do not resolve to a record still get a gate (keyed
169    /// by the post-alias name) — matching `dbLockerAlloc`, which
170    /// accepts the record pointers it is given without a liveness
171    /// re-check.
172    pub async fn lock_records<I, S>(&self, records: I) -> ManyRecordWriteGuard
173    where
174        I: IntoIterator<Item = S>,
175        S: AsRef<str>,
176    {
177        // Alias-resolve every name so two links naming the same record
178        // via different aliases share one gate.
179        let mut names: Vec<String> = Vec::new();
180        for record in records {
181            let record = record.as_ref();
182            names.push(
183                self.resolve_alias(record)
184                    .await
185                    .unwrap_or_else(|| record.to_string()),
186            );
187        }
188        // Deadlock-free canonical order: sort + dedup so the same
189        // record is locked once and overlapping transactions share an
190        // acquisition order.
191        names.sort_unstable();
192        names.dedup();
193
194        let mut guards = Vec::with_capacity(names.len());
195        for name in &names {
196            let gate = self.inner.record_locks.gate_for(name);
197            guards.push(gate.lock_owned().await);
198        }
199        ManyRecordWriteGuard { _guards: guards }
200    }
201}
202
203#[cfg(test)]
204mod tests {
205    use super::*;
206    use std::sync::atomic::{AtomicUsize, Ordering};
207    use std::time::Duration;
208
209    /// A single-record gate excludes a concurrent same-record locker.
210    #[tokio::test]
211    async fn lock_record_excludes_same_record() {
212        let db = PvDatabase::new();
213        let order = Arc::new(AtomicUsize::new(0));
214
215        let g = db.lock_record("ai:1").await;
216
217        let db2 = db.clone();
218        let order2 = order.clone();
219        let h = tokio::spawn(async move {
220            let _g2 = db2.lock_record("ai:1").await;
221            // This must observe the first holder having released (1).
222            order2.fetch_add(10, Ordering::SeqCst);
223        });
224
225        // Give the spawned task time to block on the gate.
226        tokio::time::sleep(Duration::from_millis(20)).await;
227        // First holder still owns the gate: counter untouched.
228        assert_eq!(order.load(Ordering::SeqCst), 0);
229        order.fetch_add(1, Ordering::SeqCst);
230        drop(g);
231
232        h.await.unwrap();
233        assert_eq!(order.load(Ordering::SeqCst), 11);
234    }
235
236    /// `lock_records` blocks a plain single-record write to a member.
237    #[tokio::test]
238    async fn lock_records_excludes_single_member_write() {
239        let db = PvDatabase::new();
240        let many = db.lock_records(["g:a", "g:b", "g:c"]).await;
241
242        let db2 = db.clone();
243        let acquired = Arc::new(AtomicUsize::new(0));
244        let acquired2 = acquired.clone();
245        let h = tokio::spawn(async move {
246            // Plain write to a member must block until `many` drops.
247            let _g = db2.lock_record("g:b").await;
248            acquired2.store(1, Ordering::SeqCst);
249        });
250
251        tokio::time::sleep(Duration::from_millis(20)).await;
252        assert_eq!(
253            acquired.load(Ordering::SeqCst),
254            0,
255            "single-member write must block while ManyRecordWriteGuard is held"
256        );
257
258        drop(many);
259        h.await.unwrap();
260        assert_eq!(acquired.load(Ordering::SeqCst), 1);
261    }
262
263    /// Two overlapping `lock_records` sets acquire in canonical order
264    /// and therefore cannot deadlock even with reversed input order.
265    #[tokio::test]
266    async fn lock_records_overlapping_sets_no_deadlock() {
267        let db = PvDatabase::new();
268
269        let db_a = db.clone();
270        let ta = tokio::spawn(async move {
271            for _ in 0..50 {
272                let _g = db_a.lock_records(["x", "y", "z"]).await;
273                tokio::task::yield_now().await;
274            }
275        });
276        let db_b = db.clone();
277        let tb = tokio::spawn(async move {
278            for _ in 0..50 {
279                // Reversed input order — sort makes the real
280                // acquisition order identical, so no deadlock.
281                let _g = db_b.lock_records(["z", "y", "x"]).await;
282                tokio::task::yield_now().await;
283            }
284        });
285
286        tokio::time::timeout(Duration::from_secs(5), async {
287            ta.await.unwrap();
288            tb.await.unwrap();
289        })
290        .await
291        .expect("overlapping lock_records sets must not deadlock");
292    }
293
294    /// An epoch over a record set excludes a second epoch that shares
295    /// any record until the first guard drops — and overlapping sets
296    /// listed in opposite orders never deadlock (sorted acquisition).
297    #[tokio::test]
298    async fn overlapping_epochs_are_mutually_exclusive_and_deadlock_free() {
299        let db = PvDatabase::new();
300        let a = vec!["RECA".to_string(), "RECB".to_string()];
301        // Opposite order on purpose — sorted acquisition must still
302        // make this safe.
303        let b = vec!["RECB".to_string(), "RECC".to_string()];
304
305        let guard_a = db.lock_records(&a).await;
306
307        // A second epoch sharing RECB must not be acquirable while
308        // `guard_a` is alive.
309        let db2 = db.clone();
310        let b2 = b.clone();
311        let handle = tokio::spawn(async move { db2.lock_records(&b2).await });
312
313        // Give the spawned task a chance to run; it must still be
314        // blocked on RECB.
315        tokio::task::yield_now().await;
316        assert!(!handle.is_finished(), "epoch B must block on shared RECB");
317
318        drop(guard_a);
319        // Now epoch B can complete.
320        let _guard_b = handle.await.expect("epoch B task");
321    }
322
323    /// Two non-overlapping epochs run concurrently — no false
324    /// serialisation.
325    #[tokio::test]
326    async fn disjoint_epochs_do_not_block_each_other() {
327        let db = PvDatabase::new();
328        let _g1 = db.lock_records(&["X1".to_string()]).await;
329        // Disjoint set: must acquire immediately without blocking.
330        let _g2 = db.lock_records(&["X2".to_string()]).await;
331    }
332}