use crate::action::{Error, Result};
use crate::ctx::BoxFuture;
use std::collections::HashMap;
use std::sync::{Arc, Mutex};
pub const MAX_JOB_PAYLOAD: usize = 4000;
pub const MEMORY_JOB_CAPACITY: usize = 10_000;
pub const MAX_BACKOFF_MS: i64 = 600_000;
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct NewJob {
pub name: String,
pub payload: String,
pub run_at_ms: i64,
pub max_attempts: u32,
pub backoff_ms: i64,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct ClaimedJob {
pub id: String,
pub name: String,
pub payload: String,
pub attempts: u32,
pub max_attempts: u32,
pub backoff_ms: i64,
pub every_ms: Option<i64>,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum NextRun {
Done,
Queue { run_at_ms: i64, attempts: u32 },
Dead,
}
impl ClaimedJob {
pub fn after_success(&self, now_ms: i64) -> NextRun {
match self.every_ms {
Some(every) => NextRun::Queue { run_at_ms: now_ms.saturating_add(every), attempts: 0 },
None => NextRun::Done,
}
}
pub fn after_failure(&self, now_ms: i64) -> NextRun {
if self.attempts < self.max_attempts {
let shift = self.attempts.saturating_sub(1).min(20);
let wait = self.backoff_ms.max(0).saturating_mul(1 << shift).min(MAX_BACKOFF_MS);
return NextRun::Queue { run_at_ms: now_ms.saturating_add(wait), attempts: self.attempts };
}
match self.every_ms {
Some(_) => self.after_success(now_ms),
None => NextRun::Dead,
}
}
}
pub fn schedule_id(name: &str) -> String {
format!("every:{name}")
}
pub trait JobStore: Send + Sync + 'static {
fn enqueue(&self, job: NewJob) -> BoxFuture<Result<String>>;
fn schedule(
&self,
name: &str,
every_ms: i64,
first_run_ms: i64,
max_attempts: u32,
backoff_ms: i64,
) -> BoxFuture<Result<()>>;
fn claim(
&self,
worker: &str,
names: &[&'static str],
now_ms: i64,
lease_ms: i64,
limit: usize,
) -> BoxFuture<Result<Vec<ClaimedJob>>>;
fn heartbeat(&self, id: &str, worker: &str, lease_until_ms: i64) -> BoxFuture<Result<bool>>;
fn complete(&self, job: &ClaimedJob, worker: &str, now_ms: i64) -> BoxFuture<Result<bool>>;
fn fail(&self, job: &ClaimedJob, worker: &str, error: &'static str, now_ms: i64) -> BoxFuture<Result<bool>>;
}
pub(crate) fn check_new(job: &NewJob) -> Result<()> {
if job.payload.len() > MAX_JOB_PAYLOAD {
return Err(Error::Internal(format!("a job payload is at most {MAX_JOB_PAYLOAD} bytes")));
}
if job.name.is_empty() || job.name.len() > 64 {
return Err(Error::Internal("a job name is 1 to 64 bytes".into()));
}
Ok(())
}
#[derive(Clone, Debug, PartialEq, Eq)]
enum State {
Queued,
Running,
Failed,
}
#[derive(Clone, Debug)]
struct Row {
name: String,
payload: String,
state: State,
run_at: i64,
attempts: u32,
max_attempts: u32,
backoff_ms: i64,
every_ms: Option<i64>,
owner: Option<String>,
lease_until: i64,
last_error: Option<&'static str>,
}
struct Mem {
rows: HashMap<String, Row>,
next_id: u64,
}
#[derive(Clone)]
pub struct MemoryJobStore {
inner: Arc<Mutex<Mem>>,
capacity: usize,
}
impl Default for MemoryJobStore {
fn default() -> Self {
MemoryJobStore::with_capacity(MEMORY_JOB_CAPACITY)
}
}
impl MemoryJobStore {
pub fn with_capacity(capacity: usize) -> Self {
MemoryJobStore {
inner: Arc::new(Mutex::new(Mem { rows: HashMap::new(), next_id: 0 })),
capacity: capacity.max(1),
}
}
pub fn len(&self) -> usize {
self.inner.lock().map(|m| m.rows.len()).unwrap_or(0)
}
pub fn is_empty(&self) -> bool {
self.len() == 0
}
pub fn failed(&self, id: &str) -> Option<&'static str> {
let m = self.inner.lock().ok()?;
m.rows.get(id).filter(|r| r.state == State::Failed).map(|r| r.last_error.unwrap_or("internal"))
}
fn make_room(&self, m: &mut Mem) -> Result<()> {
if m.rows.len() < self.capacity {
return Ok(());
}
let oldest = m.rows.iter().filter(|(_, r)| r.state == State::Failed).min_by_key(|(_, r)| r.run_at);
match oldest.map(|(id, _)| id.clone()) {
Some(id) => {
m.rows.remove(&id);
Ok(())
}
None => Err(Error::Internal(format!("the job queue is full ({} runs)", self.capacity))),
}
}
fn finish(&self, job: &ClaimedJob, worker: &str, next: NextRun, error: Option<&'static str>) -> Result<bool> {
let mut m = self.inner.lock().map_err(|_| Error::Internal("job store lock".into()))?;
let held = m.rows.get(&job.id).is_some_and(|r| r.state == State::Running && r.owner.as_deref() == Some(worker));
if !held {
return Ok(false);
}
match next {
NextRun::Done => {
m.rows.remove(&job.id);
}
NextRun::Queue { run_at_ms, attempts } => {
if let Some(r) = m.rows.get_mut(&job.id) {
r.state = State::Queued;
r.run_at = run_at_ms;
r.attempts = attempts;
r.owner = None;
r.last_error = error;
}
}
NextRun::Dead => {
if let Some(r) = m.rows.get_mut(&job.id) {
r.state = State::Failed;
r.owner = None;
r.last_error = error;
}
}
}
Ok(true)
}
}
impl JobStore for MemoryJobStore {
fn enqueue(&self, job: NewJob) -> BoxFuture<Result<String>> {
let out = (|| {
check_new(&job)?;
let mut m = self.inner.lock().map_err(|_| Error::Internal("job store lock".into()))?;
self.make_room(&mut m)?;
m.next_id += 1;
let id = format!("m{}", m.next_id);
m.rows.insert(
id.clone(),
Row {
name: job.name,
payload: job.payload,
state: State::Queued,
run_at: job.run_at_ms,
attempts: 0,
max_attempts: job.max_attempts.max(1),
backoff_ms: job.backoff_ms,
every_ms: None,
owner: None,
lease_until: 0,
last_error: None,
},
);
Ok(id)
})();
Box::pin(async move { out })
}
fn schedule(
&self,
name: &str,
every_ms: i64,
first_run_ms: i64,
max_attempts: u32,
backoff_ms: i64,
) -> BoxFuture<Result<()>> {
let out = (|| {
let mut m = self.inner.lock().map_err(|_| Error::Internal("job store lock".into()))?;
let id = schedule_id(name);
if let Some(r) = m.rows.get_mut(&id) {
r.every_ms = Some(every_ms);
r.max_attempts = max_attempts.max(1);
r.backoff_ms = backoff_ms;
return Ok(());
}
self.make_room(&mut m)?;
m.rows.insert(
id,
Row {
name: name.to_string(),
payload: String::new(),
state: State::Queued,
run_at: first_run_ms,
attempts: 0,
max_attempts: max_attempts.max(1),
backoff_ms,
every_ms: Some(every_ms),
owner: None,
lease_until: 0,
last_error: None,
},
);
Ok(())
})();
Box::pin(async move { out })
}
fn claim(
&self,
worker: &str,
names: &[&'static str],
now_ms: i64,
lease_ms: i64,
limit: usize,
) -> BoxFuture<Result<Vec<ClaimedJob>>> {
let out = (|| {
let mut m = self.inner.lock().map_err(|_| Error::Internal("job store lock".into()))?;
let mut due: Vec<(i64, String)> = m
.rows
.iter()
.filter(|(_, r)| names.contains(&r.name.as_str()))
.filter(|(_, r)| match r.state {
State::Queued => r.run_at <= now_ms,
State::Running => r.lease_until < now_ms,
State::Failed => false,
})
.map(|(id, r)| (r.run_at, id.clone()))
.collect();
due.sort_unstable();
due.truncate(limit);
let mut claimed = Vec::with_capacity(due.len());
for (_, id) in due {
if let Some(r) = m.rows.get_mut(&id) {
r.state = State::Running;
r.owner = Some(worker.to_string());
r.lease_until = now_ms.saturating_add(lease_ms);
r.attempts = r.attempts.saturating_add(1);
claimed.push(ClaimedJob {
id,
name: r.name.clone(),
payload: r.payload.clone(),
attempts: r.attempts,
max_attempts: r.max_attempts,
backoff_ms: r.backoff_ms,
every_ms: r.every_ms,
});
}
}
Ok(claimed)
})();
Box::pin(async move { out })
}
fn heartbeat(&self, id: &str, worker: &str, lease_until_ms: i64) -> BoxFuture<Result<bool>> {
let out = self.inner.lock().map_err(|_| Error::Internal("job store lock".into())).map(|mut m| {
match m.rows.get_mut(id) {
Some(r) if r.state == State::Running && r.owner.as_deref() == Some(worker) => {
r.lease_until = lease_until_ms;
true
}
_ => false,
}
});
Box::pin(async move { out })
}
fn complete(&self, job: &ClaimedJob, worker: &str, now_ms: i64) -> BoxFuture<Result<bool>> {
let out = self.finish(job, worker, job.after_success(now_ms), None);
Box::pin(async move { out })
}
fn fail(&self, job: &ClaimedJob, worker: &str, error: &'static str, now_ms: i64) -> BoxFuture<Result<bool>> {
let out = self.finish(job, worker, job.after_failure(now_ms), Some(error));
Box::pin(async move { out })
}
}