Skip to main content

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}