Skip to main content

webx_core/
hosted.rs

1//! Background work: the `BackgroundService` worker base class, its host
2//! adapter, and cancellation helpers.
3//!
4//! ## Why
5//!
6//! `IHostedService` is the startup/shutdown hook: `start()` runs to completion
7//! and then the host accepts requests. A **worker** (queue consumer, outbox
8//! publisher, periodic poller) instead runs *forever* next to the request
9//! pipeline. Written as an `IHostedService` it has to hand-roll the loop, the
10//! cancellation flag, the join handle and the shutdown logic. Business code
11//! should only write the loop body:
12//!
13//! ```ignore
14//! struct OutboxWorker { services: Arc<dyn ScopeFactory> }
15//!
16//! #[async_trait]
17//! impl BackgroundService for OutboxWorker {
18//!     async fn execute(&self, ct: CancellationToken) -> Result<()> {
19//!         while !ct.is_cancelled() {
20//!             let scope = self.services.create_scope();
21//!             publish_due(&scope).await?;
22//!             // Interruptible wait: returns `false` immediately on shutdown.
23//!             if !delay(&ct, Duration::from_secs(5)).await {
24//!                 break;
25//!             }
26//!         }
27//!         Ok(())
28//!     }
29//! }
30//! ```
31//!
32//! Register it with `add_background_service_with` / `add_background_service`;
33//! the host starts it together with the hosted services and **cancels the token
34//! on shutdown**, so the loop leaves `delay` at once instead of finishing a long
35//! sleep.
36//!
37//! ## Failure semantics
38//!
39//! `execute` returning `Err` is a typed failure, not a panic: the adapter logs
40//! it when it happens and reports it again from `stop()`. A panic inside
41//! `execute` is caught by the task and converted to [`Error::Internal`] in
42//! `stop()`. A worker that
43//! ignores cancellation is aborted after [`STOP_GRACE`] so it cannot outlive
44//! provider disposal.
45
46use std::sync::Arc;
47use std::time::Duration;
48
49use crate::error::{Error, Result};
50use crate::handler::IHostedService;
51
52pub use tokio_util::sync::CancellationToken;
53
54/// How long a background worker may take to observe cancellation.
55///
56/// The host itself allows 30s per `stop()`; a worker that is still running after
57/// this grace period is aborted, because a detached task outliving
58/// `provider.dispose()` would keep touching disposed services.
59pub const STOP_GRACE: Duration = Duration::from_secs(5);
60
61/// A long-running worker: the host calls [`execute`](BackgroundService::execute)
62/// once on startup and cancels the token on shutdown.
63///
64/// Business code only implements the loop body; scope creation, shutdown
65/// observation, panic conversion and the join-handle lifecycle belong to the
66/// framework (`BackgroundServiceHost`).
67#[async_trait::async_trait]
68pub trait BackgroundService: Send + Sync + 'static {
69    /// Run until `ct` is cancelled (or the work is finished).
70    ///
71    /// `ct` is cancelled when the host starts shutting down. Long waits must be
72    /// interruptible — use [`delay`] instead of `tokio::time::sleep`, so a
73    /// shutdown does not wait for the next tick.
74    async fn execute(&self, ct: CancellationToken) -> Result<()>;
75}
76
77/// Wait for `duration`, returning `false` as soon as `ct` is cancelled.
78///
79/// This is the interruptible counterpart of `tokio::time::sleep`: a worker that
80/// sleeps for 30s stops within milliseconds of a shutdown.
81///
82/// ```ignore
83/// while !ct.is_cancelled() {
84///     work().await?;
85///     if !delay(&ct, interval).await {
86///         break; // cancelled
87///     }
88/// }
89/// ```
90pub async fn delay(ct: &CancellationToken, duration: Duration) -> bool {
91    tokio::select! {
92        _ = ct.cancelled() => false,
93        _ = tokio::time::sleep(duration) => true,
94    }
95}
96
97/// A periodic worker: the framework owns the interval loop, the interruptible
98/// wait and stop/join; business code implements [`tick`](PeriodicBackgroundService::tick)
99/// only.
100///
101/// This is the extracted form of the hand-rolled
102/// `AtomicBool + Notify + Mutex<JoinHandle>` runner every application used to
103/// duplicate (interval, wake-on-stop, join, "tick already running finishes"
104/// semantics). Implement it, register it with `add_background_service*`, and
105/// you get a cancellable timer for free:
106///
107/// ```ignore
108/// struct OutboxWorker { services: Arc<dyn ScopeFactory>, interval: Duration }
109///
110/// #[async_trait]
111/// impl PeriodicBackgroundService for OutboxWorker {
112///     fn interval(&self) -> Duration { self.interval }
113///
114///     async fn tick(&self) -> Result<()> {
115///         let scope = self.services.create_scope(); // one scope per tick
116///         publish_due(&scope).await
117///     }
118/// }
119/// ```
120///
121/// Semantics (kept identical to the runner this replaces):
122///
123/// * the first tick runs **after one interval**;
124/// * a tick that is already running finishes before the loop stops — keep ticks
125///   short/bounded;
126/// * a failed tick is logged and the next one runs on schedule, unless
127///   [`stop_on_error`](PeriodicBackgroundService::stop_on_error) is `true`;
128/// * shutdown interrupts the wait immediately, so a 60s interval never delays a
129///   30s shutdown window.
130///
131/// It is automatically a [`BackgroundService`], so
132/// `add_background_service_with` / `add_background_service` accept it as-is.
133#[async_trait::async_trait]
134pub trait PeriodicBackgroundService: Send + Sync + 'static {
135    /// How long to wait between ticks.
136    ///
137    /// Must be greater than zero; a zero interval is a configuration error and
138    /// fails the worker instead of spinning the CPU.
139    fn interval(&self) -> Duration;
140
141    /// One tick. Return `Err` to report a failure (logged, loop continues by
142    /// default).
143    async fn tick(&self) -> Result<()>;
144
145    /// Whether a failed tick stops the worker.
146    ///
147    /// Defaults to `false`: a poller must survive a transient database outage.
148    /// Return `true` for work where a failure is fatal (the error then ends
149    /// `execute` and is reported from `stop()`).
150    fn stop_on_error(&self) -> bool {
151        false
152    }
153}
154
155#[async_trait::async_trait]
156impl<T: PeriodicBackgroundService + ?Sized> BackgroundService for T {
157    async fn execute(&self, ct: CancellationToken) -> Result<()> {
158        let interval = self.interval();
159        if interval.is_zero() {
160            return Err(Error::Internal(
161                "periodic background service interval must be greater than zero".to_string(),
162            ));
163        }
164
165        // Wait first (the first tick is one interval after startup), then tick
166        // until shutdown. `delay` wakes as soon as the token is cancelled.
167        while delay(&ct, interval).await {
168            if let Err(err) = self.tick().await {
169                tracing::error!(
170                    error = %err,
171                    interval_secs = interval.as_secs_f64(),
172                    "periodic background service tick failed"
173                );
174                if self.stop_on_error() {
175                    return Err(err);
176                }
177            }
178        }
179        Ok(())
180    }
181}
182
183/// Runs a [`BackgroundService`] as an [`IHostedService`].
184///
185/// Registered for you by `add_background_service*`; construct it directly only
186/// when you need to compose it yourself.
187pub struct BackgroundServiceHost {
188    service: Arc<dyn BackgroundService>,
189    run: tokio::sync::Mutex<Option<Running>>,
190}
191
192struct Running {
193    stop: CancellationToken,
194    handle: tokio::task::JoinHandle<Result<()>>,
195}
196
197impl BackgroundServiceHost {
198    /// Wrap `service` so the host can start and stop it.
199    pub fn new(service: Arc<dyn BackgroundService>) -> Self {
200        Self {
201            service,
202            run: tokio::sync::Mutex::new(None),
203        }
204    }
205
206    /// Cancel and join the worker, waiting at most `grace` for it to observe
207    /// cancellation.
208    ///
209    /// `stop()` uses [`STOP_GRACE`]; tests use a shorter grace so exercising the
210    /// abort path does not slow the suite down.
211    pub(crate) async fn stop_with_grace(&self, grace: Duration) -> Result<()> {
212        // Taking the state makes `stop()` idempotent and race-free with `start()`.
213        let Some(running) = self.run.lock().await.take() else {
214            return Ok(());
215        };
216
217        running.stop.cancel();
218        let mut handle = running.handle;
219        match tokio::time::timeout(grace, &mut handle).await {
220            Ok(Ok(result)) => result,
221            // The task was aborted or the runtime is shutting down.
222            Ok(Err(join_error)) if join_error.is_cancelled() => Ok(()),
223            Ok(Err(join_error)) => Err(Error::Internal(format!(
224                "background service panicked: {join_error}"
225            ))),
226            Err(_) => {
227                handle.abort();
228                Err(Error::Internal(format!(
229                    "background service did not observe cancellation within {}s and was aborted",
230                    grace.as_secs()
231                )))
232            }
233        }
234    }
235}
236
237#[async_trait::async_trait]
238impl IHostedService for BackgroundServiceHost {
239    async fn start(&self) -> Result<()> {
240        let mut slot = self.run.lock().await;
241        if slot.is_some() {
242            return Err(Error::State(
243                "background service is already running".to_string(),
244            ));
245        }
246
247        let stop = CancellationToken::new();
248        let service = Arc::clone(&self.service);
249        let token = stop.clone();
250        let handle = tokio::spawn(async move {
251            let result = service.execute(token).await;
252            // Log at the moment of failure: `stop()` may run much later, and a
253            // worker that died silently is exactly what operators miss.
254            if let Err(err) = &result {
255                tracing::error!(error = %err, "background service exited with an error");
256            }
257            result
258        });
259
260        *slot = Some(Running { stop, handle });
261        Ok(())
262    }
263
264    async fn stop(&self) -> Result<()> {
265        self.stop_with_grace(STOP_GRACE).await
266    }
267}
268
269/// Hosted service that starts after every registration with a lower `order`.
270///
271/// Implementation detail of `add_hosted_service_ordered` /
272/// `add_hosted_service_with_ordered`: the host discovers hosted services through
273/// DI, so the order has to travel with the instance.
274pub(crate) struct OrderedHostedService<T: ?Sized> {
275    order: i32,
276    inner: Arc<T>,
277}
278
279impl<T: IHostedService + ?Sized> OrderedHostedService<T> {
280    pub(crate) fn new(order: i32, inner: Arc<T>) -> Self {
281        Self { order, inner }
282    }
283}
284
285#[async_trait::async_trait]
286impl<T: IHostedService + ?Sized> IHostedService for OrderedHostedService<T> {
287    async fn start(&self) -> Result<()> {
288        self.inner.start().await
289    }
290
291    async fn stop(&self) -> Result<()> {
292        self.inner.stop().await
293    }
294
295    fn order(&self) -> i32 {
296        self.order
297    }
298}
299
300#[cfg(test)]
301mod tests {
302    use super::*;
303    use std::sync::atomic::{AtomicBool, AtomicU32, Ordering};
304    use std::time::Instant;
305
306    struct Idle;
307
308    #[async_trait::async_trait]
309    impl BackgroundService for Idle {
310        async fn execute(&self, _ct: CancellationToken) -> Result<()> {
311            Ok(())
312        }
313    }
314
315    #[tokio::test]
316    async fn delay_is_interrupted_by_cancellation() {
317        let ct = CancellationToken::new();
318        let cancel = ct.clone();
319        tokio::spawn(async move {
320            tokio::time::sleep(Duration::from_millis(50)).await;
321            cancel.cancel();
322        });
323
324        let started = Instant::now();
325        let slept = delay(&ct, Duration::from_secs(60)).await;
326
327        assert!(!slept, "a cancelled delay must report cancellation");
328        assert!(
329            started.elapsed() < Duration::from_millis(100),
330            "cancellation must interrupt the sleep within 100ms, took {:?}",
331            started.elapsed()
332        );
333    }
334
335    #[tokio::test]
336    async fn delay_waits_the_full_duration_without_cancellation() {
337        let ct = CancellationToken::new();
338        assert!(delay(&ct, Duration::from_millis(10)).await);
339    }
340
341    struct Counting(Arc<AtomicBool>);
342
343    #[async_trait::async_trait]
344    impl BackgroundService for Counting {
345        async fn execute(&self, ct: CancellationToken) -> Result<()> {
346            while delay(&ct, Duration::from_secs(60)).await {
347                self.0.store(true, Ordering::SeqCst);
348            }
349            Ok(())
350        }
351    }
352
353    #[tokio::test]
354    async fn stop_cancels_the_worker_and_joins_it() {
355        let stopped = Arc::new(AtomicBool::new(false));
356        let host = BackgroundServiceHost::new(Arc::new(Counting(Arc::clone(&stopped))));
357
358        host.start().await.expect("start must succeed");
359        tokio::time::sleep(Duration::from_millis(20)).await;
360
361        let started = Instant::now();
362        host.stop().await.expect("stop must succeed");
363        assert!(
364            started.elapsed() < Duration::from_millis(500),
365            "stop must interrupt the worker's sleep"
366        );
367        assert!(!stopped.load(Ordering::SeqCst), "the loop never ticked");
368
369        // Stopping twice is a no-op, so the host may stop on shutdown and again
370        // during teardown.
371        host.stop().await.expect("second stop must be a no-op");
372    }
373
374    struct Failing;
375
376    #[async_trait::async_trait]
377    impl BackgroundService for Failing {
378        async fn execute(&self, _ct: CancellationToken) -> Result<()> {
379            Err(Error::Internal("worker exploded".to_string()))
380        }
381    }
382
383    #[tokio::test]
384    async fn worker_failure_is_reported_as_a_typed_error_from_stop() {
385        let host = BackgroundServiceHost::new(Arc::new(Failing));
386        host.start().await.expect("start must succeed");
387        // Let `execute` fail before stop observes it.
388        tokio::time::sleep(Duration::from_millis(20)).await;
389
390        let err = host
391            .stop()
392            .await
393            .expect_err("failure must not be swallowed");
394        assert!(err.to_string().contains("worker exploded"), "{err}");
395    }
396
397    struct Panicking;
398
399    #[async_trait::async_trait]
400    impl BackgroundService for Panicking {
401        async fn execute(&self, _ct: CancellationToken) -> Result<()> {
402            panic!("boom");
403        }
404    }
405
406    #[tokio::test]
407    async fn worker_panic_becomes_a_typed_error_instead_of_propagating() {
408        let host = BackgroundServiceHost::new(Arc::new(Panicking));
409        host.start().await.expect("start must succeed");
410        tokio::time::sleep(Duration::from_millis(20)).await;
411
412        let err = host.stop().await.expect_err("panic must be a typed error");
413        assert!(
414            err.to_string().contains("background service panicked"),
415            "{err}"
416        );
417    }
418
419    struct Stubborn;
420
421    #[async_trait::async_trait]
422    impl BackgroundService for Stubborn {
423        async fn execute(&self, _ct: CancellationToken) -> Result<()> {
424            // Ignores cancellation entirely.
425            tokio::time::sleep(Duration::from_secs(600)).await;
426            Ok(())
427        }
428    }
429
430    #[tokio::test]
431    async fn a_worker_that_ignores_cancellation_is_aborted() {
432        let host = BackgroundServiceHost::new(Arc::new(Stubborn));
433        host.start().await.expect("start must succeed");
434
435        // Shorten nothing: `stop` waits STOP_GRACE. Keep the test honest but
436        // fast by only asserting that it eventually returns a typed error.
437        let result = tokio::time::timeout(Duration::from_secs(10), host.stop()).await;
438        let err = result
439            .expect("stop must not hang forever")
440            .expect_err("aborted worker is reported as an error");
441        assert!(err.to_string().contains("aborted"), "{err}");
442    }
443
444    #[tokio::test]
445    async fn idle_worker_stops_cleanly() {
446        let host = BackgroundServiceHost::new(Arc::new(Idle));
447        host.start().await.expect("start must succeed");
448        tokio::time::sleep(Duration::from_millis(20)).await;
449        host.stop().await.expect("a finished worker stops cleanly");
450    }
451
452    struct Ticker {
453        ticks: Arc<AtomicU32>,
454    }
455
456    #[async_trait::async_trait]
457    impl PeriodicBackgroundService for Ticker {
458        fn interval(&self) -> Duration {
459            Duration::from_millis(10)
460        }
461
462        async fn tick(&self) -> Result<()> {
463            self.ticks.fetch_add(1, Ordering::SeqCst);
464            Ok(())
465        }
466    }
467
468    #[tokio::test]
469    async fn periodic_worker_ticks_until_stopped() {
470        let ticks = Arc::new(AtomicU32::new(0));
471        let host = BackgroundServiceHost::new(Arc::new(Ticker {
472            ticks: Arc::clone(&ticks),
473        }));
474
475        host.start().await.expect("start must succeed");
476        // Wait for a couple of ticks with a bound instead of a fixed sleep.
477        let deadline = Instant::now() + Duration::from_secs(2);
478        while ticks.load(Ordering::SeqCst) < 2 && Instant::now() < deadline {
479            tokio::time::sleep(Duration::from_millis(5)).await;
480        }
481        assert!(
482            ticks.load(Ordering::SeqCst) >= 2,
483            "the periodic worker must tick, saw {}",
484            ticks.load(Ordering::SeqCst)
485        );
486
487        let started = Instant::now();
488        host.stop().await.expect("stop must succeed");
489        assert!(
490            started.elapsed() < Duration::from_millis(500),
491            "stop must not wait for the next interval"
492        );
493
494        // A completed stop joins the task: no tick may run afterwards.
495        let after_stop = ticks.load(Ordering::SeqCst);
496        tokio::time::sleep(Duration::from_millis(50)).await;
497        assert_eq!(ticks.load(Ordering::SeqCst), after_stop);
498    }
499
500    struct LongInterval(Arc<AtomicU32>);
501
502    #[async_trait::async_trait]
503    impl PeriodicBackgroundService for LongInterval {
504        fn interval(&self) -> Duration {
505            Duration::from_secs(60)
506        }
507
508        async fn tick(&self) -> Result<()> {
509            self.0.fetch_add(1, Ordering::SeqCst);
510            Ok(())
511        }
512    }
513
514    #[tokio::test]
515    async fn periodic_worker_shutdown_interrupts_the_interval_wait() {
516        let ticks = Arc::new(AtomicU32::new(0));
517        let host = BackgroundServiceHost::new(Arc::new(LongInterval(Arc::clone(&ticks))));
518        host.start().await.expect("start must succeed");
519        tokio::time::sleep(Duration::from_millis(30)).await;
520        assert_eq!(
521            ticks.load(Ordering::SeqCst),
522            0,
523            "first tick is one interval away"
524        );
525
526        let started = Instant::now();
527        host.stop().await.expect("stop must succeed");
528        assert!(
529            started.elapsed() < Duration::from_millis(500),
530            "the 60s interval must not delay shutdown, took {:?}",
531            started.elapsed()
532        );
533        assert_eq!(ticks.load(Ordering::SeqCst), 0);
534    }
535
536    struct FailingTicker {
537        ticks: Arc<AtomicU32>,
538        fatal: bool,
539    }
540
541    #[async_trait::async_trait]
542    impl PeriodicBackgroundService for FailingTicker {
543        fn interval(&self) -> Duration {
544            Duration::from_millis(5)
545        }
546
547        async fn tick(&self) -> Result<()> {
548            self.ticks.fetch_add(1, Ordering::SeqCst);
549            Err(Error::Internal("tick failed".to_string()))
550        }
551
552        fn stop_on_error(&self) -> bool {
553            self.fatal
554        }
555    }
556
557    #[tokio::test]
558    async fn a_failed_tick_is_logged_and_the_loop_continues() {
559        let ticks = Arc::new(AtomicU32::new(0));
560        let host = BackgroundServiceHost::new(Arc::new(FailingTicker {
561            ticks: Arc::clone(&ticks),
562            fatal: false,
563        }));
564        host.start().await.expect("start must succeed");
565
566        let deadline = Instant::now() + Duration::from_secs(2);
567        while ticks.load(Ordering::SeqCst) < 3 && Instant::now() < deadline {
568            tokio::time::sleep(Duration::from_millis(5)).await;
569        }
570        assert!(
571            ticks.load(Ordering::SeqCst) >= 3,
572            "a transient failure must not stop a poller"
573        );
574        // Stopping after a failed tick is clean: the error was handled.
575        host.stop().await.expect("stop must succeed");
576    }
577
578    #[tokio::test]
579    async fn stop_on_error_surfaces_the_typed_tick_error() {
580        let ticks = Arc::new(AtomicU32::new(0));
581        let host = BackgroundServiceHost::new(Arc::new(FailingTicker { ticks, fatal: true }));
582        host.start().await.expect("start must succeed");
583
584        // Let `execute` fail before stop observes it.
585        tokio::time::sleep(Duration::from_millis(50)).await;
586        let err = host
587            .stop()
588            .await
589            .expect_err("a fatal tick error must be reported");
590        assert!(err.to_string().contains("tick failed"), "{err}");
591    }
592
593    struct ZeroInterval;
594
595    #[async_trait::async_trait]
596    impl PeriodicBackgroundService for ZeroInterval {
597        fn interval(&self) -> Duration {
598            Duration::ZERO
599        }
600
601        async fn tick(&self) -> Result<()> {
602            Ok(())
603        }
604    }
605
606    #[tokio::test]
607    async fn zero_interval_is_a_typed_configuration_error() {
608        let host = BackgroundServiceHost::new(Arc::new(ZeroInterval));
609        host.start().await.expect("start must succeed");
610        tokio::time::sleep(Duration::from_millis(20)).await;
611
612        let err = host.stop().await.expect_err("zero interval must fail");
613        assert!(err.to_string().contains("greater than zero"), "{err}");
614    }
615}