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}