use super::store::{ClaimedJob, JobStore, NewJob, NextRun, check_new, schedule_id};
use crate::action::{Error, Result};
use crate::ctx::BoxFuture;
use crate::sql::{Arg, Cell, Col, Db, Dialect, Sql, bind, checked_prefix};
use std::sync::Arc;
pub fn create_job_tables(dialect: &str, prefix: &str) -> Result<Vec<String>> {
let p = checked_prefix(prefix)?;
let (text, big) = match dialect {
"postgres" => ("VARCHAR", "BIGINT"),
"oracle" => ("VARCHAR2", "NUMBER(19)"),
other => {
return Err(Error::Internal(format!(
"no job tables for the `{other}` dialect"
)));
}
};
Ok(vec![
format!(
"CREATE TABLE {p}jobs (id {text}(80) PRIMARY KEY, name {text}(64) NOT NULL, payload {text}(4000), state {text}(16) NOT NULL, run_at {big} NOT NULL, attempts {big} NOT NULL, max_attempts {big} NOT NULL, backoff_ms {big} NOT NULL, every_ms {big}, lease_owner {text}(64), lease_until {big}, last_error {text}(64))"
),
format!("CREATE INDEX {p}jobs_due ON {p}jobs (state, run_at)"),
])
}
#[derive(Clone)]
pub struct SqlJobStore {
db: Db,
prefix: Arc<str>,
}
const CLAIMED: &str = "id, name, payload, attempts, max_attempts, backoff_ms, every_ms";
const CLAIMED_COLS: [Col; 7] = [
Col::Text,
Col::Text,
Col::Text,
Col::Int,
Col::Int,
Col::Int,
Col::Int,
];
const DUE: &str = "((state = 'queued' AND run_at <= ?) OR (state = 'running' AND lease_until < ?))";
const HELD: &str = "id = ? AND lease_owner = ? AND state = 'running'";
fn list(n: usize) -> String {
let mut out = String::with_capacity(n * 3);
for i in 0..n {
out.push_str(if i == 0 { "?" } else { ", ?" });
}
out
}
fn claimed_of(row: Vec<Cell>) -> Option<ClaimedJob> {
let mut c = row.into_iter();
let text = |c: Option<Cell>| match c {
Some(Cell::Text(s)) => Some(s),
_ => None,
};
let int = |c: Option<Cell>| match c {
Some(Cell::Int(n)) => Some(n),
_ => None,
};
Some(ClaimedJob {
id: text(c.next())?,
name: text(c.next())?,
payload: text(c.next()).unwrap_or_default(),
attempts: u32::try_from(int(c.next())?).unwrap_or(u32::MAX),
max_attempts: u32::try_from(int(c.next())?).unwrap_or(1),
backoff_ms: int(c.next())?,
every_ms: int(c.next()),
})
}
fn sql(d: &dyn Dialect, p: &str, template: &str, args: Vec<Arg>, cols: &[Col]) -> Sql {
bind(d, p, template, args, cols)
}
pub(crate) fn plan_claim(
d: &dyn Dialect,
p: &str,
worker: &str,
names: &[&'static str],
now_ms: i64,
lease_ms: i64,
limit: usize,
) -> Sql {
let in_names = list(names.len());
let name_args = names
.iter()
.map(|n| Arg::Text((*n).to_string()));
if d.name() == "oracle" {
let mut args: Vec<Arg> = name_args.collect();
args.extend([Arg::Int(now_ms), Arg::Int(now_ms), Arg::Int(limit as i64)]);
return sql(
d,
p,
&format!(
"SELECT id FROM {{p}}jobs WHERE name IN ({in_names}) AND {DUE} AND ROWNUM <= ? FOR UPDATE SKIP LOCKED"
),
args,
&[Col::Text],
);
}
let mut args = vec![
Arg::Text(worker.to_string()),
Arg::Int(now_ms.saturating_add(lease_ms)),
];
args.extend(name_args);
args.extend([Arg::Int(now_ms), Arg::Int(now_ms), Arg::Int(limit as i64)]);
sql(
d,
p,
&format!(
"UPDATE {{p}}jobs SET state = 'running', lease_owner = ?, lease_until = ?, attempts = attempts + 1 WHERE id IN (SELECT id FROM {{p}}jobs WHERE name IN ({in_names}) AND {DUE} ORDER BY run_at LIMIT ? FOR UPDATE SKIP LOCKED) RETURNING {CLAIMED}"
),
args,
&CLAIMED_COLS,
)
}
pub(crate) fn plan_finish(
d: &dyn Dialect,
p: &str,
job: &ClaimedJob,
worker: &str,
next: NextRun,
error: Option<&'static str>,
) -> Sql {
let held = [Arg::Text(job.id.clone()), Arg::Text(worker.to_string())];
let error = error.map_or(Arg::Null(Col::Text), |e| Arg::Text(e.to_string()));
match next {
NextRun::Done => sql(
d,
p,
&format!("DELETE FROM {{p}}jobs WHERE {HELD}"),
held.to_vec(),
&[],
),
NextRun::Queue {
run_at_ms,
attempts,
} => {
let mut args = vec![Arg::Int(run_at_ms), Arg::Int(i64::from(attempts)), error];
args.extend(held);
sql(
d,
p,
&format!(
"UPDATE {{p}}jobs SET state = 'queued', run_at = ?, attempts = ?, lease_owner = NULL, lease_until = NULL, last_error = ? WHERE {HELD}"
),
args,
&[],
)
}
NextRun::Dead => {
let mut args = vec![error];
args.extend(held);
sql(
d,
p,
&format!(
"UPDATE {{p}}jobs SET state = 'failed', lease_owner = NULL, lease_until = NULL, last_error = ? WHERE {HELD}"
),
args,
&[],
)
}
}
}
impl SqlJobStore {
pub fn new(db: Db) -> Self {
SqlJobStore {
db,
prefix: Arc::from("zeyn_"),
}
}
pub fn prefix(mut self, prefix: &str) -> Result<Self> {
self.prefix = Arc::from(checked_prefix(prefix)?);
Ok(self)
}
pub async fn create_tables(&self) -> Result<()> {
for statement in create_job_tables(self.db.dialect().name(), &self.prefix)? {
self.db
.run(Sql {
text: statement,
args: Vec::new(),
cols: Vec::new(),
})
.await?;
}
Ok(())
}
fn sql(&self, template: &str, args: Vec<Arg>, cols: &[Col]) -> Sql {
sql(self.db.dialect(), &self.prefix, template, args, cols)
}
async fn claim_oracle(
&self,
worker: String,
first: Sql,
now_ms: i64,
lease_ms: i64,
) -> Result<Vec<ClaimedJob>> {
let mut tx = self.db.begin().await?;
let ids: Vec<Arg> = tx
.rows(first)
.await?
.into_iter()
.filter_map(|r| match r.into_iter().next() {
Some(Cell::Text(id)) => Some(Arg::Text(id)),
_ => None,
})
.collect();
if ids.is_empty() {
tx.rollback().await?;
return Ok(Vec::new());
}
let in_ids = list(ids.len());
let until = now_ms.saturating_add(lease_ms);
let mut args = vec![Arg::Text(worker.clone()), Arg::Int(until)];
args.extend(ids.iter().cloned());
tx.run(self.sql(
&format!(
"UPDATE {{p}}jobs SET state = 'running', lease_owner = ?, lease_until = ?, attempts = attempts + 1 WHERE id IN ({in_ids})"
),
args,
&[],
))
.await?;
let mut args = ids;
args.extend([Arg::Text(worker), Arg::Int(until)]);
let rows = tx
.rows(self.sql(
&format!(
"SELECT {CLAIMED} FROM {{p}}jobs WHERE id IN ({in_ids}) AND lease_owner = ? AND lease_until = ? ORDER BY run_at"
),
args,
&CLAIMED_COLS,
))
.await?;
tx.commit().await?;
Ok(rows
.into_iter()
.filter_map(claimed_of)
.collect())
}
}
impl JobStore for SqlJobStore {
fn enqueue(&self, job: NewJob) -> BoxFuture<Result<String>> {
let s = self.clone();
Box::pin(async move {
check_new(&job)?;
let id = super::random_id();
s.db.run(s.sql(
"INSERT INTO {p}jobs (id, name, payload, state, run_at, attempts, max_attempts, backoff_ms) VALUES (?, ?, ?, 'queued', ?, 0, ?, ?)",
vec![
Arg::Text(id.clone()),
Arg::Text(job.name),
Arg::Text(job.payload),
Arg::Int(job.run_at_ms),
Arg::Int(i64::from(job.max_attempts.max(1))),
Arg::Int(job.backoff_ms),
],
&[],
))
.await?;
Ok(id)
})
}
fn schedule(
&self,
name: &str,
every_ms: i64,
first_run_ms: i64,
max_attempts: u32,
backoff_ms: i64,
) -> BoxFuture<Result<()>> {
let (s, name) = (self.clone(), name.to_string());
Box::pin(async move {
let id = schedule_id(&name);
let max = i64::from(max_attempts.max(1));
let changed =
s.db.run(s.sql(
"UPDATE {p}jobs SET every_ms = ?, max_attempts = ?, backoff_ms = ? WHERE id = ?",
vec![Arg::Int(every_ms), Arg::Int(max), Arg::Int(backoff_ms), Arg::Text(id.clone())],
&[],
))
.await?;
if changed > 0 {
return Ok(());
}
let inserted = s
.db
.run(s.sql(
"INSERT INTO {p}jobs (id, name, state, run_at, attempts, max_attempts, backoff_ms, every_ms) VALUES (?, ?, 'queued', ?, 0, ?, ?, ?)",
vec![
Arg::Text(id),
Arg::Text(name),
Arg::Int(first_run_ms),
Arg::Int(max),
Arg::Int(backoff_ms),
Arg::Int(every_ms),
],
&[],
))
.await;
match inserted {
Ok(_) | Err(Error::Validation(_)) => Ok(()),
Err(e) => Err(e),
}
})
}
fn claim(
&self,
worker: &str,
names: &[&'static str],
now_ms: i64,
lease_ms: i64,
limit: usize,
) -> BoxFuture<Result<Vec<ClaimedJob>>> {
let s = self.clone();
let worker = worker.to_string();
let plan = (!names.is_empty() && limit > 0).then(|| {
plan_claim(
s.db.dialect(),
&s.prefix,
&worker,
names,
now_ms,
lease_ms,
limit,
)
});
Box::pin(async move {
let Some(plan) = plan else {
return Ok(Vec::new());
};
if s.db.dialect().name() == "oracle" {
return s
.claim_oracle(worker, plan, now_ms, lease_ms)
.await;
}
Ok(s.db
.rows(plan)
.await?
.into_iter()
.filter_map(claimed_of)
.collect())
})
}
fn heartbeat(&self, id: &str, worker: &str, lease_until_ms: i64) -> BoxFuture<Result<bool>> {
let s = self.clone();
let sql = s.sql(
&format!("UPDATE {{p}}jobs SET lease_until = ? WHERE {HELD}"),
vec![
Arg::Int(lease_until_ms),
Arg::Text(id.to_string()),
Arg::Text(worker.to_string()),
],
&[],
);
Box::pin(async move { Ok(s.db.run(sql).await? > 0) })
}
fn complete(&self, job: &ClaimedJob, worker: &str, now_ms: i64) -> BoxFuture<Result<bool>> {
let s = self.clone();
let sql = plan_finish(
s.db.dialect(),
&s.prefix,
job,
worker,
job.after_success(now_ms),
None,
);
Box::pin(async move { Ok(s.db.run(sql).await? > 0) })
}
fn fail(
&self,
job: &ClaimedJob,
worker: &str,
error: &'static str,
now_ms: i64,
) -> BoxFuture<Result<bool>> {
let s = self.clone();
let sql = plan_finish(
s.db.dialect(),
&s.prefix,
job,
worker,
job.after_failure(now_ms),
Some(error),
);
Box::pin(async move { Ok(s.db.run(sql).await? > 0) })
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::sql::{Oracle, OracleCompat, Postgres};
fn job() -> ClaimedJob {
ClaimedJob {
id: "r1".into(),
name: "mail".into(),
payload: String::new(),
attempts: 1,
max_attempts: 3,
backoff_ms: 100,
every_ms: None,
}
}
#[test]
fn postgres_claims_in_one_statement_with_skip_locked_and_binds_every_value() {
let sql = plan_claim(
&Postgres,
"zeyn_",
"w1",
&["mail", "report"],
1_000,
60_000,
4,
);
assert_eq!(
sql.text,
"UPDATE zeyn_jobs SET state = 'running', lease_owner = $1, lease_until = $2, attempts = attempts + 1 WHERE id IN (SELECT id FROM zeyn_jobs WHERE name IN ($3, $4) AND ((state = 'queued' AND run_at <= $5) OR (state = 'running' AND lease_until < $6)) ORDER BY run_at LIMIT $7 FOR UPDATE SKIP LOCKED) RETURNING id, name, payload, attempts, max_attempts, backoff_ms, every_ms"
);
assert_eq!(
sql.args,
vec![
Arg::Text("w1".into()),
Arg::Int(61_000),
Arg::Text("mail".into()),
Arg::Text("report".into()),
Arg::Int(1_000),
Arg::Int(1_000),
Arg::Int(4)
]
);
assert_eq!(sql.cols, CLAIMED_COLS.to_vec());
}
#[test]
fn oracle_11g_selects_ids_with_rownum_and_skip_locked() {
let d = Oracle {
compat: OracleCompat::V11,
};
let sql = plan_claim(&d, "zeyn_", "w1", &["mail"], 1_000, 60_000, 2);
assert_eq!(
sql.text,
"SELECT id FROM zeyn_jobs WHERE name IN (:1) AND ((state = 'queued' AND run_at <= :2) OR (state = 'running' AND lease_until < :3)) AND ROWNUM <= :4 FOR UPDATE SKIP LOCKED"
);
assert!(
!sql.text.contains("FETCH") && !sql.text.contains("ORDER BY"),
"11g syntax, no ORDER BY under FOR UPDATE"
);
assert_eq!(sql.args.len(), 4);
}
#[test]
fn finishing_only_changes_a_run_the_worker_still_holds() {
let j = job();
let done = plan_finish(&Postgres, "p_", &j, "w1", NextRun::Done, None);
assert_eq!(
done.text,
"DELETE FROM p_jobs WHERE id = $1 AND lease_owner = $2 AND state = 'running'"
);
let retry = plan_finish(
&Postgres,
"p_",
&j,
"w1",
j.after_failure(1_000),
Some("internal"),
);
assert!(
retry
.text
.starts_with("UPDATE p_jobs SET state = 'queued', run_at = $1, attempts = $2")
);
assert!(
retry
.text
.ends_with("WHERE id = $4 AND lease_owner = $5 AND state = 'running'")
);
assert_eq!(retry.args[0], Arg::Int(1_100));
assert_eq!(retry.args[2], Arg::Text("internal".into()));
let dead = plan_finish(
&Postgres,
"p_",
&ClaimedJob { attempts: 3, ..j },
"w1",
NextRun::Dead,
Some("internal"),
);
assert!(dead.text.contains("state = 'failed'"));
}
#[test]
fn the_schema_uses_whole_number_times_and_checks_the_prefix() {
for dialect in ["postgres", "oracle"] {
let all = create_job_tables(dialect, "shop_")
.unwrap()
.join("\n");
assert!(all.contains("CREATE TABLE shop_jobs ("));
assert!(all.contains("CREATE INDEX shop_jobs_due ON shop_jobs (state, run_at)"));
assert!(
!all.contains("TIMESTAMP") && !all.contains("IDENTITY"),
"works on Oracle 11g"
);
}
assert!(create_job_tables("oracle", "z_").unwrap()[0].contains("VARCHAR2(4000)"));
assert!(create_job_tables("postgres", "Bad").is_err());
assert!(create_job_tables("sqlite", "z_").is_err());
}
}