Skip to main content

apalis_core/backend/ext/poll_strategy/
mod.rs

1//! Polling strategies for backends.
2//!
3//! This module provides abstractions and implementations for different polling strategies
4//! used by backends to determine when to poll for new tasks. Strategies can be
5//! combined, customized, and extended to suit various workload requirements.
6//!
7//! # Features
8//!
9//! - Trait [`PollStrategy`] for defining custom polling strategies.
10//! - [`PollSnapshot`] struct for passing contextual information to strategies.
11//! - Boxed trait object type [`BoxedPollStrategy`] for dynamic dispatch.
12//! - Built-in strategies and combinators.
13//!
14//! # Usage
15//!
16//! Implement the [`PollStrategy`] trait for your custom strategy, or use the provided
17//! strategies and combinators. Use [`PollSnapshot`] to access worker state and previous
18//! task counts.
19//!
20//! See submodules for available strategies and builder utilities.
21use std::{
22    task::{Context, Poll},
23    time::{Duration, Instant},
24};
25
26mod strategies;
27pub use strategies::*;
28mod builder;
29pub use builder::*;
30mod poll_with;
31pub use poll_with::*;
32
33/// A boxed poll strategy
34pub type BoxedPollStrategy = Box<dyn PollStrategy + Send + Sync + 'static>;
35
36/// A trait for different polling strategies
37/// All strategies can be combined in a race condition
38pub trait PollStrategy {
39    /// Poll to bind a waker to the next instance.
40    ///
41    /// This can be for example a stream which wakes when it receives a new item
42    ///
43    /// # Contract
44    /// If this returns `Poll::Pending`, the implementation **must** arrange
45    /// for `cx.waker()` to be woken when it becomes ready to poll again.
46    fn poll_drive(&mut self, cx: &mut Context<'_>, snapshot: &PollSnapshot) -> Poll<Option<()>>;
47
48    /// Signifies a poll was made
49    #[allow(unused_variables)]
50    fn on_poll(&mut self, snapshot: &PollSnapshot) {}
51}
52
53/// Snapshot of the current polling state.
54///
55/// This is intended to be passed to adaptive polling strategies.
56#[derive(Debug, Clone, Copy, Default)]
57pub struct PollSnapshot {
58    /// Number of consecutive successful polls since the last `Pending`.
59    pub consecutive_ready: usize,
60
61    /// Total number of successful polls.
62    pub total_ready: u64,
63
64    /// Total number of pending polls.
65    pub total_pending: u64,
66
67    /// Duration since the last successful poll.
68    pub idle_for: Duration,
69}
70
71/// Keeps a record of the current poll status
72#[derive(Debug, Default, Clone)]
73pub struct PollMetrics {
74    consecutive_ready: usize,
75    total_ready: u64,
76    total_pending: u64,
77    last_ready: Option<Instant>,
78}
79
80/// The worker's activity classification based on consecutive successful polls.
81#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
82pub enum ActivityState {
83    /// No work has been produced since the last `Pending` (or ever).
84    #[default]
85    Idle,
86    /// Exactly one successful poll since the last idle period — just started working.
87    WakingUp,
88    /// More than one consecutive successful poll — sustained activity.
89    Saturated,
90}
91
92impl ActivityState {
93    #[must_use]
94    const fn from_consecutive_ready(consecutive_ready: usize) -> Self {
95        match consecutive_ready {
96            0 => Self::Idle,
97            1 => Self::WakingUp,
98            _ => Self::Saturated,
99        }
100    }
101}
102
103impl PollSnapshot {
104    /// Create a new polling context.
105    #[must_use]
106    pub const fn new() -> Self {
107        Self {
108            consecutive_ready: 0,
109            total_ready: 0,
110            total_pending: 0,
111            idle_for: Duration::from_secs(0),
112        }
113    }
114
115    /// Total number of polls performed.
116    #[must_use]
117    pub const fn total_polls(&self) -> u64 {
118        self.total_ready + self.total_pending
119    }
120
121    /// Returns true if no polls have been performed yet.
122    #[must_use]
123    pub const fn is_empty(&self) -> bool {
124        self.total_polls() == 0
125    }
126
127    /// Fraction of polls that returned work.
128    ///
129    /// Returns `1.0` if no polls have occurred yet.
130    #[must_use]
131    pub fn hit_rate(&self) -> f64 {
132        let total = self.total_polls();
133
134        if total == 0 {
135            1.0
136        } else {
137            self.total_ready as f64 / total as f64
138        }
139    }
140
141    /// Fraction of polls that returned `Pending`.
142    #[must_use]
143    pub fn miss_rate(&self) -> f64 {
144        1.0 - self.hit_rate()
145    }
146
147    /// Returns whether the worker has been idle for at least `duration`.
148    #[must_use]
149    pub fn idle_for_at_least(&self, duration: Duration) -> bool {
150        self.idle_for >= duration
151    }
152
153    /// Returns whether the worker has been idle for less than `duration`.
154    #[must_use]
155    pub fn idle_for_less_than(&self, duration: Duration) -> bool {
156        self.idle_for < duration
157    }
158
159    /// Returns true if enough consecutive jobs have been received to
160    /// consider switching to an aggressive polling strategy.
161    #[must_use]
162    pub const fn should_poll_aggressively(&self, threshold: usize) -> bool {
163        self.consecutive_ready >= threshold
164    }
165
166    /// Returns true if the worker should consider backing off after
167    /// remaining idle for the given duration.
168    #[must_use]
169    pub fn should_backoff(&self, max_idle: Duration) -> bool {
170        self.consecutive_ready == 0 && self.idle_for >= max_idle
171    }
172
173    /// Classify the worker's current activity state.
174    #[must_use]
175    pub const fn activity(&self) -> ActivityState {
176        ActivityState::from_consecutive_ready(self.consecutive_ready)
177    }
178
179    /// Returns true if the previous poll produced work (not idle).
180    #[must_use]
181    pub const fn is_busy(&self) -> bool {
182        !matches!(self.activity(), ActivityState::Idle)
183    }
184
185    /// Returns true if the worker is currently idle.
186    #[must_use]
187    pub const fn is_idle(&self) -> bool {
188        matches!(self.activity(), ActivityState::Idle)
189    }
190
191    /// Returns whether this is the first successful poll after an idle period.
192    #[must_use]
193    pub const fn is_waking_up(&self) -> bool {
194        matches!(self.activity(), ActivityState::WakingUp)
195    }
196
197    /// Returns whether the worker has sustained activity.
198    #[must_use]
199    pub const fn is_saturated(&self) -> bool {
200        matches!(self.activity(), ActivityState::Saturated)
201    }
202}
203
204impl PollMetrics {
205    /// Register a ready call response
206    pub fn on_ready(&mut self) {
207        self.consecutive_ready = self.consecutive_ready.saturating_add(1);
208        self.total_ready = self.total_ready.saturating_add(1);
209        self.last_ready = Some(Instant::now());
210    }
211
212    /// Register a pending poll response
213    pub fn on_pending(&mut self) {
214        self.consecutive_ready = 0;
215        self.total_pending = self.total_pending.saturating_add(1);
216    }
217
218    /// Get the snapshot of the current poll status
219    #[must_use]
220    pub fn snapshot(&self) -> PollSnapshot {
221        PollSnapshot {
222            consecutive_ready: self.consecutive_ready,
223            total_ready: self.total_ready,
224            total_pending: self.total_pending,
225            idle_for: self
226                .last_ready
227                .map(|instant| instant.elapsed())
228                .unwrap_or_default(),
229        }
230    }
231}
232
233#[cfg(test)]
234mod tests {
235    use std::{collections::VecDeque, sync::Arc, time::Duration};
236
237    use futures_channel::mpsc;
238
239    use futures_util::{
240        FutureExt, SinkExt, StreamExt,
241        lock::Mutex,
242        sink,
243        stream::{self},
244    };
245
246    use crate::{
247        error::BoxDynError,
248        task::{Task, task_id::RandomId},
249        worker::{
250            builder::WorkerBuilder, context::WorkerContext, ext::event_listener::EventListenerExt,
251        },
252    };
253
254    use super::*;
255    use crate::backend::TaskSink;
256
257    const ITEMS: u32 = 10;
258
259    #[tokio::test]
260    #[cfg(feature = "sleep")]
261    async fn basic_strategy_backend() {
262        use crate::backend::{dequeue::VecDequeBackend, ext::BackendExt};
263        let config = BackoffConfig::new(Duration::from_secs(5))
264            .with_multiplier(1.5)
265            .with_jitter(0.2);
266
267        let interval = Duration::from_millis(200);
268        let mut backend = VecDequeBackend::new().poll_with_backoff(interval, config);
269
270        for i in 0..ITEMS {
271            backend.push(i).await.unwrap();
272        }
273
274        async fn task(task: u32, worker: WorkerContext) -> Result<(), BoxDynError> {
275            tokio::time::sleep(Duration::from_secs(1)).await;
276            if task == ITEMS - 1 {
277                tokio::time::sleep(Duration::from_secs(5)).await;
278                worker.stop().unwrap();
279                return Err("Worker stopped!")?;
280            }
281            Ok(())
282        }
283
284        let worker = WorkerBuilder::new("rango-tango")
285            .backend(backend)
286            .on_event(|worker, ev| {
287                println!("On Event = {ev:?} from {}", worker.name());
288            })
289            .build(task);
290        worker.run().await.unwrap();
291    }
292
293    #[tokio::test]
294    #[cfg(feature = "sleep")]
295    async fn custom_strategy_backend() {
296        use crate::backend::{custom::BackendBuilder, ext::BackendExt};
297
298        let memory = Arc::new(Mutex::new(VecDeque::new()));
299        let backoff = BackoffConfig::new(Duration::from_secs(5))
300            .with_multiplier(1.5)
301            .with_jitter(0.2);
302        let interval = IntervalStrategy::new(Duration::from_millis(200)).with_backoff(backoff);
303
304        let when_i_am_ready = FutureStrategy::new(move |_| {
305            // println!("Waiting to be ready...");
306            crate::timer::sleep(Duration::from_millis(1500))
307        });
308
309        let (mut tx, rx) = mpsc::channel(1);
310
311        tokio::spawn(async move {
312            for i in 0..ITEMS {
313                tokio::time::sleep(Duration::from_secs((i) as u64)).await;
314                if tx.send(()).await.is_err() {
315                    break;
316                }
317            }
318        });
319
320        let strategy = Strategy::new()
321            .apply(when_i_am_ready)
322            .apply(interval)
323            .apply(StreamStrategy::new(rx));
324
325        let mut backend = BackendBuilder::new_with_cfg(())
326            .database(memory)
327            .fetcher(|db, _, worker| {
328                stream::unfold((db.clone(), worker.clone()), |(p, worker)| async move {
329                    let mut db = p.lock().await;
330                    let item = db.pop_front();
331                    drop(db);
332                    if let Some(item) = item {
333                        Some((Ok::<_, BoxDynError>(Some(item)), (p, worker)))
334                    } else {
335                        Some((Ok::<Option<Task<u32>>, BoxDynError>(None), (p, worker)))
336                    }
337                })
338                .boxed()
339            })
340            .sink(|db, _| {
341                sink::unfold(db.clone(), move |p, item: Task<u32>| {
342                    async move {
343                        use crate::task::task_id::TaskId;
344
345                        let mut db = p.lock().await;
346
347                        db.push_back(
348                            item.into_builder()
349                                .task_id(TaskId::from_string(RandomId::default()))
350                                .build(),
351                        );
352                        drop(db);
353                        Ok::<_, BoxDynError>(p)
354                    }
355                    .boxed()
356                })
357            })
358            .build()
359            .unwrap()
360            .poll_with_strategy(strategy);
361
362        for i in 0..ITEMS {
363            backend.push(i).await.unwrap();
364        }
365
366        async fn task(task: u32, worker: WorkerContext) -> Result<(), BoxDynError> {
367            tokio::time::sleep(Duration::from_secs(1)).await;
368            if task == ITEMS - 1 {
369                tokio::time::sleep(Duration::from_secs(10)).await;
370                worker.stop().unwrap();
371                return Err("Worker stopped!")?;
372            }
373            Ok(())
374        }
375
376        let worker = WorkerBuilder::new("rango-tango")
377            .backend(backend)
378            .on_event(|worker, ev| {
379                println!("On Event = {ev:?} from {}", worker.name());
380            })
381            .build(task);
382        worker.run().await.unwrap();
383    }
384}