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 })
}
}