use std::collections::{HashMap, VecDeque};
use std::fmt;
use std::ops::Deref;
use std::panic::AssertUnwindSafe;
use std::sync::atomic::{AtomicU64, AtomicUsize, Ordering};
use std::sync::{Arc, Condvar, Mutex};
use std::time::{Duration, SystemTime, UNIX_EPOCH};
pub const DEFAULT_FINISHED_RETENTION: Duration = Duration::from_secs(600);
pub const DEFAULT_FINISHED_KEPT: usize = 64;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum JobState {
Queued,
Running,
Done,
Failed,
}
impl JobState {
pub fn is_live(self) -> bool {
matches!(self, JobState::Queued | JobState::Running)
}
}
pub trait JobKind: Send + Sync + Sized + 'static {
const NAME: &'static str;
const FINISHED_RETENTION: Duration = DEFAULT_FINISHED_RETENTION;
const FINISHED_KEPT: usize = DEFAULT_FINISHED_KEPT;
type Order: Clone + fmt::Debug + Send + Sync + 'static;
type Report: Clone + fmt::Debug + Default + Send + Sync + 'static;
fn key(order: &Self::Order) -> &str;
fn run(&self, order: &Self::Order, progress: &Reporter<Self>) -> Result<(), String>;
fn settle(
&self,
_order: &Self::Order,
outcome: Result<(), String>,
_progress: &Reporter<Self>,
) -> (JobState, Option<String>) {
match outcome {
Ok(()) => (JobState::Done, None),
Err(why) => (JobState::Failed, Some(why)),
}
}
fn shutdown_error(&self) -> String {
format!("the {} queue is shutting down", Self::NAME)
}
fn spawn_error(&self, err: &std::io::Error) -> String {
format!("the {} worker could not be started: {err}", Self::NAME)
}
}
pub struct Job<K: JobKind> {
pub job_id: String,
pub key: String,
pub state: JobState,
pub queue_position: u32,
pub progress: Option<(u64, u64)>,
pub error: Option<String>,
pub order: K::Order,
pub report: K::Report,
pub enqueued_at_unix_ms: i64,
pub started_at_unix_ms: i64,
pub finished_at_unix_ms: i64,
}
impl<K: JobKind> Deref for Job<K> {
type Target = K::Report;
fn deref(&self) -> &K::Report {
&self.report
}
}
impl<K: JobKind> Clone for Job<K> {
fn clone(&self) -> Self {
Job {
job_id: self.job_id.clone(),
key: self.key.clone(),
state: self.state,
queue_position: self.queue_position,
progress: self.progress,
error: self.error.clone(),
order: self.order.clone(),
report: self.report.clone(),
enqueued_at_unix_ms: self.enqueued_at_unix_ms,
started_at_unix_ms: self.started_at_unix_ms,
finished_at_unix_ms: self.finished_at_unix_ms,
}
}
}
impl<K: JobKind> fmt::Debug for Job<K> {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("Job")
.field("kind", &K::NAME)
.field("job_id", &self.job_id)
.field("key", &self.key)
.field("state", &self.state)
.field("queue_position", &self.queue_position)
.field("progress", &self.progress)
.field("error", &self.error)
.field("order", &self.order)
.field("report", &self.report)
.field("enqueued_at_unix_ms", &self.enqueued_at_unix_ms)
.field("started_at_unix_ms", &self.started_at_unix_ms)
.field("finished_at_unix_ms", &self.finished_at_unix_ms)
.finish()
}
}
pub struct Reporter<K: JobKind> {
inner: Arc<Inner<K>>,
key: String,
}
impl<K: JobKind> Clone for Reporter<K> {
fn clone(&self) -> Self {
Reporter {
inner: Arc::clone(&self.inner),
key: self.key.clone(),
}
}
}
impl<K: JobKind> Reporter<K> {
pub fn key(&self) -> &str {
&self.key
}
pub fn progress(&self, done: u64, total: u64) {
self.edit(|job| job.progress = Some((done, total)));
}
pub fn report(&self, apply: impl FnOnce(&mut K::Report)) {
self.edit(|job| apply(&mut job.report));
}
pub fn snapshot(&self) -> Option<Job<K>> {
let state = self.inner.state.lock().unwrap_or_else(|p| p.into_inner());
state.jobs.get(&self.key).cloned()
}
fn edit(&self, apply: impl FnOnce(&mut Job<K>)) {
let mut state = self.inner.state.lock().unwrap_or_else(|p| p.into_inner());
if let Some(job) = state.jobs.get_mut(&self.key) {
apply(job);
}
}
}
pub struct JobQueue<K: JobKind> {
inner: Arc<Inner<K>>,
}
impl<K: JobKind> Clone for JobQueue<K> {
fn clone(&self) -> Self {
JobQueue {
inner: Arc::clone(&self.inner),
}
}
}
struct Inner<K: JobKind> {
state: Mutex<State<K>>,
wake: Condvar,
kind: K,
in_flight: AtomicUsize,
peak_in_flight: AtomicUsize,
runs: AtomicU64,
seq: AtomicU64,
}
struct State<K: JobKind> {
waiting: VecDeque<String>,
jobs: HashMap<String, Job<K>>,
started: bool,
stopping: bool,
}
impl<K: JobKind> Default for State<K> {
fn default() -> Self {
State {
waiting: VecDeque::new(),
jobs: HashMap::new(),
started: false,
stopping: false,
}
}
}
pub(crate) fn now_ms() -> i64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_millis() as i64)
.unwrap_or_default()
}
impl<K: JobKind> JobQueue<K> {
pub fn new(kind: K) -> Self {
JobQueue {
inner: Arc::new(Inner {
state: Mutex::new(State::default()),
wake: Condvar::new(),
kind,
in_flight: AtomicUsize::new(0),
peak_in_flight: AtomicUsize::new(0),
runs: AtomicU64::new(0),
seq: AtomicU64::new(0),
}),
}
}
pub fn next_job_id(&self) -> String {
self.mint()
}
pub fn enqueue(&self, order: K::Order) -> Job<K> {
self.enqueue_inner(order, None)
}
pub fn enqueue_as(&self, order: K::Order, job_id: String) -> Job<K> {
self.enqueue_inner(order, Some(job_id))
}
pub fn status(&self, key: &str) -> Option<Job<K>> {
let state = self.inner.state.lock().unwrap_or_else(|p| p.into_inner());
let job = state.jobs.get(key)?.clone();
Some(with_position(&state, job))
}
pub fn peak_in_flight(&self) -> usize {
self.inner.peak_in_flight.load(Ordering::SeqCst)
}
pub fn runs(&self) -> u64 {
self.inner.runs.load(Ordering::SeqCst)
}
pub fn stop(&self) {
let mut state = self.inner.state.lock().unwrap_or_else(|p| p.into_inner());
state.stopping = true;
self.inner.wake.notify_all();
}
fn mint(&self) -> String {
let id = self.inner.seq.fetch_add(1, Ordering::SeqCst) + 1;
format!("{}-{id}", K::NAME)
}
fn enqueue_inner(&self, order: K::Order, preminted: Option<String>) -> Job<K> {
let key = K::key(&order).to_owned();
let mut state = self.inner.state.lock().unwrap_or_else(|p| p.into_inner());
if state.stopping {
let now = now_ms();
let refused: Job<K> = Job {
job_id: preminted.unwrap_or_default(),
key,
state: JobState::Failed,
queue_position: 0,
progress: None,
error: Some(self.inner.kind.shutdown_error()),
order,
report: K::Report::default(),
enqueued_at_unix_ms: now,
started_at_unix_ms: 0,
finished_at_unix_ms: now,
};
return refused;
}
if let Some(existing) = state.jobs.get(&key) {
if existing.state.is_live() {
let job = existing.clone();
return with_position(&state, job);
}
}
let job_id = match preminted {
Some(id) => id,
None => self.mint(),
};
let job: Job<K> = Job {
job_id,
key: key.clone(),
state: JobState::Queued,
queue_position: 0,
progress: None,
error: None,
order,
report: K::Report::default(),
enqueued_at_unix_ms: now_ms(),
started_at_unix_ms: 0,
finished_at_unix_ms: 0,
};
state.jobs.insert(key.clone(), job.clone());
state.waiting.push_back(key.clone());
if !state.started {
state.started = true;
let inner = Arc::clone(&self.inner);
let spawned = std::thread::Builder::new()
.name(format!("gunnar-{}", K::NAME))
.spawn(move || work(inner));
if let Err(err) = spawned {
state.started = false;
state.waiting.pop_back();
let message = self.inner.kind.spawn_error(&err);
let failed = state.jobs.get_mut(&key).expect("just inserted");
failed.state = JobState::Failed;
failed.error = Some(message);
failed.finished_at_unix_ms = now_ms();
return failed.clone();
}
}
self.inner.wake.notify_all();
with_position(&state, job)
}
}
fn with_position<K: JobKind>(state: &State<K>, mut job: Job<K>) -> Job<K> {
job.queue_position = if job.state == JobState::Queued {
state
.waiting
.iter()
.position(|k| *k == job.key)
.map(|n| n as u32)
.unwrap_or(0)
} else {
0
};
job
}
fn work<K: JobKind>(inner: Arc<Inner<K>>) {
loop {
let key = {
let mut state = inner.state.lock().unwrap_or_else(|p| p.into_inner());
loop {
if state.stopping {
state.started = false;
return;
}
if let Some(key) = state.waiting.pop_front() {
if let Some(job) = state.jobs.get_mut(&key) {
job.state = JobState::Running;
job.started_at_unix_ms = now_ms();
}
break key;
}
state = inner.wake.wait(state).unwrap_or_else(|p| p.into_inner());
}
};
let order = {
let state = inner.state.lock().unwrap_or_else(|p| p.into_inner());
match state.jobs.get(&key) {
Some(job) => job.order.clone(),
None => continue,
}
};
let progress = Reporter {
inner: Arc::clone(&inner),
key: key.clone(),
};
let live = inner.in_flight.fetch_add(1, Ordering::SeqCst) + 1;
inner.peak_in_flight.fetch_max(live, Ordering::SeqCst);
inner.runs.fetch_add(1, Ordering::SeqCst);
let outcome =
match std::panic::catch_unwind(AssertUnwindSafe(|| inner.kind.run(&order, &progress))) {
Ok(result) => result,
Err(panic) => Err(format!(
"the {} panicked: {}",
K::NAME,
panic_text(&panic)
)),
};
inner.in_flight.fetch_sub(1, Ordering::SeqCst);
let (state_out, error) = inner.kind.settle(&order, outcome, &progress);
let mut state = inner.state.lock().unwrap_or_else(|p| p.into_inner());
if let Some(job) = state.jobs.get_mut(&key) {
job.state = state_out;
job.error = error;
job.finished_at_unix_ms = now_ms();
}
prune::<K>(&mut state);
}
}
fn prune<K: JobKind>(state: &mut State<K>) {
let now = now_ms();
let cutoff = now - K::FINISHED_RETENTION.as_millis() as i64;
let mut finished: Vec<(String, i64)> = state
.jobs
.iter()
.filter(|(_, job)| !job.state.is_live())
.map(|(key, job)| (key.clone(), job.finished_at_unix_ms))
.collect();
finished.sort_by_key(|job| std::cmp::Reverse(job.1));
for (index, (key, finished_at)) in finished.into_iter().enumerate() {
if index >= K::FINISHED_KEPT || finished_at < cutoff {
state.jobs.remove(&key);
}
}
}
pub(crate) fn panic_text(panic: &Box<dyn std::any::Any + Send>) -> String {
if let Some(text) = panic.downcast_ref::<&'static str>() {
return (*text).to_owned();
}
if let Some(text) = panic.downcast_ref::<String>() {
return text.clone();
}
"a panic with no message".to_owned()
}
#[cfg(test)]
mod tests {
use super::*;
struct Nothing;
impl JobKind for Nothing {
const NAME: &'static str = "nothing";
type Order = String;
type Report = ();
fn key(order: &String) -> &str {
order
}
fn run(&self, _order: &String, _progress: &Reporter<Self>) -> Result<(), String> {
Ok(())
}
}
fn finished(key: &str, age: i64) -> Job<Nothing> {
Job {
job_id: format!("nothing-{key}"),
key: key.to_owned(),
state: JobState::Done,
queue_position: 0,
progress: None,
error: None,
order: key.to_owned(),
report: (),
enqueued_at_unix_ms: 0,
started_at_unix_ms: 0,
finished_at_unix_ms: now_ms() + age,
}
}
#[test]
fn prune_keeps_the_newest_finished_jobs() {
let mut state = State::<Nothing>::default();
for n in 0..(Nothing::FINISHED_KEPT + 5) {
let key = format!("ns/repo{n}");
state.jobs.insert(key.clone(), finished(&key, n as i64));
}
prune::<Nothing>(&mut state);
assert_eq!(state.jobs.len(), Nothing::FINISHED_KEPT);
assert!(
state
.jobs
.contains_key(&format!("ns/repo{}", Nothing::FINISHED_KEPT + 4)),
"the newest finished job must survive"
);
assert!(
!state.jobs.contains_key("ns/repo0"),
"the oldest finished job must be dropped"
);
}
#[test]
fn prune_never_drops_a_live_job() {
let mut state = State::<Nothing>::default();
let mut running = finished("ns/running", 0);
running.state = JobState::Running;
running.finished_at_unix_ms = 0;
state.jobs.insert("ns/running".into(), running);
for n in 0..(Nothing::FINISHED_KEPT + 5) {
let key = format!("ns/repo{n}");
state.jobs.insert(key.clone(), finished(&key, n as i64));
}
prune::<Nothing>(&mut state);
assert!(
state.jobs.contains_key("ns/running"),
"a running job must survive a prune; forgetting it would leave a \
console button lit for ever"
);
}
#[test]
fn a_panics_message_survives_being_caught() {
let owned: Box<dyn std::any::Any + Send> = Box::new("a static message".to_string());
assert_eq!(panic_text(&owned), "a static message");
let borrowed: Box<dyn std::any::Any + Send> = Box::new("a literal message");
assert_eq!(panic_text(&borrowed), "a literal message");
let odd: Box<dyn std::any::Any + Send> = Box::new(7u32);
assert_eq!(panic_text(&odd), "a panic with no message");
}
}