Skip to main content

tako_rs_core/queue/
runtime.rs

1//! Queue runtime: builder and lifecycle handles.
2
3use std::collections::VecDeque;
4use std::future::Future;
5use std::pin::Pin;
6use std::sync::Arc;
7use std::sync::atomic::AtomicBool;
8use std::sync::atomic::AtomicU64;
9use std::sync::atomic::Ordering;
10use std::time::Duration;
11use std::time::Instant;
12
13use parking_lot::Mutex;
14use scc::HashMap as SccHashMap;
15use tokio::sync::Notify;
16
17use super::DeadJob;
18use super::Job;
19use super::QueueBuilder;
20use super::QueueError;
21use super::RetryPolicy;
22#[cfg(feature = "signals")]
23use super::signal_ids;
24use super::worker::worker_loop;
25#[cfg(feature = "signals")]
26use crate::signals::Signal;
27#[cfg(feature = "signals")]
28use crate::signals::SignalArbiter;
29
30pub(crate) struct PendingJob {
31  pub(crate) id: u64,
32  pub(crate) name: String,
33  pub(crate) payload: Vec<u8>,
34  pub(crate) attempt: u32,
35  pub(crate) run_after: Option<Instant>,
36  pub(crate) dedup_key: Option<String>,
37}
38
39pub(crate) type BoxHandler =
40  Arc<dyn Fn(Job) -> Pin<Box<dyn Future<Output = Result<(), QueueError>> + Send>> + Send + Sync>;
41
42pub(crate) struct QueueInner {
43  /// Pending jobs waiting to be processed.
44  pub(crate) pending: Mutex<VecDeque<PendingJob>>,
45  /// Registered job handlers by name.
46  pub(crate) handlers: SccHashMap<String, BoxHandler>,
47  /// Dead letter queue.
48  ///
49  /// Stored as `Vec<Arc<DeadJob>>` so the [`Queue::dead_letters_arc`]
50  /// snapshot only clones the outer `Vec` plus cheap atomic-refcount bumps
51  /// per entry, rather than deep-copying every `payload` / `name` / `error`
52  /// string. The owned [`Queue::dead_letters`] accessor still pays the deep
53  /// clone for API compatibility.
54  pub(crate) dead_letters: Mutex<Vec<Arc<DeadJob>>>,
55  /// Notify workers when new jobs arrive.
56  pub(crate) notify: Notify,
57  /// Monotonically increasing job ID counter.
58  pub(crate) next_id: AtomicU64,
59  /// Number of worker tasks.
60  pub(crate) num_workers: usize,
61  /// Retry policy.
62  pub(crate) retry_policy: RetryPolicy,
63  /// Whether the queue has been shut down.
64  pub(crate) shutdown: AtomicBool,
65  /// Track in-flight jobs for graceful shutdown.
66  pub(crate) inflight: AtomicU64,
67  /// Notify when inflight reaches 0.
68  pub(crate) drain_notify: Notify,
69}
70
71/// An in-memory background job queue.
72///
73/// Create via [`Queue::builder()`] or [`Queue::new()`].
74/// Register handlers with [`register()`](Queue::register), then push jobs
75/// with [`push()`](Queue::push) or [`push_delayed()`](Queue::push_delayed).
76///
77/// The queue must be started with [`start()`](Queue::start) to spawn
78/// background worker tasks that process jobs.
79#[derive(Clone)]
80pub struct Queue {
81  pub(crate) inner: Arc<QueueInner>,
82}
83
84impl Queue {
85  /// Create a queue with default settings (4 workers, no retries).
86  pub fn new() -> Self {
87    Self::builder().build()
88  }
89
90  /// Create a builder for customizing the queue.
91  pub fn builder() -> QueueBuilder {
92    QueueBuilder {
93      workers: 4,
94      retry: RetryPolicy::default(),
95    }
96  }
97
98  /// Register a named job handler.
99  ///
100  /// The handler receives a [`Job`] and returns `Result<(), QueueError>`.
101  ///
102  /// # Examples
103  ///
104  /// ```rust,ignore
105  /// queue.register("process_order", |job: Job| async move {
106  ///     let order_id: u64 = job.deserialize()?;
107  ///     // process the order ...
108  ///     Ok(())
109  /// });
110  /// ```
111  pub fn register<F, Fut>(&self, name: impl Into<String>, handler: F)
112  where
113    F: Fn(Job) -> Fut + Send + Sync + 'static,
114    Fut: Future<Output = Result<(), QueueError>> + Send + 'static,
115  {
116    let name = name.into();
117    let handler: BoxHandler = Arc::new(move |job| Box::pin(handler(job)));
118    let _ = self.inner.handlers.insert_sync(name, handler);
119  }
120
121  /// Push a job for immediate execution.
122  ///
123  /// The payload is serialized to JSON. Returns the job ID.
124  pub async fn push(
125    &self,
126    name: impl Into<String>,
127    payload: &(impl serde::Serialize + ?Sized),
128  ) -> Result<u64, QueueError> {
129    self.push_inner(name.into(), payload, None)
130  }
131
132  /// Push a job for delayed execution.
133  ///
134  /// The job will not be picked up by a worker until `delay` has elapsed.
135  pub async fn push_delayed(
136    &self,
137    name: impl Into<String>,
138    payload: &(impl serde::Serialize + ?Sized),
139    delay: Duration,
140  ) -> Result<u64, QueueError> {
141    self.push_inner(name.into(), payload, Some(Instant::now() + delay))
142  }
143
144  /// Push with a dedup key — the job is queued at most once concurrently.
145  ///
146  /// If a job with the same `dedup_key` is currently in `pending`, this is a
147  /// no-op and the existing id is returned. Useful for idempotent triggers
148  /// (e.g. flush a cache only once per minute regardless of how many requests
149  /// arrived). The dedup window ends when the job is picked up.
150  pub async fn push_dedup(
151    &self,
152    name: impl Into<String>,
153    payload: &(impl serde::Serialize + ?Sized),
154    dedup_key: impl Into<String>,
155  ) -> Result<u64, QueueError> {
156    if self.inner.shutdown.load(Ordering::SeqCst) {
157      return Err(QueueError::Shutdown);
158    }
159    let key = dedup_key.into();
160    let name = name.into();
161    let bytes =
162      serde_json::to_vec(payload).map_err(|e| QueueError::SerializeError(e.to_string()))?;
163
164    // Hold the pending lock across the check-and-insert so two concurrent
165    // `push_dedup` callers cannot both observe "no duplicate" and then both
166    // enqueue their own copy of the job. Re-check `shutdown` inside the lock
167    // so a concurrent `shutdown()` (which itself grabs this lock around the
168    // flag flip) cannot slip in between the early check above and the push.
169    let id = {
170      let mut pending = self.inner.pending.lock();
171      if self.inner.shutdown.load(Ordering::SeqCst) {
172        return Err(QueueError::Shutdown);
173      }
174      for j in pending.iter() {
175        if j.dedup_key.as_deref() == Some(key.as_str()) {
176          return Ok(j.id);
177        }
178      }
179      let id = self.inner.next_id.fetch_add(1, Ordering::SeqCst);
180      pending.push_back(PendingJob {
181        id,
182        name,
183        payload: bytes,
184        attempt: 0,
185        run_after: None,
186        dedup_key: Some(key),
187      });
188      id
189    };
190
191    self.inner.notify.notify_one();
192    Ok(id)
193  }
194
195  fn push_inner(
196    &self,
197    name: String,
198    payload: &(impl serde::Serialize + ?Sized),
199    run_after: Option<Instant>,
200  ) -> Result<u64, QueueError> {
201    if self.inner.shutdown.load(Ordering::SeqCst) {
202      return Err(QueueError::Shutdown);
203    }
204
205    let bytes =
206      serde_json::to_vec(payload).map_err(|e| QueueError::SerializeError(e.to_string()))?;
207
208    let id = self.inner.next_id.fetch_add(1, Ordering::SeqCst);
209
210    #[cfg(feature = "signals")]
211    let job_name = name.clone();
212    {
213      let mut pending = self.inner.pending.lock();
214      // Re-check shutdown inside the lock — `shutdown()` flips the flag under
215      // the same lock, so this turns the check-and-push into an atomic test
216      // that cannot race with concurrent shutdown.
217      if self.inner.shutdown.load(Ordering::SeqCst) {
218        return Err(QueueError::Shutdown);
219      }
220      pending.push_back(PendingJob {
221        id,
222        name,
223        payload: bytes,
224        attempt: 0,
225        run_after,
226        dedup_key: None,
227      });
228    }
229
230    self.inner.notify.notify_one();
231    #[cfg(feature = "signals")]
232    {
233      let arbiter = SignalArbiter::emit_app(
234        Signal::with_capacity(signal_ids::QUEUE_JOB_QUEUED, 2)
235          .meta("name", job_name)
236          .meta("id", id.to_string()),
237      );
238      // Best-effort fire-and-forget; the push API is sync for ergonomics.
239      // Both runtimes need a spawn — previously the compio branch silently
240      // dropped the arbiter future, so queue signals never fired under
241      // io_uring.
242      #[cfg(not(feature = "compio"))]
243      {
244        tokio::spawn(arbiter);
245      }
246      #[cfg(feature = "compio")]
247      {
248        compio::runtime::spawn(arbiter).detach();
249      }
250    }
251    Ok(id)
252  }
253
254  /// Start background worker tasks.
255  ///
256  /// This spawns `workers` number of tokio tasks that process jobs from the queue.
257  /// Must be called once before pushing jobs.
258  #[cfg(not(feature = "compio"))]
259  pub fn start(&self) {
260    for _ in 0..self.inner.num_workers {
261      let inner = self.inner.clone();
262      tokio::spawn(async move { worker_loop(inner).await });
263    }
264    tracing::debug!("Queue started with {} workers", self.inner.num_workers);
265  }
266
267  /// Start background worker tasks (compio runtime).
268  #[cfg(feature = "compio")]
269  pub fn start(&self) {
270    for _ in 0..self.inner.num_workers {
271      let inner = self.inner.clone();
272      compio::runtime::spawn(async move { worker_loop(inner).await }).detach();
273    }
274    tracing::debug!("Queue started with {} workers", self.inner.num_workers);
275  }
276
277  /// Gracefully shut down the queue.
278  ///
279  /// Stops accepting new jobs and waits for in-flight jobs to complete
280  /// (up to the given timeout).
281  pub async fn shutdown(&self, timeout: Duration) {
282    // Acquire the pending lock before flipping the flag so any concurrent
283    // `push_inner` / `push_dedup` (which re-check `shutdown` while holding
284    // the same lock) reliably observes the flip and rejects with
285    // `QueueError::Shutdown` instead of silently enqueuing a job into a
286    // queue whose workers are about to exit.
287    {
288      let _guard = self.inner.pending.lock();
289      self.inner.shutdown.store(true, Ordering::SeqCst);
290    }
291    // Wake all workers so they see the shutdown flag.
292    //
293    // `Notify::notify_one` only stores ONE pending permit — sequential calls
294    // collapse onto non-parked workers, so this loop only reliably wakes the
295    // workers that happened to be parked at the moment of the first call.
296    // Any worker mid-job (most of them, in practice) wouldn't observe the
297    // wake; they only learned about shutdown via the 100ms park timeout.
298    // `notify_waiters` permits *all* currently-parked waiters at once, which
299    // is the desired shutdown semantics.
300    self.inner.notify.notify_waiters();
301
302    if self.inner.inflight.load(Ordering::SeqCst) > 0 {
303      #[cfg(not(feature = "compio"))]
304      {
305        let _ = tokio::time::timeout(timeout, self.inner.drain_notify.notified()).await;
306      }
307      #[cfg(feature = "compio")]
308      {
309        let drain = std::pin::pin!(self.inner.drain_notify.notified());
310        let sleep = std::pin::pin!(compio::time::sleep(timeout));
311        let _ = futures_util::future::select(drain, sleep).await;
312      }
313    }
314
315    tracing::debug!("Queue shut down");
316  }
317
318  /// Returns a snapshot of jobs in the dead letter queue.
319  ///
320  /// Allocates a new `Vec<DeadJob>` and deep-clones every entry. For
321  /// monitoring code that just iterates entries read-only, prefer
322  /// [`Self::dead_letters_arc`] (atomic refcount per entry, no payload
323  /// copies) or [`Self::dead_letter_count`] (no allocation at all).
324  pub fn dead_letters(&self) -> Vec<DeadJob> {
325    self
326      .inner
327      .dead_letters
328      .lock()
329      .iter()
330      .map(|j| (**j).clone())
331      .collect()
332  }
333
334  /// Returns a cheap snapshot of the dead letter queue.
335  ///
336  /// Each entry is shared via `Arc` rather than deep-cloned, so this is
337  /// suitable for emitting from metrics endpoints or hot paths. Returned
338  /// `Arc<DeadJob>` handles remain valid even if [`Self::clear_dead_letters`]
339  /// is called concurrently.
340  pub fn dead_letters_arc(&self) -> Vec<Arc<DeadJob>> {
341    self.inner.dead_letters.lock().clone()
342  }
343
344  /// Returns the number of jobs currently in the dead letter queue.
345  pub fn dead_letter_count(&self) -> usize {
346    self.inner.dead_letters.lock().len()
347  }
348
349  /// Clear all dead letters.
350  pub fn clear_dead_letters(&self) {
351    self.inner.dead_letters.lock().clear();
352  }
353
354  /// Returns the number of pending jobs.
355  pub fn pending_count(&self) -> usize {
356    self.inner.pending.lock().len()
357  }
358
359  /// Returns the number of currently in-flight jobs.
360  pub fn inflight_count(&self) -> u64 {
361    self.inner.inflight.load(Ordering::SeqCst)
362  }
363}
364
365impl Default for Queue {
366  fn default() -> Self {
367    Self::new()
368  }
369}