use std::collections::HashMap;
use std::sync::Arc;
use std::time::Duration;
use chrono::{DateTime, Utc};
use rainier_container::Container;
use rainier_support::{BoxedFuture, Error, Result};
use serde::de::DeserializeOwned;
use serde::{Deserialize, Serialize};
pub struct JobContext {
container: Arc<Container>,
attempt: u32,
max_attempts: u32,
id: String,
queue: String,
}
impl JobContext {
pub fn new(
container: Arc<Container>,
id: impl Into<String>,
queue: impl Into<String>,
attempt: u32,
max_attempts: u32,
) -> Self {
Self { container, attempt, max_attempts, id: id.into(), queue: queue.into() }
}
pub fn container(&self) -> &Arc<Container> {
&self.container
}
pub fn resolve<T: Send + Sync + 'static>(&self) -> Result<Arc<T>> {
self.container.resolve::<T>()
}
pub fn attempt(&self) -> u32 {
self.attempt
}
pub fn max_attempts(&self) -> u32 {
self.max_attempts
}
pub fn is_last_attempt(&self) -> bool {
self.attempt >= self.max_attempts
}
pub fn id(&self) -> &str {
&self.id
}
pub fn queue(&self) -> &str {
&self.queue
}
}
impl std::fmt::Debug for JobContext {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("JobContext")
.field("id", &self.id)
.field("queue", &self.queue)
.field("attempt", &format_args!("{}/{}", self.attempt, self.max_attempts))
.finish()
}
}
#[async_trait::async_trait]
pub trait Job: Serialize + DeserializeOwned + Send + Sync + 'static {
const NAME: &'static str;
async fn handle(&self, context: &JobContext) -> Result<()>;
const TRIES: u32 = 3;
const QUEUE: &'static str = "default";
fn backoff(attempt: u32) -> Duration {
Duration::from_secs(1u64 << attempt.min(6))
}
async fn failed(&self, context: &JobContext, error: &Error) {
let _ = (context, error);
}
fn unique_id(&self) -> Option<String> {
None
}
const UNIQUE_FOR: Duration = Duration::from_secs(3600);
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct QueuedJob {
pub id: String,
pub name: String,
pub payload: serde_json::Value,
pub queue: String,
pub attempts: u32,
pub max_attempts: u32,
pub available_at: DateTime<Utc>,
pub created_at: DateTime<Utc>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub unique_key: Option<String>,
}
impl QueuedJob {
pub fn from_job<J: Job>(job: &J) -> Result<Self> {
let now = Utc::now();
Ok(Self {
id: generate_id(),
name: J::NAME.to_string(),
payload: serde_json::to_value(job)?,
queue: J::QUEUE.to_string(),
attempts: 0,
max_attempts: J::TRIES,
unique_key: None,
available_at: now,
created_at: now,
})
}
pub fn on_queue(mut self, queue: impl Into<String>) -> Self {
self.queue = queue.into();
self
}
pub fn delayed_by(mut self, delay: Duration) -> Self {
let delay = chrono::Duration::from_std(delay).unwrap_or_else(|_| chrono::Duration::zero());
self.available_at = Utc::now() + delay;
self
}
pub fn available_at(mut self, at: DateTime<Utc>) -> Self {
self.available_at = at;
self
}
pub fn is_available(&self) -> bool {
self.available_at <= Utc::now()
}
pub fn is_exhausted(&self) -> bool {
self.attempts >= self.max_attempts
}
}
fn generate_id() -> String {
use std::sync::atomic::{AtomicU64, Ordering};
static COUNTER: AtomicU64 = AtomicU64::new(0);
let micros = Utc::now().timestamp_micros();
let sequence = COUNTER.fetch_add(1, Ordering::Relaxed);
format!("{micros:x}-{sequence:x}")
}
type Runner =
Arc<dyn Fn(serde_json::Value, Arc<JobContext>) -> BoxedFuture<Result<()>> + Send + Sync>;
#[derive(Default, Clone)]
pub struct JobRegistry {
runners: HashMap<String, Runner>,
}
impl JobRegistry {
pub fn new() -> Self {
Self::default()
}
pub fn register<J: Job>(&mut self) -> &mut Self {
let runner: Runner = Arc::new(|payload, context| {
Box::pin(async move {
let job: J = serde_json::from_value(payload).map_err(|e| {
Error::internal(format!(
"could not deserialise a `{}` job — its payload and its type have \
diverged: {e}",
J::NAME
))
})?;
let outcome = job.handle(&context).await;
if let Err(error) = &outcome {
if context.is_last_attempt() {
job.failed(&context, error).await;
}
}
outcome
})
});
self.runners.insert(J::NAME.to_string(), runner);
self
}
pub fn with<J: Job>(mut self) -> Self {
self.register::<J>();
self
}
pub fn knows(&self, name: &str) -> bool {
self.runners.contains_key(name)
}
pub fn names(&self) -> Vec<&str> {
let mut names: Vec<&str> = self.runners.keys().map(String::as_str).collect();
names.sort();
names
}
pub async fn run(&self, job: &QueuedJob, context: Arc<JobContext>) -> Result<()> {
let runner = self.runners.get(&job.name).ok_or_else(|| {
Error::internal(format!(
"no job is registered as `{}` — register it on the JobRegistry, or the worker \
cannot run it. Known jobs: {:?}",
job.name,
self.names()
))
})?;
runner(job.payload.clone(), context).await
}
}
impl std::fmt::Debug for JobRegistry {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("JobRegistry").field("jobs", &self.names()).finish()
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::atomic::{AtomicU32, Ordering};
use std::sync::Mutex;
static RAN: Mutex<Vec<String>> = Mutex::new(Vec::new());
static FAILED: AtomicU32 = AtomicU32::new(0);
#[derive(Serialize, Deserialize)]
struct SendEmail {
to: String,
}
#[async_trait::async_trait]
impl Job for SendEmail {
const NAME: &'static str = "mail.send";
async fn handle(&self, _: &JobContext) -> Result<()> {
RAN.lock().unwrap().push(self.to.clone());
Ok(())
}
}
#[derive(Serialize, Deserialize)]
struct AlwaysFails;
#[async_trait::async_trait]
impl Job for AlwaysFails {
const NAME: &'static str = "test.fails";
const TRIES: u32 = 2;
const QUEUE: &'static str = "slow";
async fn handle(&self, _: &JobContext) -> Result<()> {
Err(Error::internal("nope"))
}
async fn failed(&self, _: &JobContext, _: &Error) {
FAILED.fetch_add(1, Ordering::SeqCst);
}
}
fn context(attempt: u32, max_attempts: u32) -> Arc<JobContext> {
Arc::new(JobContext::new(
Arc::new(Container::new()),
"job-1",
"default",
attempt,
max_attempts,
))
}
#[test]
fn a_job_serialises_with_its_name_and_defaults() {
let queued = QueuedJob::from_job(&SendEmail { to: "ada@example.com".into() }).unwrap();
assert_eq!(queued.name, "mail.send");
assert_eq!(queued.queue, "default");
assert_eq!(queued.max_attempts, 3);
assert_eq!(queued.attempts, 0);
assert_eq!(queued.payload["to"], "ada@example.com");
assert!(queued.is_available());
assert!(!queued.is_exhausted());
}
#[test]
fn a_job_can_declare_its_own_queue_and_tries() {
let queued = QueuedJob::from_job(&AlwaysFails).unwrap();
assert_eq!(queued.queue, "slow");
assert_eq!(queued.max_attempts, 2);
}
#[test]
fn a_delay_pushes_availability_into_the_future() {
let queued = QueuedJob::from_job(&SendEmail { to: "a@b.c".into() })
.unwrap()
.delayed_by(Duration::from_secs(60));
assert!(!queued.is_available());
assert!(queued.available_at > Utc::now());
}
#[test]
fn queued_ids_are_unique() {
let a = QueuedJob::from_job(&SendEmail { to: "a".into() }).unwrap();
let b = QueuedJob::from_job(&SendEmail { to: "b".into() }).unwrap();
assert_ne!(a.id, b.id);
}
#[test]
fn backoff_grows_and_then_levels_off() {
assert_eq!(SendEmail::backoff(0), Duration::from_secs(1));
assert_eq!(SendEmail::backoff(1), Duration::from_secs(2));
assert_eq!(SendEmail::backoff(3), Duration::from_secs(8));
assert_eq!(SendEmail::backoff(20), Duration::from_secs(64));
}
#[tokio::test]
async fn the_registry_runs_a_job_by_name() {
RAN.lock().unwrap().clear();
let registry = JobRegistry::new().with::<SendEmail>();
let queued = QueuedJob::from_job(&SendEmail { to: "ada@example.com".into() }).unwrap();
registry.run(&queued, context(1, 3)).await.unwrap();
assert_eq!(*RAN.lock().unwrap(), vec!["ada@example.com"]);
}
#[tokio::test]
async fn an_unregistered_job_names_itself_and_what_is_known() {
let registry = JobRegistry::new().with::<SendEmail>();
let queued = QueuedJob::from_job(&AlwaysFails).unwrap();
let err = registry.run(&queued, context(1, 2)).await.unwrap_err();
assert!(err.message().contains("test.fails"), "{}", err.message());
assert!(err.message().contains("mail.send"), "{}", err.message());
}
#[tokio::test]
async fn a_payload_that_no_longer_fits_its_type_is_reported_clearly() {
let registry = JobRegistry::new().with::<SendEmail>();
let mut queued = QueuedJob::from_job(&SendEmail { to: "a".into() }).unwrap();
queued.payload = serde_json::json!({ "recipient": "a" });
let err = registry.run(&queued, context(1, 3)).await.unwrap_err();
assert!(err.message().contains("diverged"), "{}", err.message());
}
#[tokio::test]
async fn the_failed_hook_only_fires_on_the_last_attempt() {
FAILED.store(0, Ordering::SeqCst);
let registry = JobRegistry::new().with::<AlwaysFails>();
let queued = QueuedJob::from_job(&AlwaysFails).unwrap();
assert!(registry.run(&queued, context(1, 2)).await.is_err());
assert_eq!(FAILED.load(Ordering::SeqCst), 0, "not on the first attempt");
assert!(registry.run(&queued, context(2, 2)).await.is_err());
assert_eq!(FAILED.load(Ordering::SeqCst), 1, "but yes on the last");
}
#[test]
fn the_registry_lists_what_it_knows() {
let registry = JobRegistry::new().with::<SendEmail>().with::<AlwaysFails>();
assert_eq!(registry.names(), vec!["mail.send", "test.fails"]);
assert!(registry.knows("mail.send"));
assert!(!registry.knows("nope"));
}
#[test]
fn the_context_reports_the_attempt_window() {
let context = context(3, 3);
assert_eq!(context.attempt(), 3);
assert!(context.is_last_attempt());
assert!(!self::context(1, 3).is_last_attempt());
}
}