use std::collections::HashSet;
use std::sync::{Arc, Condvar, Mutex};
use std::time::{Duration, Instant};
use crate::runtime::background::facility::{recover, run_isolated};
use crate::runtime::task::{StackSizeClass, ThreadPriority, enter_ioc_thread};
use crate::server::database::PvDatabase;
use crate::server::record::ScanType;
pub(crate) struct ScanScheduler {
db: Arc<PvDatabase>,
}
pub(crate) const PERIODIC_SCANS: &[ScanType] = &[
ScanType::Sec10,
ScanType::Sec5,
ScanType::Sec2,
ScanType::Sec1,
ScanType::Sec05,
ScanType::Sec02,
ScanType::Sec01,
];
const _: () = assert!(
PERIODIC_SCANS.len() == crate::runtime::background::scan_once::PERIODIC_SCAN_BAND_COUNT,
"adding or removing a periodic scan rate moves the scanOnce worker's band \
(dbScan.c:776) — update PERIODIC_SCAN_BAND_COUNT in epics-libcom-rs to match"
);
const FACILITY: &str = "periodic scan";
fn periodic_priority(ind: usize) -> ThreadPriority {
ThreadPriority::Custom(ThreadPriority::ScanLow.value() + ind as u8)
}
fn periodic_thread_name(period: Duration) -> String {
format!("scan-{}", period.as_secs_f64())
}
struct ScanStop {
stopped: Mutex<bool>,
wake: Condvar,
}
struct ScanStopGuard(Arc<ScanStop>);
impl Drop for ScanStopGuard {
fn drop(&mut self) {
*recover(FACILITY, self.0.stopped.lock()) = true;
self.0.wake.notify_all();
}
}
#[derive(Clone)]
struct TickDriver {
#[cfg(tokio_backend)]
handle: tokio::runtime::Handle,
}
impl TickDriver {
fn capture() -> Self {
Self {
#[cfg(tokio_backend)]
handle: tokio::runtime::Handle::try_current()
.expect("ScanScheduler::run must be called inside a tokio runtime"),
}
}
fn drive<F: Future>(&self, fut: F) -> F::Output {
#[cfg(tokio_backend)]
{
self.handle.block_on(fut)
}
#[cfg(exec_backend)]
{
match crate::runtime::task::block_on_sync(fut) {
Ok(out) => out,
Err(e) => unreachable!("a periodic scan thread is blockable: {e}"),
}
}
}
}
fn periodic_loop(
db: Arc<PvDatabase>,
scan_type: ScanType,
period: Duration,
stop: Arc<ScanStop>,
driver: TickDriver,
) {
let mut next = Instant::now() + period;
loop {
let mut stopped = recover(FACILITY, stop.stopped.lock());
loop {
if *stopped {
return;
}
let now = Instant::now();
if now >= next {
break;
}
let (guard, _timeout) = recover(FACILITY, stop.wake.wait_timeout(stopped, next - now));
stopped = guard;
}
drop(stopped);
run_isolated(FACILITY, || {
driver.drive(async {
let names = db.records_for_scan(scan_type).await;
for name in &names {
let mut visited = HashSet::new();
let _ = db.process_record_with_links(name, &mut visited, 0).await;
}
});
});
next += period;
let now = Instant::now();
if next <= now {
next = now + period;
}
}
}
impl ScanScheduler {
pub(crate) fn new(db: Arc<PvDatabase>) -> Self {
Self { db }
}
pub(crate) async fn run(&self) {
let is_first = self.db.try_claim_scan_start();
if !is_first {
std::future::pending::<()>().await;
return;
}
if !self.db.pini_done() {
self.db
.pini_process(crate::server::record::PiniMode::Yes)
.await;
}
self.db.mark_pini_done();
let stop = Arc::new(ScanStop {
stopped: Mutex::new(false),
wake: Condvar::new(),
});
let guard = ScanStopGuard(Arc::clone(&stop));
let driver = TickDriver::capture();
for (ind, &scan_type) in PERIODIC_SCANS.iter().enumerate() {
if let Some(period) = scan_type.interval() {
let db = Arc::clone(&self.db);
let stop = Arc::clone(&stop);
let driver = driver.clone();
std::thread::Builder::new()
.name(periodic_thread_name(period))
.stack_size(StackSizeClass::Big.bytes())
.spawn(move || {
let _ = enter_ioc_thread(periodic_priority(ind));
periodic_loop(db, scan_type, period, stop, driver);
})
.expect("failed to spawn periodic scan thread");
}
}
let _guard = guard;
std::future::pending::<()>().await;
}
}
pub struct ScanOwner {
stop: Option<crate::runtime::sync::oneshot::Sender<()>>,
join: Option<std::thread::JoinHandle<()>>,
}
impl ScanOwner {
pub fn start(db: Arc<PvDatabase>) -> Self {
let (stop_tx, stop_rx) = crate::runtime::sync::oneshot::channel::<()>();
#[cfg(tokio_backend)]
let handle = tokio::runtime::Handle::try_current()
.expect("ScanOwner::start on the tokio backend must be called inside a tokio runtime");
let join = std::thread::Builder::new()
.name("scan-owner".to_string())
.stack_size(StackSizeClass::Medium.bytes())
.spawn(move || {
let _ = enter_ioc_thread(ThreadPriority::Low);
let scheduler = ScanScheduler::new(db);
let owner = async move {
tokio::select! {
_ = scheduler.run() => {}
_ = stop_rx => {}
}
};
#[cfg(tokio_backend)]
handle.block_on(owner);
#[cfg(exec_backend)]
match crate::runtime::task::block_on_sync(owner) {
Ok(()) => {}
Err(e) => unreachable!("the scan-owner thread is a plain std thread: {e}"),
}
})
.expect("failed to spawn the scan-owner thread");
Self {
stop: Some(stop_tx),
join: Some(join),
}
}
}
impl Drop for ScanOwner {
fn drop(&mut self) {
if let Some(tx) = self.stop.take() {
let _ = tx.send(());
}
if let Some(join) = self.join.take() {
let _ = join.join();
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn periodic_ladder_matches_dbscan() {
let expected: &[(ScanType, u8, &str)] = &[
(ScanType::Sec10, 60, "scan-10"),
(ScanType::Sec5, 61, "scan-5"),
(ScanType::Sec2, 62, "scan-2"),
(ScanType::Sec1, 63, "scan-1"),
(ScanType::Sec05, 64, "scan-0.5"),
(ScanType::Sec02, 65, "scan-0.2"),
(ScanType::Sec01, 66, "scan-0.1"),
];
assert_eq!(PERIODIC_SCANS.len(), expected.len());
for (ind, &(scan_type, prio, name)) in expected.iter().enumerate() {
assert_eq!(PERIODIC_SCANS[ind], scan_type, "order is load-bearing");
assert_eq!(periodic_priority(ind).value(), prio);
let period = scan_type.interval().expect("periodic rate has a period");
assert_eq!(periodic_thread_name(period), name);
}
}
#[test]
fn periodic_ladder_stays_inside_the_scan_band() {
for ind in 0..PERIODIC_SCANS.len() {
let v = periodic_priority(ind).value();
assert!(v >= ThreadPriority::ScanLow.value());
assert!(v < ThreadPriority::ScanHigh.value());
assert!(v > ThreadPriority::CaServerHigh.value());
}
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn cancelling_the_scheduler_stops_the_scan_threads() {
let db = Arc::new(PvDatabase::new());
let scheduler = ScanScheduler::new(Arc::clone(&db));
let task = tokio::spawn(async move { scheduler.run().await });
let deadline = Instant::now() + Duration::from_secs(10);
while Arc::strong_count(&db) < 2 + PERIODIC_SCANS.len() {
assert!(Instant::now() < deadline, "scan threads never started");
tokio::time::sleep(Duration::from_millis(10)).await;
}
task.abort();
let _ = task.await;
let deadline = Instant::now() + Duration::from_secs(10);
while Arc::strong_count(&db) > 1 {
assert!(
Instant::now() < deadline,
"scan threads still alive after cancellation: {} Arc holders",
Arc::strong_count(&db)
);
tokio::time::sleep(Duration::from_millis(10)).await;
}
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn a_periodic_tick_processes_on_its_own_banded_scan_thread() {
use crate::error::CaResult;
use crate::server::record::{FieldDesc, ProcessOutcome, Record};
use crate::types::EpicsValue;
struct ThreadProbe(Arc<Mutex<Option<String>>>);
impl Record for ThreadProbe {
fn record_type(&self) -> &'static str {
"scan_thread_probe"
}
fn process(&mut self) -> CaResult<ProcessOutcome> {
let name = std::thread::current().name().map(str::to_string);
*self.0.lock().expect("probe mutex") = name;
Ok(ProcessOutcome::complete())
}
fn get_field(&self, name: &str) -> Option<EpicsValue> {
match name {
"VAL" => Some(EpicsValue::Double(0.0)),
_ => None,
}
}
fn put_field(&mut self, _name: &str, _value: EpicsValue) -> CaResult<()> {
Ok(())
}
fn declared_fields(&self) -> &'static [FieldDesc] {
&[]
}
}
let seen = Arc::new(Mutex::new(None::<String>));
let db = Arc::new(PvDatabase::new());
db.add_record("SCAN:THREAD", Box::new(ThreadProbe(Arc::clone(&seen))))
.await
.unwrap();
{
let rec = db.get_record("SCAN:THREAD").unwrap();
rec.write().common.scan = ScanType::Sec01;
}
db.update_scan_index("SCAN:THREAD", ScanType::Passive, ScanType::Sec01, 0, 0);
let owner = ScanOwner::start(Arc::clone(&db));
let deadline = Instant::now() + Duration::from_secs(10);
let name = loop {
if let Some(n) = seen.lock().expect("probe mutex").clone() {
break n;
}
assert!(Instant::now() < deadline, "the record was never scanned");
tokio::time::sleep(Duration::from_millis(10)).await;
};
drop(owner);
let expected = periodic_thread_name(ScanType::Sec01.interval().unwrap());
assert_eq!(
name, expected,
"the .1 second tick processed on `{name}`, not on its own \
banded `{expected}` thread — periodic scan is back on a \
shared pool"
);
}
async fn wait_for_count(db: &Arc<PvDatabase>, what: &str, pred: impl Fn(usize) -> bool) {
let deadline = Instant::now() + Duration::from_secs(10);
while !pred(Arc::strong_count(db)) {
assert!(
Instant::now() < deadline,
"{what}: {} Arc holders",
Arc::strong_count(db)
);
tokio::time::sleep(Duration::from_millis(10)).await;
}
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn dropping_the_scan_owner_stops_the_scan_threads() {
let db = Arc::new(PvDatabase::new());
let owner = ScanOwner::start(Arc::clone(&db));
wait_for_count(&db, "scan threads never started", |n| {
n >= 2 + PERIODIC_SCANS.len()
})
.await;
drop(owner);
wait_for_count(&db, "scan threads still alive after ScanOwner drop", |n| {
n == 1
})
.await;
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn the_owner_skips_pini_when_the_init_path_already_ran_it() {
use crate::server::record::PiniMode;
use crate::server::records::ai::AiRecord;
use crate::types::EpicsValue;
let db = Arc::new(PvDatabase::new());
db.add_record("PINI:ONCE", Box::new(AiRecord::new(1.5)))
.await
.unwrap();
{
let rec = db.get_record("PINI:ONCE").unwrap();
let mut inst = rec.write();
inst.put_common_field("PINI", EpicsValue::String("YES".into()))
.unwrap();
inst.common.udf = 0;
}
db.pini_process(PiniMode::Yes).await;
db.mark_pini_done();
let t_init = db.get_record("PINI:ONCE").unwrap().read().common.time;
let owner = ScanOwner::start(Arc::clone(&db));
wait_for_count(&db, "scan threads never started", |n| {
n >= 2 + PERIODIC_SCANS.len()
})
.await;
let t_owner = db.get_record("PINI:ONCE").unwrap().read().common.time;
assert_eq!(
t_owner, t_init,
"the owner re-ran the PINI=YES pass the init path already ran"
);
drop(owner);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn the_owner_runs_pini_when_nothing_pre_ran_it() {
use crate::server::records::ai::AiRecord;
use crate::types::EpicsValue;
let db = Arc::new(PvDatabase::new());
db.add_record("PINI:OWNED", Box::new(AiRecord::new(2.5)))
.await
.unwrap();
let t_unprocessed = {
let rec = db.get_record("PINI:OWNED").unwrap();
let mut inst = rec.write();
inst.put_common_field("PINI", EpicsValue::String("YES".into()))
.unwrap();
inst.common.udf = 0;
inst.common.time
};
let owner = ScanOwner::start(Arc::clone(&db));
wait_for_count(&db, "scan threads never started", |n| {
n >= 2 + PERIODIC_SCANS.len()
})
.await;
let t_owner = db.get_record("PINI:OWNED").unwrap().read().common.time;
assert!(
t_owner > t_unprocessed,
"the owner must run the PINI=YES pass when the init path did not"
);
drop(owner);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn a_redundant_scan_owner_parks_and_its_drop_is_harmless() {
let db = Arc::new(PvDatabase::new());
let first = ScanOwner::start(Arc::clone(&db));
wait_for_count(&db, "scan threads never started", |n| {
n >= 2 + PERIODIC_SCANS.len()
})
.await;
let with_first = Arc::strong_count(&db) - 1;
let second = ScanOwner::start(Arc::clone(&db));
drop(second);
wait_for_count(&db, "second owner's drop leaked or killed holders", |n| {
n == with_first + 1
})
.await;
drop(first);
wait_for_count(
&db,
"scan threads still alive after first owner drop",
|n| n == 1,
)
.await;
}
}