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 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(if every >= RECORD_MIN {
ledger_sink()
} else {
JobSink::noop()
}),
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);
}
}