Skip to main content

laterite_core/queue/
worker.rs

1//! The worker: claims, runs, retries and fails jobs by their own rules.
2
3use std::time::Duration;
4
5use chrono::Utc;
6
7use super::{JobError, JobFailed, Queue, Queued};
8use crate::error::CoreResult;
9
10/// How one [`Worker`] runs.
11#[derive(Debug, Clone)]
12pub struct WorkOptions {
13    /// The queues to take from, in priority order; the queue's configured
14    /// list when empty.
15    pub queues: Vec<String>,
16    /// Process one job (or find none) and return.
17    pub once: bool,
18    /// Return when no job is waiting.
19    pub stop_when_empty: bool,
20    /// The worker's name, recorded on the jobs it claims.
21    pub name: String,
22}
23
24impl Default for WorkOptions {
25    fn default() -> Self {
26        Self {
27            queues: Vec::new(),
28            once: false,
29            stop_when_empty: false,
30            name: format!("{}-{:x}", hostname(), rand::random::<u32>()),
31        }
32    }
33}
34
35impl WorkOptions {
36    pub fn queues(mut self, queues: Vec<String>) -> Self {
37        self.queues = queues;
38        self
39    }
40    pub fn once(mut self, once: bool) -> Self {
41        self.once = once;
42        self
43    }
44    pub fn stop_when_empty(mut self, stop: bool) -> Self {
45        self.stop_when_empty = stop;
46        self
47    }
48    pub fn name(mut self, name: impl Into<String>) -> Self {
49        self.name = name.into();
50        self
51    }
52}
53
54fn hostname() -> String {
55    std::env::var("HOSTNAME")
56        .ok()
57        .filter(|h| !h.is_empty())
58        .unwrap_or_else(|| format!("pid{}", std::process::id()))
59}
60
61/// One worker loop over a [`Queue`].
62pub struct Worker {
63    queue: Queue,
64    options: WorkOptions,
65}
66
67/// What became of one claimed job.
68#[derive(Debug, Clone, PartialEq, Eq)]
69pub enum Outcome {
70    Done,
71    Released { attempt: u32, retry_in: Duration },
72    Failed { attempt: u32, error: String },
73}
74
75impl Worker {
76    pub fn new(queue: Queue, options: WorkOptions) -> Self {
77        Self { queue, options }
78    }
79
80    /// Runs until `stop` turns true, or as the options end it. Returns how
81    /// many jobs it processed.
82    pub async fn run(&self, mut stop: tokio::sync::watch::Receiver<bool>) -> CoreResult<usize> {
83        let queues: Vec<String> = if self.options.queues.is_empty() {
84            self.queue.queues().to_vec()
85        } else {
86            self.options.queues.clone()
87        };
88        let mut processed = 0usize;
89        loop {
90            if *stop.borrow() {
91                return Ok(processed);
92            }
93            let claimed = self
94                .queue
95                .driver()
96                .claim(&queues, self.queue.retry_after(), &self.options.name)
97                .await;
98            match claimed {
99                Ok(Some(job)) => {
100                    self.process(job).await;
101                    processed += 1;
102                    if self.options.once {
103                        return Ok(processed);
104                    }
105                }
106                Ok(None) => {
107                    if self.options.once || self.options.stop_when_empty {
108                        return Ok(processed);
109                    }
110                    tokio::select! {
111                        _ = self.queue.wake().notified() => {}
112                        _ = tokio::time::sleep(self.queue.poll()) => {}
113                        _ = stop.changed() => {}
114                    }
115                }
116                Err(e) => {
117                    tracing::error!(error = %e, "claiming a job failed");
118                    tokio::select! {
119                        _ = tokio::time::sleep(self.queue.poll()) => {}
120                        _ = stop.changed() => {}
121                    }
122                }
123            }
124        }
125    }
126
127    /// Runs one claimed job to its outcome and records it.
128    pub async fn process(&self, job: Queued) -> Outcome {
129        let Some(reg) = self.queue.job(&job.name) else {
130            return self
131                .fail(&job, &format!("job `{}` is not registered", job.name))
132                .await;
133        };
134        let meta = match (reg.meta)(&job.payload) {
135            Ok(meta) => meta,
136            Err(e) => return self.fail(&job, &e).await,
137        };
138        let now = Utc::now();
139        // A job claimed again after a crash may already be out of attempts.
140        let window_passed = meta.retry_until.is_some_and(|until| now > until);
141        let tries_spent = meta.retry_until.is_none() && meta.tries > 0 && job.attempts > meta.tries;
142        if window_passed || tries_spent {
143            return self
144                .fail(
145                    &job,
146                    &format!("attempt {} exceeded the job's tries", job.attempts),
147                )
148                .await;
149        }
150        let cx = self.queue.context(Some(job.id), job.attempts, &job.queue);
151        let run = (reg.run)(job.payload.clone(), cx);
152        let result = match meta.timeout {
153            Some(limit) => match tokio::time::timeout(limit, run).await {
154                Ok(result) => result,
155                Err(_) => Err(JobError::Retry(format!(
156                    "timed out after {} seconds",
157                    limit.as_secs()
158                ))),
159            },
160            None => run.await,
161        };
162        match result {
163            Ok(()) => {
164                if let Err(e) = self.queue.driver().delete(job.id).await {
165                    tracing::error!(job = job.id, error = %e, "finished job could not be removed");
166                }
167                tracing::debug!(job = job.id, name = %job.name, attempt = job.attempts, "job done");
168                Outcome::Done
169            }
170            Err(JobError::Fail(message)) => self.fail(&job, &message).await,
171            Err(JobError::Retry(message)) => {
172                let now = Utc::now();
173                let window_passed = meta.retry_until.is_some_and(|until| now >= until);
174                let last_try =
175                    meta.retry_until.is_none() && meta.tries > 0 && job.attempts >= meta.tries;
176                if window_passed || last_try {
177                    return self.fail(&job, &message).await;
178                }
179                let retry_in = (reg.backoff)(&job.payload, job.attempts);
180                let due =
181                    now + chrono::Duration::from_std(retry_in).unwrap_or(chrono::Duration::MAX);
182                if let Err(e) = self.queue.driver().release(job.id, due).await {
183                    tracing::error!(job = job.id, error = %e, "job could not be released");
184                }
185                tracing::warn!(
186                    job = job.id,
187                    name = %job.name,
188                    attempt = job.attempts,
189                    retry_in = retry_in.as_secs(),
190                    error = %message,
191                    "job failed; will retry"
192                );
193                Outcome::Released {
194                    attempt: job.attempts,
195                    retry_in,
196                }
197            }
198        }
199    }
200
201    async fn fail(&self, job: &Queued, error: &str) -> Outcome {
202        match self.queue.driver().fail(job, error).await {
203            Ok(failed_id) => {
204                tracing::error!(
205                    job = job.id,
206                    name = %job.name,
207                    attempt = job.attempts,
208                    error,
209                    "job failed"
210                );
211                self.queue
212                    .services()
213                    .events
214                    .emit(&JobFailed {
215                        id: failed_id,
216                        name: job.name.clone(),
217                        queue: job.queue.clone(),
218                        attempts: job.attempts,
219                        error: error.to_string(),
220                    })
221                    .await;
222            }
223            Err(e) => {
224                tracing::error!(job = job.id, error = %e, "failed job could not be recorded");
225            }
226        }
227        Outcome::Failed {
228            attempt: job.attempts,
229            error: error.to_string(),
230        }
231    }
232}