use std::panic::{catch_unwind, AssertUnwindSafe};
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use std::sync::{Arc, Mutex, OnceLock};
use std::time::{Duration, Instant};
use jera::jobs::chain::{ChainQueue, ChainSpec, Priority};
use jera::jobs::JobSink;
pub use nordisk_pairing::duty::{Round, Say};
const DOMAIN: &str = "gunnar";
pub const DEFER_POLL: Duration = Duration::from_millis(20);
pub struct Job {
kind: String,
queue: Arc<ChainQueue>,
stopped: AtomicBool,
in_look: AtomicBool,
looks: AtomicU64,
failures: AtomicU64,
last_failure: Mutex<Option<String>>,
wake: tokio::sync::Notify,
tasks: Mutex<Vec<tokio::task::JoinHandle<()>>>,
}
impl Job {
pub fn kind(&self) -> &str {
&self.kind
}
pub fn looks(&self) -> u64 {
self.looks.load(Ordering::SeqCst)
}
pub fn failures(&self) -> u64 {
self.failures.load(Ordering::SeqCst)
}
pub fn last_failure(&self) -> Option<String> {
self.last_failure.lock().unwrap_or_else(|p| p.into_inner()).clone()
}
pub fn is_stopped(&self) -> bool {
self.stopped.load(Ordering::SeqCst)
}
pub fn queue(&self) -> &Arc<ChainQueue> {
&self.queue
}
pub fn stop(&self, grace: Duration) -> bool {
self.tell_stop();
let until = Instant::now() + grace;
while self.in_look.load(Ordering::SeqCst) {
if Instant::now() >= until {
return false;
}
std::thread::sleep(Duration::from_millis(5));
}
true
}
fn tell_stop(&self) {
self.stopped.store(true, Ordering::SeqCst);
self.wake.notify_waiters();
for t in self.tasks.lock().unwrap_or_else(|p| p.into_inner()).drain(..) {
t.abort();
}
}
fn failed(&self, n: u64, why: &str, say: &Say) {
self.failures.fetch_add(1, Ordering::SeqCst);
*self.last_failure.lock().unwrap_or_else(|p| p.into_inner()) = Some(why.to_string());
say(&format!("ALERT {} look {n} {why}; the next look runs as due", self.kind));
}
}
fn panic_words(p: &(dyn std::any::Any + Send)) -> String {
p.downcast_ref::<&str>().map(|s| s.to_string()).or_else(|| p.downcast_ref::<String>().cloned()).unwrap_or_else(|| "(no words)".to_string())
}
fn registry() -> &'static Mutex<Vec<Arc<Job>>> {
static R: OnceLock<Mutex<Vec<Arc<Job>>>> = OnceLock::new();
R.get_or_init(Mutex::default)
}
pub fn running() -> Vec<Arc<Job>> {
registry().lock().unwrap_or_else(|p| p.into_inner()).iter().filter(|j| !j.is_stopped()).cloned().collect()
}
pub fn find(kind: &str) -> Option<Arc<Job>> {
running().into_iter().find(|j| j.kind == kind)
}
pub fn stop_all(grace: Duration) -> usize {
let all = running();
for j in &all {
j.tell_stop();
}
let until = Instant::now() + grace;
for j in &all {
j.stop(until.saturating_duration_since(Instant::now()));
}
close_orphans("the box stopped before this job ended");
all.len()
}
pub const RECORD_MIN: Duration = Duration::from_secs(30);
pub fn set_ledger(dir: &std::path::Path) -> Result<(), String> {
let store = jera::jobs::JobStore::open(dir).map_err(|e| format!("the jobs' ledger under {} does not open: {e:#}", dir.display()))?;
*ledger().lock().unwrap_or_else(|p| p.into_inner()) = Some(Arc::new(store));
let closed = close_orphans("the box restarted before this job ended");
if closed > 0 {
tracing::warn!("the jobs' ledger: {closed} row(s) a stopped run left running are marked stopped");
}
Ok(())
}
pub fn close_orphans(why: &str) -> usize {
let Some(store) = ledger().lock().unwrap_or_else(|p| p.into_inner()).clone() else { return 0 };
let open: std::collections::BTreeSet<String> = store.list(&jera::jobs::JobSelector::All).unwrap_or_default().into_iter().filter(|r| !r.is_terminal()).map(|r| r.kind).collect();
let kinds: Vec<&str> = open.iter().map(String::as_str).collect();
if kinds.is_empty() {
0
} else {
store.stop_orphans(&kinds, why)
}
}
fn ledger() -> &'static Mutex<Option<Arc<jera::jobs::JobStore>>> {
static L: OnceLock<Mutex<Option<Arc<jera::jobs::JobStore>>>> = OnceLock::new();
L.get_or_init(Mutex::default)
}
pub fn ledger_sink() -> JobSink {
ledger().lock().unwrap_or_else(|p| p.into_inner()).as_ref().map(|s| s.sink()).unwrap_or_else(JobSink::noop)
}
pub fn ledger_rows(kind: &str) -> Vec<jera::jobs::JobRecord> {
let store = ledger().lock().unwrap_or_else(|p| p.into_inner()).clone();
store.and_then(|s| s.list(&jera::jobs::JobSelector::Kind(kind.to_string())).ok()).unwrap_or_default()
}
pub fn handle() -> Result<tokio::runtime::Handle, String> {
if let Ok(h) = tokio::runtime::Handle::try_current() {
return Ok(h);
}
static OWN: OnceLock<Result<tokio::runtime::Runtime, String>> = OnceLock::new();
OWN.get_or_init(|| tokio::runtime::Builder::new_multi_thread().worker_threads(2).thread_name("gunnar-jobs").enable_all().build().map_err(|e| format!("the jobs' runtime cannot start: {e}")))
.as_ref()
.map(|rt| rt.handle().clone())
.map_err(Clone::clone)
}
pub fn start(kind: &str, every: Duration, say: Say, look: impl FnMut() -> Round + Send + 'static) -> Result<Arc<Job>, String> {
let rt = handle().map_err(|e| format!("the job `{kind}` cannot start: {e}"))?;
let job = Arc::new(Job {
kind: kind.to_string(),
queue: ChainQueue::new_with_poll(if every >= RECORD_MIN { ledger_sink() } else { JobSink::noop() }, DEFER_POLL),
stopped: AtomicBool::new(false),
in_look: AtomicBool::new(false),
looks: AtomicU64::new(0),
failures: AtomicU64::new(0),
last_failure: Mutex::new(None),
wake: tokio::sync::Notify::new(),
tasks: Mutex::new(Vec::new()),
});
let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel::<Round>();
let look = Mutex::new(look);
let j = Arc::clone(&job);
let worker = rt.spawn(Arc::clone(&job.queue).run(move |spec| {
let n = u64::try_from(spec.generation).unwrap_or(0);
if j.is_stopped() {
let _ = tx.send(Round::Done);
return Ok(serde_json::json!({ "look": n, "ran": false, "why": "the job is stopped" }));
}
j.in_look.store(true, Ordering::SeqCst);
j.looks.fetch_add(1, Ordering::SeqCst);
let answer = catch_unwind(AssertUnwindSafe(|| (look.lock().unwrap_or_else(|p| p.into_inner()))()));
j.in_look.store(false, Ordering::SeqCst);
match answer {
Ok(round) => {
let _ = tx.send(round);
Ok(serde_json::json!({ "look": n, "done": round == Round::Done }))
}
Err(p) => {
let why = format!("panicked: {}", panic_words(p.as_ref()));
j.failed(n, &why, &say);
let _ = tx.send(Round::Again);
Err(anyhow::anyhow!(why))
}
}
}));
let j = Arc::clone(&job);
let ticker = rt.spawn(async move {
let mut n = 0u64;
loop {
n += 1;
let generation = i64::try_from(n).unwrap_or(i64::MAX);
let spec = ChainSpec::new(j.kind.as_str(), format!("{}-{n}", j.kind), j.kind.as_str(), generation, DOMAIN, serde_json::json!({ "look": n })).with_target(format!("look {n} of {}", j.kind)).with_priority(Priority::Normal);
let _ = j.queue.submit(spec);
let next = match rx.recv().await {
Some(Round::Done) | None => {
j.stopped.store(true, Ordering::SeqCst);
return;
}
Some(Round::Again) => every,
Some(Round::After(d)) => d,
};
tokio::select! {
_ = tokio::time::sleep(next) => {}
_ = j.wake.notified() => {}
}
if j.is_stopped() {
return;
}
}
});
job.tasks.lock().unwrap_or_else(|p| p.into_inner()).extend([worker, ticker]);
let mut all = registry().lock().unwrap_or_else(|p| p.into_inner());
all.retain(|j| !j.is_stopped());
all.push(Arc::clone(&job));
drop(all);
Ok(job)
}
#[cfg(test)]
mod tests {
use super::*;
static ONE: Mutex<()> = Mutex::new(());
fn heard() -> (Say, Arc<Mutex<Vec<String>>>) {
let lines = Arc::new(Mutex::new(Vec::new()));
let l = Arc::clone(&lines);
(Arc::new(move |s: &str| l.lock().unwrap().push(s.to_string())), lines)
}
fn until(what: impl Fn() -> bool) -> bool {
let t = Instant::now();
while t.elapsed() < Duration::from_secs(10) {
if what() {
return true;
}
std::thread::sleep(Duration::from_millis(10));
}
false
}
const FAST: Duration = Duration::from_millis(20);
#[test]
fn a_look_that_panics_is_said_counted_failed_and_the_job_goes_on() {
let _one = ONE.lock().unwrap_or_else(|p| p.into_inner());
let (say, lines) = heard();
let mut n = 0;
let j = start("test.panics", FAST, say, move || {
n += 1;
if n == 1 {
panic!("the first look breaks")
}
Round::Again
})
.expect("a job starts");
assert!(until(|| j.looks() >= 3), "the looks after the panic run (looks {})", j.looks());
assert_eq!(j.failures(), 1, "the panic is counted");
assert!(j.last_failure().unwrap_or_default().contains("the first look breaks"), "{:?}", j.last_failure());
let said = lines.lock().unwrap().join("\n");
assert!(said.contains("ALERT test.panics") && said.contains("panicked"), "the panic is an ALERT line: {said:?}");
assert!(until(|| j.queue().stats().failed >= 1 && j.queue().stats().done >= 2), "the panicked look is a FAILED job, the others DONE: {:?}", j.queue().stats());
j.stop(Duration::from_secs(1));
}
#[test]
fn a_stopped_job_looks_no_more() {
let _one = ONE.lock().unwrap_or_else(|p| p.into_inner());
let (say, _) = heard();
let j = start("test.stops", FAST, say, || Round::Again).expect("a job starts");
assert!(until(|| j.looks() >= 1));
assert!(find("test.stops").is_some(), "a running job is listed");
assert!(stop_all(Duration::from_secs(1)) >= 1);
assert!(j.is_stopped() && find("test.stops").is_none(), "a stopped job is not listed");
std::thread::sleep(Duration::from_millis(100));
let at = j.looks();
std::thread::sleep(Duration::from_millis(200));
assert_eq!(j.looks(), at, "no look runs after the stop");
}
#[test]
fn the_stop_ends_a_long_wait_at_once() {
let _one = ONE.lock().unwrap_or_else(|p| p.into_inner());
let (say, _) = heard();
let j = start("test.long-wait", Duration::from_secs(3600), say, || Round::Again).expect("a job starts");
assert!(until(|| j.looks() == 1), "the first look runs at once");
let t = Instant::now();
assert!(j.stop(Duration::from_secs(2)), "stopped");
assert!(t.elapsed() < Duration::from_secs(1), "the stop did not wait out the period");
}
#[test]
fn done_ends_the_job_and_after_is_the_looks_own_wait() {
let _one = ONE.lock().unwrap_or_else(|p| p.into_inner());
let (say, _) = heard();
let mut n = 0;
let j = start("test.after", Duration::from_secs(3600), say, move || {
n += 1;
if n >= 3 {
Round::Done
} else {
Round::After(Duration::from_millis(10))
}
})
.expect("a job starts");
assert!(until(|| j.is_stopped()), "Done stops the job (looks {})", j.looks());
std::thread::sleep(Duration::from_millis(150));
assert_eq!(j.looks(), 3, "no look after Done");
assert!(find("test.after").is_none());
}
#[test]
fn jobs_started_at_the_same_instant_all_look_at_once() {
let _one = ONE.lock().unwrap_or_else(|p| p.into_inner());
let looked = Arc::new(AtomicU64::new(0));
let began = Instant::now();
let rt = tokio::runtime::Builder::new_multi_thread().worker_threads(8).enable_all().build().unwrap();
let together = Arc::new(std::sync::Barrier::new(8));
let starters: Vec<_> = (0..8)
.map(|i| {
let l = Arc::clone(&looked);
let together = Arc::clone(&together);
let h = rt.handle().clone();
std::thread::spawn(move || {
let _on = h.enter();
let (say, _) = heard();
together.wait();
start(&format!("test.burst-{i}"), Duration::from_secs(3600), say, move || {
l.fetch_add(1, Ordering::SeqCst);
Round::Done
})
.expect("a job starts")
})
})
.collect();
let jobs: Vec<_> = starters.into_iter().map(|t| t.join().unwrap()).collect();
assert!(until(|| looked.load(Ordering::SeqCst) == 8));
assert!(began.elapsed() < Duration::from_millis(1000), "eight first looks took {:?}", began.elapsed());
drop(jobs);
}
#[test]
fn with_a_ledger_a_recorded_dutys_looks_are_rows_and_a_panic_a_failed_row() {
let _one = ONE.lock().unwrap_or_else(|p| p.into_inner());
let dir = std::env::temp_dir().join(format!("gunnar-jobs-ledger-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
set_ledger(&dir).expect("the ledger opens");
let (say, _) = heard();
let mut n = 0;
let j = start("test.recorded", RECORD_MIN, say, move || {
n += 1;
match n {
1 => Round::After(Duration::from_millis(10)),
_ => panic!("the second look breaks"),
}
})
.expect("a job starts");
assert!(until(|| ledger_rows("test.recorded").iter().filter(|r| r.status == "done" || r.status == "failed").count() >= 2), "two finished rows: {:?}", ledger_rows("test.recorded").iter().map(|r| r.status.clone()).collect::<Vec<_>>());
let rows = ledger_rows("test.recorded");
assert!(rows.iter().any(|r| r.status == "done") && rows.iter().any(|r| r.status == "failed"), "a done row and a FAILED row");
j.stop(Duration::from_secs(1));
let (say, _) = heard();
let quick = start("test.unrecorded", FAST, say, || Round::Again).expect("a job starts");
assert!(until(|| quick.looks() >= 3));
assert!(ledger_rows("test.unrecorded").is_empty(), "a frequent duty writes no rows");
quick.stop(Duration::from_secs(1));
}
#[test]
fn a_row_left_running_reads_stopped_after_the_ledger_opens() {
let _one = ONE.lock().unwrap_or_else(|p| p.into_inner());
let dir = std::env::temp_dir().join(format!("gunnar-jobs-orphan-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
{
let store = jera::jobs::JobStore::open(&dir).expect("a ledger");
let q = ChainQueue::new(store.sink());
let _ = q.submit(ChainSpec::new("test.orphan", "k", "test.orphan", 0, DOMAIN, serde_json::json!({})));
}
set_ledger(&dir).expect("the ledger opens");
let rows = ledger_rows("test.orphan");
assert!(!rows.is_empty() && rows.iter().all(|r| r.status == "stopped"), "{:?}", rows.iter().map(|r| r.status.clone()).collect::<Vec<_>>());
}
#[test]
fn a_job_started_with_no_runtime_runs() {
let _one = ONE.lock().unwrap_or_else(|p| p.into_inner());
let (say, _) = heard();
let j = std::thread::spawn(move || start("test.no-runtime", FAST, say, || Round::Done)).join().unwrap().expect("a job starts");
assert!(until(|| j.is_stopped() && j.looks() == 1));
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
#[allow(clippy::await_holding_lock)] async fn a_job_started_on_a_runtime_runs_there() {
let _one = ONE.lock().unwrap_or_else(|p| p.into_inner());
let (say, _) = heard();
let j = start("test.on-runtime", FAST, say, || Round::Done).expect("a job starts");
let t = Instant::now();
while !(j.is_stopped() && j.looks() == 1) && t.elapsed() < Duration::from_secs(10) {
tokio::time::sleep(Duration::from_millis(10)).await;
}
assert!(j.is_stopped() && j.looks() == 1);
}
}