epics_libcom_rs/runtime/background/scan_once.rs
1//! `scanOnce` queue + worker — RTEMS-safe port of the `onceQ`/`scanOnce`/
2//! `onceTask` machinery in `modules/database/src/ioc/db/dbScan.c`.
3//!
4//! # C parity
5//!
6//! C keeps a single bounded ring `onceQ` of `onceQueueSize == 1000` entries
7//! (`dbScan.c:64-66`) drained by one dedicated `scanOnce` worker thread
8//! (`dbScan.c:68`, `onceTaskId`). `scanOnce(prec)` / `scanOnceCallback`
9//! (`dbScan.c:660-694`) pushes an entry and **returns immediately**
10//! (`return !pushOK`, `dbScan.c:693`); the caller never blocks on record
11//! processing. `onceTask` (`dbScan.c:696-726`) waits on `onceSem`, drains the
12//! ring, and for each entry does `dbScanLock` / `dbProcess` / `dbScanUnlock`
13//! (`dbScan.c:715-717`) plus an optional completion callback
14//! (`dbScan.c:718-719`).
15//!
16//! The Rust port keeps that exact shape with **plain `std` threads +
17//! `Mutex`/`Condvar`** and a boxed closure per entry (the closure carries the
18//! "lock + process this record" tail the seam supplies later — this increment
19//! does not touch `pv.rs`/`processing.rs`). No tokio-runtime dependency, so it
20//! runs on RTEMS.
21//!
22//! ## Overflow hysteresis (`dbScan.c:672`, `:683-690`)
23//!
24//! A `static int newOverflow` latch makes C print the
25//! `"scanOnce: Ring buffer overflow"` warning **once per overflow episode**:
26//! the first full push logs and clears `newOverflow`; further full pushes only
27//! bump `onceQOverruns` silently; the next *successful* push re-arms the latch.
28//! We reproduce that latch exactly.
29
30use std::collections::VecDeque;
31use std::sync::atomic::{AtomicUsize, Ordering};
32use std::sync::{Arc, Condvar, Mutex};
33use std::thread::JoinHandle;
34
35use super::facility::{recover, run_facility_loop, run_isolated};
36use crate::runtime::task::{MandatoryThread, StackSizeClass, ThreadPriority};
37
38/// A queued "process this record" tail. C stores `{prec, cb, usr}`
39/// (`dbScan.c:664-668`); the Rust port boxes a closure that already captures
40/// the record handle and completion callback.
41pub type OnceCallback = Box<dyn FnOnce() + Send + 'static>;
42
43/// Default ring capacity — C `onceQueueSize` (`dbScan.c:64`).
44pub const DEFAULT_ONCE_QUEUE_SIZE: usize = 1000;
45
46/// The `scanOnce` ring was full — C returns non-zero (`!pushOK`,
47/// `dbScan.c:693`) and drops the request.
48#[derive(Debug, Clone, Copy, PartialEq, Eq)]
49pub struct ScanOnceOverflow;
50
51struct OnceState {
52 queue: VecDeque<OnceCallback>,
53 /// C `epicsRingBytesHighWaterMark(onceQ)` — the deepest the ring has
54 /// been since the last reset. `scanOnceQueueShow` reports it and
55 /// `scanOnceQueueStatus(reset=1)` clears it (`dbScan.c:734-751`), so
56 /// it has to be latched on the push rather than derived later.
57 high_water: usize,
58 /// C `onceQOverruns` — lifetime overflow count (`dbScan.c:67`).
59 overflows: u64,
60 /// C `static int newOverflow` latch (`dbScan.c:672`): `true` means the
61 /// next overflow should log.
62 new_overflow: bool,
63 shutdown: bool,
64}
65
66struct Inner {
67 capacity: usize,
68 state: Mutex<OnceState>,
69 /// C `onceSem` (`dbScan.c`), the worker's wake-up event.
70 wake: Condvar,
71 /// The drain thread, started on the first request rather than at
72 /// construction — see [`Inner::ensure_worker`].
73 worker: Mutex<Option<JoinHandle<()>>>,
74}
75
76impl Inner {
77 /// Start the drain thread if it is not running yet.
78 ///
79 /// C creates `onceTask` in `scanInit` (`dbScan.c:771-780`), which
80 /// `iocInit` calls AFTER `initPeriodic` has sized `nPeriodic` from the
81 /// loaded `menuScan` — the thread's priority is
82 /// `epicsThreadPriorityScanLow + nPeriodic` and cannot be chosen before
83 /// the menu is known. The port's `BackgroundExecutor` is built long before
84 /// any `.dbd` is loaded, so the thread is started at the first `scanOnce`
85 /// request instead: still before any work can be lost, and by then the
86 /// record system has frozen the menu and pushed the count down with
87 /// [`set_periodic_scan_band_count`].
88 fn ensure_worker(self: &Arc<Self>) {
89 let mut worker = recover(FACILITY, self.worker.lock());
90 if worker.is_some() {
91 return;
92 }
93 let worker_inner = Arc::clone(self);
94 // Losing this thread stops every FLNK/scanOnce tail in the IOC while
95 // records still accept writes — the same silent half-IOC whether the
96 // loss happens later (`run_facility_loop`) or at creation
97 // (`MandatoryThread`).
98 *worker = Some(
99 MandatoryThread::new(
100 // dbScan.c:779 — thread name "scanOnce".
101 "scanOnce",
102 // dbScan.c:772 — priority `epicsThreadPriorityScanLow +
103 // nPeriodic`, so scanOnce preempts every periodic scan thread:
104 // 60 + 7 = 67 with base's own menu. Measured on the C IOC on
105 // RTEMS 6: scanOnce OSIPRI 67.
106 scan_once_priority(),
107 // dbScan.c:773 — `opts.stackSize = epicsThreadStackBig`.
108 StackSizeClass::Big,
109 )
110 .spawn(move || {
111 // C `onceTask` registers before it signals `startStopEvent`
112 // and removes on the way out (`dbScan.c:698`, `:724`).
113 // Unbounded: the loop's normal state is parked on `onceSem`
114 // with nothing queued, which is not a fault and has no
115 // deadline to miss.
116 let _watched = crate::runtime::taskwd::taskwd_insert(
117 "scanOnce",
118 crate::runtime::taskwd::CheckIn::Unbounded,
119 None,
120 );
121 run_facility_loop(
122 FACILITY,
123 || once_loop(&worker_inner),
124 || recover(FACILITY, worker_inner.state.lock()).shutdown = true,
125 );
126 }),
127 );
128 }
129
130 /// Port of `scanOnceCallback` (`dbScan.c:670-694`).
131 fn scan_once(&self, cb: OnceCallback) -> Result<(), ScanOnceOverflow> {
132 let mut st = recover(FACILITY, self.state.lock());
133 if st.shutdown {
134 // Worker stopped: C drops late scanOnce requests during shutdown
135 // without surfacing an error (parity with `callbackStop` handling
136 // of late requests). Drop `cb` (never processed) and report
137 // success rather than a spurious overflow.
138 drop(st);
139 tracing::trace!(
140 target: "epics_base_rs::runtime::scan_once",
141 "scanOnce after shutdown dropped"
142 );
143 return Ok(());
144 }
145 let result = if st.queue.len() >= self.capacity {
146 // dbScan.c:682-687 — ring full: log once per episode, then count.
147 if st.new_overflow {
148 tracing::warn!(
149 target: "epics_base_rs::runtime::scan_once",
150 "WARNING scanOnce: Ring buffer overflow"
151 );
152 }
153 st.new_overflow = false; // dbScan.c:686
154 st.overflows += 1; // dbScan.c:687
155 Err(ScanOnceOverflow)
156 } else {
157 st.new_overflow = true; // dbScan.c:689 — re-arm on a good push.
158 st.queue.push_back(cb);
159 st.high_water = st.high_water.max(st.queue.len());
160 Ok(())
161 };
162 drop(st);
163 // dbScan.c:691 — `epicsEventSignal(onceSem)` is issued unconditionally,
164 // outside the push success/failure branch.
165 self.wake.notify_one();
166 result
167 }
168
169 /// Port of `scanOnceQueueStatus` (`dbScan.c:734-751`).
170 fn stats(&self, reset: bool) -> ScanOnceQueueStats {
171 let mut st = recover(FACILITY, self.state.lock());
172 let out = ScanOnceQueueStats {
173 size: self.capacity,
174 num_used: st.queue.len(),
175 max_used: st.high_water,
176 num_overflow: st.overflows,
177 };
178 if reset {
179 st.high_water = 0;
180 }
181 out
182 }
183}
184
185/// What this facility is called when it has to report something about itself.
186const FACILITY: &str = "scanOnce worker";
187
188/// How many periodic scan rates the record system has when nobody has said
189/// otherwise — the seven of base's own `menuScan.dbd`.
190///
191/// The real count is C's `nPeriodic`, and it is site data: `initPeriodic`
192/// sizes it from the loaded `menuScan` (`dbScan.c:866`), so an IOC that ships
193/// its own menu has as many rates as it declared. This crate is *below* the
194/// record system and cannot read the menu itself, so the owner of the menu
195/// pushes the count down with [`set_periodic_scan_band_count`] when it freezes
196/// the table. Until then this is the answer, which is the right one for every
197/// IOC that does not override the menu.
198pub const DEFAULT_PERIODIC_SCAN_BAND_COUNT: usize = 7;
199
200static PERIODIC_SCAN_BAND_COUNT: std::sync::atomic::AtomicUsize =
201 std::sync::atomic::AtomicUsize::new(DEFAULT_PERIODIC_SCAN_BAND_COUNT);
202
203/// Tell the scanOnce facility how many periodic scan rates the loaded
204/// `menuScan` has, so its worker lands one band above the fastest of them.
205///
206/// The single caller is the owner of the menu — `epics-base-rs`'s
207/// `server::record::menu_scan`, at the moment it freezes the table, which is
208/// C's `initPeriodic` moment. It has to be called before the worker thread
209/// starts, and it is: the worker is not spawned until the first `scanOnce`
210/// request, and C likewise creates its `onceTask` in `scanInit`, after
211/// `initPeriodic` and before any record can call `scanOnce`.
212pub fn set_periodic_scan_band_count(n: usize) {
213 PERIODIC_SCAN_BAND_COUNT.store(n, std::sync::atomic::Ordering::Relaxed);
214}
215
216/// The scanOnce worker's EPICS band — `epicsThreadPriorityScanLow +
217/// nPeriodic` (`dbScan.c:772`). With base's own menu that is 60 + 7 = 67:
218/// scanOnce preempts every periodic scan thread, as in C.
219fn scan_once_priority() -> ThreadPriority {
220 let n = PERIODIC_SCAN_BAND_COUNT.load(std::sync::atomic::Ordering::Relaxed);
221 ThreadPriority::Custom(ThreadPriority::ScanLow.value() + n.min(u8::MAX as usize) as u8)
222}
223
224/// Port of `onceTask` (`dbScan.c:696-726`): wait on the wake event, drain the
225/// ring, run each queued tail.
226fn once_loop(inner: &Inner) {
227 loop {
228 let mut st = recover(FACILITY, inner.state.lock());
229 while st.queue.is_empty() && !st.shutdown {
230 st = recover(FACILITY, inner.wake.wait(st));
231 }
232 if st.queue.is_empty() {
233 return; // empty + shutdown
234 }
235 let cb = st.queue.pop_front().unwrap();
236 drop(st);
237 // dbScan.c:715-719 — the queued tail owns lock/dbProcess/unlock/cb.
238 run_isolated(FACILITY, cb);
239 }
240}
241
242/// Cheap, clonable submission side of a [`ScanOnceQueue`] — the seam route the
243/// FLNK/scanOnce chain hands records into.
244#[derive(Clone)]
245pub struct ScanOnceHandle {
246 inner: Arc<Inner>,
247}
248
249impl ScanOnceHandle {
250 /// Enqueue `cb` for one-shot processing and return immediately. `Err` on a
251 /// full ring (the request is dropped, as in C). Port of `scanOnce`
252 /// (`dbScan.c:660`).
253 pub fn scan_once(&self, cb: OnceCallback) -> Result<(), ScanOnceOverflow> {
254 self.inner.ensure_worker();
255 self.inner.scan_once(cb)
256 }
257
258 /// Lifetime overflow count — C `onceQOverruns` (`dbScan.c:67`).
259 pub fn overflow_count(&self) -> u64 {
260 recover(FACILITY, self.inner.state.lock()).overflows
261 }
262
263 /// C `scanOnceQueueStatus` (`dbScan.c:734-751`).
264 pub fn stats(&self, reset: bool) -> ScanOnceQueueStats {
265 self.inner.stats(reset)
266 }
267}
268
269/// The `scanOnce` ring as `scanOnceQueueShow` prints it — C
270/// `scanOnceQueueStats` (`dbScan.h`).
271#[derive(Debug, Clone, Copy, PartialEq, Eq)]
272pub struct ScanOnceQueueStats {
273 /// Ring capacity — C `stats.size`.
274 pub size: usize,
275 /// Entries queued right now — C `stats.numUsed`.
276 pub num_used: usize,
277 /// Deepest the ring has been since the last reset — C `stats.maxUsed`.
278 pub max_used: usize,
279 /// Lifetime overflow count — C `stats.numOverflow` (`onceQOverruns`).
280 pub num_overflow: u64,
281}
282
283/// The capacity [`ScanOnceQueue::new`] will use — C's `onceQueueSize`
284/// file-static (`dbScan.c:64`), which `scanOnceSetQueueSize` writes and
285/// `initOnce` reads when it creates the ring (`dbScan.c:774`).
286static CONFIGURED_ONCE_QUEUE_SIZE: AtomicUsize = AtomicUsize::new(DEFAULT_ONCE_QUEUE_SIZE);
287
288/// C `scanOnceSetQueueSize` (`dbScan.c:728-732`), which is the bare
289/// assignment `onceQueueSize = size` — it validates nothing and reports
290/// nothing. Clamped to at least 1 here so the ring is always usable.
291pub fn set_queue_size(size: usize) {
292 CONFIGURED_ONCE_QUEUE_SIZE.store(size.max(1), Ordering::Relaxed);
293}
294
295/// The `scanOnce` facility: one bounded ring drained by one worker thread.
296///
297/// Dropping it stops and joins the worker.
298pub struct ScanOnceQueue {
299 inner: Arc<Inner>,
300}
301
302impl ScanOnceQueue {
303 /// Build with the C default capacity (`onceQueueSize`, `dbScan.c:64`).
304 pub fn new() -> Self {
305 Self::with_capacity(CONFIGURED_ONCE_QUEUE_SIZE.load(Ordering::Relaxed))
306 }
307
308 /// Build with an explicit ring capacity (clamped to at least 1).
309 pub fn with_capacity(capacity: usize) -> Self {
310 let inner = Arc::new(Inner {
311 capacity: capacity.max(1),
312 state: Mutex::new(OnceState {
313 queue: VecDeque::new(),
314 high_water: 0,
315 overflows: 0,
316 new_overflow: true,
317 shutdown: false,
318 }),
319 wake: Condvar::new(),
320 worker: Mutex::new(None),
321 });
322 ScanOnceQueue { inner }
323 }
324
325 /// Create the drain thread now — C `initOnce` (`dbScan.c:768-780`), which
326 /// `scanInit` calls once the `menuScan` count is known and before it
327 /// spawns the periodic threads (`dbScan.c:201-205`).
328 ///
329 /// The worker otherwise appears at the first `scanOnce`, which is late
330 /// enough that an IOC that has never run a one-shot has no `scanOnce`
331 /// thread where C always does — visible in `taskwdShow`, and it defers
332 /// C's fail-at-init contract for a thread that cannot be created. Calling
333 /// this at the port's own `scanInit` is what removes the difference; it
334 /// stays idempotent, so the lazy path is still the safety net for a
335 /// `scanOnce` that arrives before any IOC init.
336 pub fn start(&self) {
337 self.inner.ensure_worker();
338 }
339
340 /// A cheap, clonable submission handle (see [`ScanOnceHandle`]).
341 pub fn handle(&self) -> ScanOnceHandle {
342 ScanOnceHandle {
343 inner: Arc::clone(&self.inner),
344 }
345 }
346
347 /// Enqueue `cb` for one-shot processing — convenience wrapper over
348 /// [`ScanOnceHandle::scan_once`].
349 pub fn scan_once(&self, cb: OnceCallback) -> Result<(), ScanOnceOverflow> {
350 self.inner.ensure_worker();
351 self.inner.scan_once(cb)
352 }
353
354 /// Lifetime overflow count — C `onceQOverruns` (`dbScan.c:67`).
355 pub fn overflow_count(&self) -> u64 {
356 recover(FACILITY, self.inner.state.lock()).overflows
357 }
358
359 /// C `scanOnceQueueStatus` (`dbScan.c:734-751`): sample the ring and,
360 /// when `reset` is set, clear the high-water mark.
361 pub fn stats(&self, reset: bool) -> ScanOnceQueueStats {
362 self.inner.stats(reset)
363 }
364}
365
366impl Default for ScanOnceQueue {
367 fn default() -> Self {
368 Self::new()
369 }
370}
371
372impl Drop for ScanOnceQueue {
373 fn drop(&mut self) {
374 {
375 let mut st = recover(FACILITY, self.inner.state.lock());
376 st.shutdown = true;
377 }
378 self.inner.wake.notify_all();
379 let worker = recover(FACILITY, self.inner.worker.lock()).take();
380 if let Some(w) = worker {
381 let _ = w.join();
382 }
383 }
384}
385
386#[cfg(test)]
387mod tests {
388 use super::*;
389 use std::sync::atomic::{AtomicBool, Ordering};
390 use std::sync::mpsc;
391 use std::time::Duration;
392
393 const T: Duration = Duration::from_secs(5);
394
395 /// `dbScan.c:772` — scanOnce runs at `ScanLow + nPeriodic`, above every
396 /// periodic scan thread. Measured on the C IOC on RTEMS 6 as OSIPRI 67
397 /// with base's own seven-rate menu, which is what an IOC that overrides
398 /// nothing has.
399 #[test]
400 fn scan_once_band_is_scanlow_plus_n_periodic() {
401 assert_eq!(
402 scan_once_priority().value(),
403 ThreadPriority::ScanLow.value() + DEFAULT_PERIODIC_SCAN_BAND_COUNT as u8
404 );
405 assert_eq!(scan_once_priority().value(), 67);
406 }
407
408 /// A site menu with a different number of rates moves the band with it —
409 /// C computes `nPeriodic` from the loaded menu, so an IOC with ten rates
410 /// runs scanOnce at 70, still one band above its fastest scan thread.
411 #[test]
412 fn a_site_menu_moves_the_band_with_its_rate_count() {
413 set_periodic_scan_band_count(10);
414 assert_eq!(scan_once_priority().value(), 70);
415 set_periodic_scan_band_count(DEFAULT_PERIODIC_SCAN_BAND_COUNT);
416 assert_eq!(scan_once_priority().value(), 67);
417 }
418
419 /// Boundary: a queued tail that panics. `dbProcess` runs inside it, so
420 /// before this one bad record stopped every FLNK and every `scanOnce` in
421 /// the IOC, with nothing said.
422 #[test]
423 fn a_panicking_tail_does_not_stop_the_worker() {
424 let q = ScanOnceQueue::new();
425 q.scan_once(Box::new(|| panic!("a scanOnce tail panicked")))
426 .expect("enqueue the panicking tail");
427
428 let (tx, rx) = mpsc::channel();
429 q.scan_once(Box::new(move || tx.send(7u32).unwrap()))
430 .expect("enqueue the next tail");
431 assert_eq!(
432 rx.recv_timeout(T).unwrap(),
433 7,
434 "the tail after a panicking one never ran: the worker died with it"
435 );
436 }
437
438 #[test]
439 fn enqueue_returns_immediately_and_worker_drains() {
440 let q = ScanOnceQueue::new();
441 let (tx, rx) = mpsc::channel();
442 // Non-blocking by construction: this returns before the worker runs.
443 q.scan_once(Box::new(move || tx.send(7u32).unwrap()))
444 .unwrap();
445 assert_eq!(rx.recv_timeout(T).unwrap(), 7);
446 }
447
448 #[test]
449 fn overflow_latches_and_counts() {
450 // Boundary: capacity-1 ring, worker pinned busy → second live entry
451 // fills the ring, third overflows (dbScan.c:682).
452 let q = ScanOnceQueue::with_capacity(1);
453 let (started_tx, started_rx) = mpsc::channel();
454 let (gate_tx, gate_rx) = mpsc::channel::<()>();
455
456 // Worker picks this up and blocks inside it; ring is empty again.
457 q.scan_once(Box::new(move || {
458 started_tx.send(()).unwrap();
459 gate_rx.recv().unwrap();
460 }))
461 .unwrap();
462 started_rx.recv_timeout(T).unwrap();
463
464 // Fill the single ring slot (worker is busy).
465 q.scan_once(Box::new(|| {})).unwrap();
466 // Ring full → overflow, request dropped.
467 assert_eq!(q.scan_once(Box::new(|| {})), Err(ScanOnceOverflow));
468 assert_eq!(q.scan_once(Box::new(|| {})), Err(ScanOnceOverflow));
469 assert_eq!(q.overflow_count(), 2);
470
471 gate_tx.send(()).unwrap(); // release the worker so it can drain.
472 }
473
474 #[test]
475 fn scan_once_after_shutdown_is_silent_noop() {
476 // Boundary: a ScanOnceHandle that outlives the queue must get Ok(())
477 // and the tail must never run — not a spurious overflow error.
478 let q = ScanOnceQueue::new();
479 let h = q.handle();
480 drop(q); // sets shutdown, joins the worker.
481
482 let ran = Arc::new(AtomicBool::new(false));
483 let r = Arc::clone(&ran);
484 let res = h.scan_once(Box::new(move || r.store(true, Ordering::SeqCst)));
485 assert_eq!(res, Ok(())); // silent no-op, not Err(ScanOnceOverflow).
486 assert!(
487 !ran.load(Ordering::SeqCst),
488 "scanOnce tail ran after shutdown; it must be dropped, not processed"
489 );
490 assert_eq!(h.overflow_count(), 0); // a shutdown drop is not an overflow.
491 }
492}