pub mod cron;
mod cx;
mod worker;
use std::future::IntoFuture;
use std::time::Duration;
pub use cx::JobCx;
use leptoskit_db::Actor;
use leptoskit_db::Connection;
use leptoskit_db::Context;
use leptoskit_db::Db;
use leptoskit_db::Enum;
use leptoskit_db::Expr;
use leptoskit_db::Id;
use leptoskit_db::Json;
use leptoskit_db::Timestamp;
use leptoskit_db::Tx;
use leptoskit_db::policy;
use leptoskit_db::table;
use leptoskit_query::BoxFuture;
use serde_json::Value;
pub(crate) use worker::Clock;
pub(crate) use worker::Faults;
pub use worker::Jobs;
pub use worker::JobsHandle;
pub(crate) use worker::Settings;
pub(crate) use worker::enqueue_cron;
pub use worker::prune;
pub(crate) use worker::prune_at as worker_prune_at;
pub(crate) use worker::run_one;
pub(crate) use worker::sweep_failures;
use super::Identity;
use super::MutationCx;
use super::QueryCx;
use super::handle;
use crate::Error;
use crate::Failed;
pub use crate::Job;
use crate::JobOptions;
use crate::Operation;
use crate::Result;
use crate::Visibility;
#[derive(Clone, Copy, Debug, PartialEq, Eq, Enum)]
pub enum JobState {
Pending,
Running,
Done,
Failed,
Cancelled,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Enum)]
pub enum JobVisibility {
Owner,
SignedIn,
Everyone,
}
#[table(
name = "jobs",
pack = "rpc",
live,
index(state, run_at),
index(locked_until),
index(queue, state),
index(serial_key, state),
index(finished_at)
)]
#[policy(insert, |_, _| Expr::always())]
#[policy(select, |job, cx| job.visibility.eq(JobVisibility::Everyone) | job.owner
.eq(cx.setting::<Actor>()) | job.viewer.eq(cx.setting::<Actor>()) | (job
.visibility
.eq(JobVisibility::SignedIn) & cx.setting::<Actor>().is_present()))]
#[policy(update, |job, cx| job.owner.eq(cx.setting::<Actor>()))]
pub struct JobRow {
pub id: Id<Self>,
#[db(unique, max_len = 255)]
pub key: Option<String>,
#[db(max_len = 255)]
pub name: String,
#[db(max_len = 255)]
pub queue: String,
pub arguments: Json<Value>,
#[db(max_len = 64)]
pub owner: String,
#[db(max_len = 64)]
pub viewer: String,
pub visibility: JobVisibility,
#[db(max_len = 512)]
pub serial_key: Option<String>,
pub state: JobState,
pub attempts: u32,
pub max_attempts: u32,
pub priority: i32,
pub run_at: Timestamp,
pub deadline_at: Option<Timestamp>,
pub started_at: Option<Timestamp>,
pub finished_at: Option<Timestamp>,
pub locked_until: Option<Timestamp>,
pub result: Option<Json<Value>>,
#[db(max_len = 2000)]
pub error: Option<String>,
#[db(max_len = 500)]
pub progress: Option<String>,
pub fraction: Option<f64>,
pub steps: Json<Value>,
pub cancel_requested: bool,
pub failure_pending: bool,
#[db(created_at)]
pub created_at: Timestamp,
}
pub struct JobRun {
pub cx: JobCx,
pub arguments: Value,
}
pub type JobHandler = fn(JobRun) -> BoxFuture<'static, Result<Value>>;
pub struct FailureRun {
pub db: Db,
pub identity: Identity,
pub id: String,
pub arguments: Value,
pub error: String,
pub attempts: u32,
}
pub type FailureHandler = fn(FailureRun) -> BoxFuture<'static, Result<()>>;
pub struct JobRegistration {
pub name: &'static str,
pub options: JobOptions,
#[doc(hidden)]
pub handler: JobHandler,
#[doc(hidden)]
pub on_failure: Option<FailureHandler>,
}
inventory::collect!(JobRegistration);
#[must_use]
pub fn job_registration(name: &str) -> Option<&'static JobRegistration> {
inventory::iter::<JobRegistration>().find(|registration| registration.name == name)
}
#[diagnostic::on_unimplemented(
message = "`{Self}` can't be the `on_failure` of `{J}`",
label = "not a mutation taking `rpc::Failed<{J}>`",
note = "an `on_failure` mutation takes one argument, `job: rpc::Failed<{J}>`, and is declared \
with #[rpc::mutation]"
)]
pub trait OnFailure<J>: Operation<Context = MutationCx> + From<Failed<J>> {}
impl<J, M: Operation<Context = MutationCx> + From<Failed<J>>> OnFailure<J> for M {}
#[doc(hidden)]
pub async fn run_on_failure<M: OnFailure<J>, J>(
db: Db,
identity: Identity,
failed: Failed<J>,
) -> Result<()> {
super::run::mutate(&db, identity, Some("on_failure"), M::from(failed))
.await
.map(|_| ())
}
#[doc(hidden)]
pub const fn assert_on_failure<M: OnFailure<J>, J>() {}
#[must_use]
pub fn enqueue<J: Job>(db: &Db, job: J) -> Enqueue<'static, J> {
Enqueue::new(Place::Db(db.clone()), job, db.actor())
}
enum Place<'a> {
Db(Db),
Tx(&'a Tx),
}
pub struct Enqueue<'a, J: Job> {
place: Place<'a>,
job: J,
owner: Option<Actor>,
settings: EnqueueSettings,
}
#[derive(Default)]
struct EnqueueSettings {
after: Option<Duration>,
at: Option<Timestamp>,
key: Option<String>,
priority: i32,
max_attempts: Option<u32>,
queue: Option<String>,
}
impl<'a, J: Job> Enqueue<'a, J> {
fn new(place: Place<'a>, job: J, owner: Option<Actor>) -> Self {
Self {
place,
job,
owner,
settings: EnqueueSettings::default(),
}
}
pub(crate) fn in_tx(tx: &'a Tx, job: J, owner: Option<Actor>) -> Self {
Self::new(Place::Tx(tx), job, owner)
}
pub(crate) fn on(db: Db, job: J, owner: Option<Actor>) -> Self {
Self::new(Place::Db(db), job, owner)
}
#[must_use]
pub fn after(mut self, after: Duration) -> Self {
self.settings.after = Some(after);
self
}
#[must_use]
pub fn at(mut self, at: Timestamp) -> Self {
self.settings.at = Some(at);
self
}
#[must_use]
#[expect(
clippy::needless_pass_by_value,
reason = "a key is written `.key(payment.id)`: any value that displays"
)]
pub fn key(mut self, key: impl ToString) -> Self {
self.settings.key = Some(key.to_string());
self
}
#[must_use]
pub fn priority(mut self, priority: i32) -> Self {
self.settings.priority = priority;
self
}
#[must_use]
pub fn max_attempts(mut self, attempts: u32) -> Self {
self.settings.max_attempts = Some(attempts.max(1));
self
}
#[must_use]
pub fn queue(mut self, queue: impl Into<String>) -> Self {
self.settings.queue = Some(queue.into());
self
}
async fn insert(self) -> Result<Id<JobRow>> {
match &self.place {
Place::Db(db) => insert(db, &self.job, self.owner, self.settings, false).await,
Place::Tx(tx) => insert(*tx, &self.job, self.owner, self.settings, true).await,
}
}
}
impl<'a, J: Job> IntoFuture for Enqueue<'a, J> {
type IntoFuture = BoxFuture<'a, Result<Id<JobRow>>>;
type Output = Result<Id<JobRow>>;
fn into_future(self) -> Self::IntoFuture {
Box::pin(self.insert())
}
}
fn argument_text(arguments: &Value, field: &str) -> Option<String> {
match arguments.get(field)? {
Value::Null => None,
Value::String(text) => Some(text.clone()),
other => Some(other.to_string()),
}
}
fn owner_text(actor: Option<Actor>) -> String {
actor
.map(|actor| actor.uuid().to_string())
.unwrap_or_default()
}
pub(crate) fn owner_actor(owner: &str) -> Option<Actor> {
owner.parse::<Id<()>>().ok().map(Actor::of)
}
pub(crate) fn owner_context(owner: Option<Actor>) -> Context {
owner.map_or_else(Context::new, |actor| Context::new().with(actor))
}
async fn insert<J: Job>(
connection: &impl Connection,
job: &J,
owner: Option<Actor>,
settings: EnqueueSettings,
in_transaction: bool,
) -> Result<Id<JobRow>> {
if let Some(key) = &settings.key
&& let Some(existing) = JobRow::query()
.filter(|row| row.key.eq(key.clone()))
.first(connection)
.await?
{
return Ok(existing.id);
}
let options = J::OPTIONS;
let now = leptoskit_db::now();
let run_at = settings
.at
.unwrap_or_else(|| now + duration(settings.after.unwrap_or_default()));
let arguments = serde_json::to_value(job)
.map_err(|error| Error::invalid(format!("a job's arguments aren't JSON: {error}")))?;
let serial_key = options.serialize_by.map(|field| {
format!(
"{}:{}",
J::NAME,
argument_text(&arguments, field).unwrap_or_default()
)
});
let (visibility, viewer) = match options.visible_to {
Visibility::Owner => (JobVisibility::Owner, String::new()),
Visibility::SignedIn => (JobVisibility::SignedIn, String::new()),
Visibility::Everyone => (JobVisibility::Everyone, String::new()),
Visibility::Argument(field) => {
(
JobVisibility::Owner,
argument_text(&arguments, field).unwrap_or_default(),
)
}
};
let row = NewJobRow {
key: settings.key.clone(),
name: J::NAME.to_owned(),
queue: settings.queue.unwrap_or_else(|| options.queue.to_owned()),
arguments: Json(arguments),
owner: owner_text(owner),
viewer,
visibility,
serial_key,
state: JobState::Pending,
attempts: 0,
max_attempts: settings
.max_attempts
.unwrap_or_else(|| options.retries.saturating_add(1)),
priority: settings.priority,
run_at,
deadline_at: options.deadline.map(|deadline| now + duration(deadline)),
started_at: None,
finished_at: None,
locked_until: None,
result: None,
error: None,
progress: None,
fraction: None,
steps: Json(Value::Object(serde_json::Map::new())),
cancel_requested: false,
failure_pending: false,
}
.insert(connection)
.await;
match row {
Ok(row) => Ok(row.id),
Err(leptoskit_db::DbError::Conflict { .. }) if settings.key.is_some() => {
if in_transaction {
return Err(Error::Contended);
}
let key = settings.key.unwrap_or_default();
match JobRow::query()
.filter(|row| row.key.eq(key))
.one(connection)
.await
{
Ok(row) => Ok(row.id),
Err(_) => {
Err(Error::conflict(
"that key is already in use by a job you can't see",
))
}
}
}
Err(error) => Err(error.into()),
}
}
pub(crate) fn duration(duration: Duration) -> chrono::Duration {
chrono::Duration::from_std(duration).unwrap_or(chrono::Duration::MAX)
}
pub(crate) fn parse_id(id: &str) -> Result<Id<JobRow>> {
id.parse()
.map_err(|_| Error::invalid("that isn't a job id"))
}
pub async fn status(cx: &QueryCx, id: String) -> Result<crate::jobs::JobStatus> {
let id = parse_id(&id)?;
let row = JobRow::query()
.filter(|job| job.id.eq(id))
.first(cx.db())
.await?
.ok_or_else(|| Error::not_found("no such job"))?;
Ok(status_of(row))
}
pub(crate) fn status_of(row: JobRow) -> crate::jobs::JobStatus {
crate::jobs::JobStatus {
state: row.state.as_str().to_owned(),
queue: row.queue,
attempts: row.attempts,
max_attempts: row.max_attempts,
error: row.error,
result: row.result.map(|result| result.0),
progress: row.progress,
fraction: row.fraction,
cancel_requested: row.cancel_requested,
run_at: row.run_at.to_rfc3339(),
finished_at: row.finished_at.map(|at| at.to_rfc3339()),
}
}
pub async fn cancel_on(connection: &impl Connection, id: Id<JobRow>) -> Result<()> {
let row = JobRow::query()
.filter(|job| job.id.eq(id))
.first(connection)
.await?
.ok_or_else(|| Error::not_found("no such job"))?;
let changed = match row.state {
JobState::Pending => {
JobRow::update()
.filter(|job| job.id.eq(id) & job.state.eq(JobState::Pending))
.set(|job| job.state, JobState::Cancelled)
.set(|job| job.cancel_requested, true)
.set(|job| job.finished_at, leptoskit_db::now())
.run(connection)
.await?
}
JobState::Running => {
JobRow::update()
.filter(|job| job.id.eq(id))
.set(|job| job.cancel_requested, true)
.run(connection)
.await?
}
JobState::Done | JobState::Failed | JobState::Cancelled => return Ok(()),
};
if changed == 1 {
return Ok(());
}
let now = JobRow::query()
.filter(|job| job.id.eq(id))
.first(connection)
.await?
.ok_or_else(|| Error::not_found("no such job"))?;
if now.state == row.state {
return Err(Error::forbidden("only whoever started a job may cancel it"));
}
Box::pin(cancel_on(connection, id)).await
}
pub async fn cancel(db: &Db, id: Id<JobRow>) -> Result<()> {
cancel_on(db, id).await
}
pub async fn retry(db: &Db, id: Id<JobRow>) -> Result<()> {
let changed = JobRow::update()
.filter(|job| {
job.id.eq(id) & (job.state.eq(JobState::Failed) | job.state.eq(JobState::Cancelled))
})
.set(|job| job.state, JobState::Pending)
.set(|job| job.attempts, 0_u32)
.set(|job| job.run_at, leptoskit_db::now())
.set(|job| job.deadline_at, None::<Timestamp>)
.set(|job| job.finished_at, None::<Timestamp>)
.set(|job| job.locked_until, None::<Timestamp>)
.set(|job| job.cancel_requested, false)
.set(|job| job.failure_pending, false)
.run(db)
.await?;
if changed == 1 {
return Ok(());
}
match JobRow::query()
.filter(|job| job.id.eq(id))
.first(db)
.await?
{
None => Err(Error::not_found("no such job")),
Some(row) => {
Err(Error::conflict(format!(
"only failed or cancelled jobs can be retried; this one is {}",
row.state.as_str()
)))
}
}
}
#[allow(deprecated)]
const _: () = {
inventory::submit! {
super::Registration {
name: <crate::jobs::JobStatusArgs as Operation>::NAME,
kind: <crate::jobs::JobStatusArgs as Operation>::KIND,
module_path: module_path!(),
handler: handle::<crate::jobs::JobStatusArgs>,
}
}
inventory::submit! {
super::Registration {
name: <crate::jobs::CancelJob as Operation>::NAME,
kind: <crate::jobs::CancelJob as Operation>::KIND,
module_path: module_path!(),
handler: handle::<crate::jobs::CancelJob>,
}
}
};
#[doc(hidden)]
pub fn run_job<J: Job>(run: JobRun) -> BoxFuture<'static, Result<Value>> {
Box::pin(async move {
let job: J = serde_json::from_value(run.arguments).map_err(|error| {
Error::permanent(format!("the arguments of `{}` don't fit: {error}", J::NAME))
})?;
let output = job.run(&run.cx).await?;
serde_json::to_value(output).map_err(|_| Error::permanent("a job's result isn't JSON"))
})
}
#[doc(hidden)]
pub fn fail_job<J: Job, M: OnFailure<J>>(run: FailureRun) -> BoxFuture<'static, Result<()>> {
Box::pin(async move {
let job: J = serde_json::from_value(run.arguments).map_err(|error| {
Error::permanent(format!("the arguments of `{}` don't fit: {error}", J::NAME))
})?;
let failed = Failed {
id: run.id,
job,
error: run.error,
attempts: run.attempts,
};
run_on_failure::<M, J>(run.db, run.identity, failed).await
})
}