use crate::error::Error;
use async_trait::async_trait;
use chrono::{DateTime, Utc};
use std::collections::BTreeMap;
use std::time::Duration;
use ulid::Ulid;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum Status {
Pending,
Claimed,
Completed,
Dead,
}
impl Status {
pub fn as_str(self) -> &'static str {
match self {
Status::Pending => "pending",
Status::Claimed => "claimed",
Status::Completed => "completed",
Status::Dead => "dead",
}
}
pub fn from_db(value: &str) -> Option<Status> {
match value {
"pending" => Some(Status::Pending),
"claimed" => Some(Status::Claimed),
"completed" => Some(Status::Completed),
"dead" => Some(Status::Dead),
_ => None,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum JournalOutcome {
Completed,
Paused,
Retried,
Dead,
StaleRecovered,
Released,
Merged,
}
impl JournalOutcome {
pub fn as_str(self) -> &'static str {
match self {
JournalOutcome::Completed => "completed",
JournalOutcome::Paused => "paused",
JournalOutcome::Retried => "retried",
JournalOutcome::Dead => "dead",
JournalOutcome::StaleRecovered => "stale-recovered",
JournalOutcome::Released => "released",
JournalOutcome::Merged => "merged",
}
}
pub fn from_db(value: &str) -> Option<JournalOutcome> {
match value {
"completed" => Some(JournalOutcome::Completed),
"paused" => Some(JournalOutcome::Paused),
"retried" => Some(JournalOutcome::Retried),
"dead" => Some(JournalOutcome::Dead),
"stale-recovered" => Some(JournalOutcome::StaleRecovered),
"released" => Some(JournalOutcome::Released),
"merged" => Some(JournalOutcome::Merged),
_ => None,
}
}
pub fn is_failure(self) -> bool {
matches!(
self,
JournalOutcome::Retried | JournalOutcome::Dead | JournalOutcome::StaleRecovered
)
}
}
#[derive(Debug, Clone)]
pub struct JobRecord {
pub id: Ulid,
pub kind: String,
pub payload: serde_json::Value,
pub priority: i16,
pub status: Status,
pub created_at: DateTime<Utc>,
pub visible_at: DateTime<Utc>,
pub claim_expires_at: Option<DateTime<Utc>>,
pub claimed_by: Option<String>,
pub finished_at: Option<DateTime<Utc>>,
pub run_count: u32,
pub failure_count: i32,
pub carry: serde_json::Value,
pub dedup_key: Option<String>,
}
#[derive(Debug, Clone)]
pub struct JournalRecord {
pub id: i64,
pub job_id: Ulid,
pub kind: String,
pub run_no: u32,
pub recorded_at: DateTime<Utc>,
pub outcome: JournalOutcome,
pub note: Option<String>,
pub attachment: Option<serde_json::Value>,
}
#[derive(Debug, Clone)]
pub struct NewJob {
pub id: Ulid,
pub kind: String,
pub payload: serde_json::Value,
pub priority: i16,
pub created_at: DateTime<Utc>,
pub visible_at: DateTime<Utc>,
pub carry: serde_json::Value,
pub dedup_key: Option<String>,
}
impl NewJob {
pub(crate) fn validate(&self) -> Result<(), Error> {
if crate::task::Priority::from_smallint(self.priority).is_none() {
return Err(Error::InvalidPriority {
priority: self.priority,
});
}
Ok(())
}
}
#[derive(Debug, Clone)]
pub struct JournalAppend {
pub kind: String,
pub run_no: u32,
pub recorded_at: DateTime<Utc>,
pub outcome: JournalOutcome,
pub note: Option<String>,
pub attachment: Option<serde_json::Value>,
}
#[derive(Debug, Clone)]
pub enum Settlement {
Complete {
finished_at: DateTime<Utc>,
},
Retry {
visible_at: DateTime<Utc>,
failure_count: i32,
carry: serde_json::Value,
},
Pause {
visible_at: DateTime<Utc>,
carry: serde_json::Value,
},
Dead {
finished_at: DateTime<Utc>,
failure_count: i32,
},
Release {
visible_at: DateTime<Utc>,
},
}
#[async_trait]
pub trait Store: Send + Sync {
async fn migrate(&self) -> Result<(), Error>;
async fn enqueue(&self, job: &NewJob) -> Result<(), Error>;
async fn claim_next(
&self,
kinds: &[String],
priority_floor: i16,
lease: Duration,
claimed_by: &str,
) -> Result<Option<JobRecord>, Error>;
async fn settle(
&self,
id: Ulid,
claimed_by: &str,
run_no: u32,
settlement: Settlement,
journal: JournalAppend,
) -> Result<bool, Error>;
async fn journal(&self, id: Ulid) -> Result<Vec<JournalRecord>, Error>;
async fn next_visible_at(&self, kinds: &[String]) -> Result<Option<DateTime<Utc>>, Error>;
async fn notifier(&self) -> Result<Box<dyn Notifier>, Error> {
Ok(Box::new(NeverNotifier))
}
async fn dedup_candidate(
&self,
kind: &str,
dedup_key: &str,
) -> Result<Option<JobRecord>, Error>;
async fn find_stale(&self) -> Result<Vec<JobRecord>, Error>;
async fn recover(
&self,
id: Ulid,
visible_at: DateTime<Utc>,
failure_count: i32,
run_no: u32,
journal: JournalAppend,
) -> Result<bool, Error>;
async fn extend_lease(
&self,
id: Ulid,
claimed_by: &str,
run_no: u32,
lease: Duration,
) -> Result<bool, Error>;
async fn query_jobs(&self, filter: &HistoryFilter) -> Result<Vec<JobRecord>, Error>;
async fn job(&self, id: Ulid) -> Result<Option<JobRecord>, Error>;
async fn cleanup(&self, criteria: &CleanupCriteria) -> Result<u64, Error>;
async fn stats(&self) -> Result<Snapshot, Error>;
async fn merge_into(
&self,
id: Ulid,
update: Option<MergePayload>,
journal: JournalAppend,
) -> Result<bool, Error>;
}
#[derive(Debug, Clone)]
pub struct MergePayload {
pub payload: serde_json::Value,
pub carry: serde_json::Value,
pub priority: i16,
}
#[derive(Debug, Clone, Default)]
pub struct HistoryFilter {
pub kind: Option<String>,
pub status: Option<Status>,
pub finished_since: Option<DateTime<Utc>>,
pub finished_until: Option<DateTime<Utc>>,
pub created_before: Option<(DateTime<Utc>, Ulid)>,
pub limit: Option<i64>,
}
#[derive(Debug, Clone)]
pub struct CleanupCriteria {
pub finished_before: DateTime<Utc>,
pub kind: Option<String>,
pub status: Option<Status>,
}
#[derive(Debug, Clone, Default)]
pub struct Snapshot {
pub pending_by_kind: BTreeMap<String, u64>,
pub oldest_pending_age: BTreeMap<String, Duration>,
pub claimed: u64,
pub dead_by_kind: BTreeMap<String, u64>,
}
#[async_trait]
pub trait Notifier: Send {
async fn recv(&mut self);
}
pub(crate) struct NeverNotifier;
#[async_trait]
impl Notifier for NeverNotifier {
async fn recv(&mut self) {
std::future::pending::<()>().await
}
}
#[cfg(test)]
mod tests {
use super::*;
fn job_with_priority(priority: i16) -> NewJob {
let now = Utc::now();
NewJob {
id: Ulid::new(),
kind: "demo".to_owned(),
payload: serde_json::Value::Null,
priority,
created_at: now,
visible_at: now,
carry: serde_json::Value::Null,
dedup_key: None,
}
}
#[test]
fn validate_accepts_every_supported_tier() {
for tier in 0..=2 {
assert!(job_with_priority(tier).validate().is_ok());
}
}
#[test]
fn validate_rejects_out_of_range_priority() {
for tier in [-1, 3, i16::MAX] {
match job_with_priority(tier).validate() {
Err(Error::InvalidPriority { priority }) => assert_eq!(priority, tier),
other => panic!("expected InvalidPriority for {tier}, got {other:?}"),
}
}
}
}