Skip to main content

rustlavel_queue/
chain.rs

1//! Jobs that run one after another.
2//!
3//! A pipeline — transcribe, then detect the moments, then render each — is a
4//! chain: the next step is dispatched when the one before it succeeds, and a
5//! step that fails for good ends the chain there. The steps are ordinary jobs;
6//! nothing about a job knows it is in a chain except the context it is handed,
7//! which carries the chain's id so progress can be reported against the whole.
8//!
9//! ```ignore
10//! queue.dispatch_chain(
11//!     Chain::new(Transcribe { video })
12//!         .then(DetectMoments { video })
13//!         .then(RenderClips { video }),
14//! ).await?;
15//! ```
16//!
17//! **A failed step stops the chain**, and the remaining steps go to the
18//! dead-letter table with it, so what did not run is visible rather than
19//! silently gone. Retries happen within the step, with the step's own policy;
20//! the chain only advances on success.
21
22use crate::job::{Job, QueuedJob};
23
24/// A sequence of jobs.
25#[derive(Debug, Clone, PartialEq)]
26pub struct Chain {
27    /// A name for the whole, so progress for the pipeline has one key.
28    /// Generated when not given.
29    pub id: String,
30    steps: Vec<QueuedJob>,
31}
32
33impl Chain {
34    /// Start a chain with its first step.
35    pub fn new<J: Job>(first: J) -> Chain {
36        Chain { id: chain_id(), steps: vec![first.to_queued()] }
37    }
38
39    /// Name the chain yourself — an order id, a video id — so the key a UI
40    /// watches is one it already knows.
41    pub fn named(mut self, id: impl Into<String>) -> Chain {
42        self.id = id.into();
43        self
44    }
45
46    pub fn then<J: Job>(mut self, next: J) -> Chain {
47        self.steps.push(next.to_queued());
48        self
49    }
50
51    /// Add a step that is already an envelope, for a job with no Rust type.
52    pub fn then_queued(mut self, next: QueuedJob) -> Chain {
53        self.steps.push(next);
54        self
55    }
56
57    pub fn len(&self) -> usize {
58        self.steps.len()
59    }
60
61    pub fn is_empty(&self) -> bool {
62        self.steps.is_empty()
63    }
64
65    /// The first step, carrying the rest and the chain's id. This is what is
66    /// pushed; the worker peels one step off each time one succeeds.
67    pub fn into_queued(mut self) -> QueuedJob {
68        let mut first = self.steps.remove(0);
69        first.chain = Some(self.id);
70        first.then = self.steps;
71        first
72    }
73}
74
75fn chain_id() -> String {
76    // Time and a counter: unique within a process and very likely across
77    // them, and readable in a log. Not a secret, so not from the CSPRNG.
78    static COUNTER: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
79    let count = COUNTER.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
80    format!("{:x}-{count:x}", crate::time::unix_now())
81}
82
83impl QueuedJob {
84    /// The job to push once this one has succeeded, if this one is a step in
85    /// a chain with more to come. The returned job carries the remainder.
86    pub fn next_in_chain(&self) -> Option<QueuedJob> {
87        let mut rest = self.then.clone();
88        if rest.is_empty() {
89            return None;
90        }
91        let mut next = rest.remove(0);
92        next.chain = self.chain.clone();
93        next.then = rest;
94        Some(next)
95    }
96}