Skip to main content

apalis_core/worker/
lifecycle.rs

1//! Task lifecycle tracking.
2//!
3//! This module provides middleware for tracking the lifecycle of tasks as they
4//! are processed by a worker. The [`LifecycleLayer`] registers tasks with the
5//! worker, tracks their execution state, records attempts, and removes them
6//! from the worker when processing completes.
7//!
8//! The lifecycle service also handles task cancellation and maps task results
9//! to their corresponding [`Status`] values.
10use std::{
11    pin::Pin,
12    sync::atomic::Ordering,
13    task::{Context, Poll},
14};
15
16use futures_util::FutureExt;
17use tower_layer::Layer;
18use tower_service::Service;
19
20use crate::{
21    error::{AbortError, BoxDynError, DeferredError, RetryAfterError},
22    task::{
23        Task,
24        attempt::Attempt,
25        status::{AtomicStatus, Status},
26    },
27    worker::context::WorkerContext,
28};
29
30#[cfg(feature = "task-context")]
31use crate::task::context::{TaskContext, TaskStateError};
32
33/// A layer that tracks the lifecycle of tasks processed by a worker.
34///
35/// The layer registers each task with the [`WorkerContext`] and wraps the
36/// resulting task future so that its lifecycle state can be updated as the
37/// future progresses.
38#[derive(Debug, Clone)]
39pub struct LifecycleLayer {
40    worker: WorkerContext,
41}
42
43impl LifecycleLayer {
44    pub(super) fn new(worker: WorkerContext) -> Self {
45        Self { worker }
46    }
47}
48
49impl<S> Layer<S> for LifecycleLayer {
50    type Service = LifecycleService<S>;
51
52    fn layer(&self, service: S) -> Self::Service {
53        LifecycleService {
54            worker: self.worker.clone(),
55            service,
56        }
57    }
58}
59
60/// A service that tracks the lifecycle of a task.
61///
62/// `LifecycleService` registers tasks with the worker before processing and
63/// wraps the underlying service future in a [`LifecycleFuture`] to track task
64/// state and completion.
65#[derive(Debug, Clone)]
66pub struct LifecycleService<S> {
67    worker: WorkerContext,
68    service: S,
69}
70
71impl<S, Args> Service<Task<Args>> for LifecycleService<S>
72where
73    S: Service<Task<Args>>,
74    S::Error: Into<BoxDynError>,
75{
76    type Response = S::Response;
77    type Error = BoxDynError;
78    type Future = LifecycleFuture<S::Future>;
79
80    fn poll_ready(&mut self, cx: &mut std::task::Context<'_>) -> Poll<Result<(), Self::Error>> {
81        let result = self.service.poll_ready(cx).map_err(Into::into);
82        if self.worker.is_shutting_down() || self.worker.is_paused() || result.is_pending() {
83            self.worker.is_ready.store(false, Ordering::SeqCst);
84            return Poll::Pending;
85        }
86
87        match &result {
88            Poll::Ready(Ok(_)) => self.worker.is_ready.store(true, Ordering::SeqCst),
89            Poll::Pending | Poll::Ready(Err(_)) => {
90                self.worker.is_ready.store(false, Ordering::SeqCst)
91            }
92        }
93
94        result
95    }
96
97    fn call(&mut self, task: Task<Args>) -> Self::Future {
98        let attempt = task.raw_attempt().clone();
99        let status = task.raw_status().clone();
100        #[allow(unused, clippy::let_unit_value)]
101        let task_ctx = self
102            .worker
103            .register_task(task.ctx())
104            .expect("Could not register task");
105
106        LifecycleFuture {
107            fut: self.service.call(task),
108            worker: self.worker.clone(),
109            #[cfg(feature = "task-context")]
110            task_ctx,
111            #[cfg(not(feature = "task-context"))]
112            task_ctx: (),
113            record_attempt: Some(attempt),
114            task_status: status,
115        }
116    }
117}
118
119/// A future that tracks the lifecycle of a task.
120///
121/// The future updates the task status when processing begins, handles
122/// cancellation, and records the final task status when processing completes.
123#[pin_project::pin_project(PinnedDrop)]
124#[derive(Debug)]
125pub struct LifecycleFuture<Fut> {
126    #[pin]
127    fut: Fut,
128    #[cfg(feature = "task-context")]
129    task_ctx: TaskContext,
130    #[cfg(not(feature = "task-context"))]
131    task_ctx: (),
132    worker: WorkerContext,
133    record_attempt: Option<Attempt>,
134    task_status: AtomicStatus,
135}
136
137/// Errors that can occur during the lifecycle of a task.
138///
139/// These errors indicate that a task could not transition through its
140/// lifecycle as requested.
141#[derive(Debug, thiserror::Error)]
142#[non_exhaustive]
143pub enum TaskLifecycleError {
144    /// The task does not have an identifier.
145    ///
146    /// A task identifier is required for lifecycle operations that need to
147    /// track or associate the task with its execution state.
148    #[error("task doesn't have an id")]
149    MissingTaskId,
150
151    /// The task already has a token associated with it.
152    ///
153    /// A task can only have one lifecycle token. Attempting to assign another
154    /// token results in this error.
155    #[error("task already has a token")]
156    Duplicate,
157
158    #[cfg(feature = "task-context")]
159    /// The task exited after it was manually canceled.
160    ///
161    /// Contains the [`TaskContext`] associated with the task at the time it
162    /// exited.
163    #[error("task exited after manually being canceled")]
164    Exit(TaskContext),
165
166    #[cfg(feature = "task-context")]
167    /// The task is in an invalid state for the requested lifecycle operation.
168    ///
169    /// Contains the underlying [`TaskStateError`] describing the invalid
170    /// state transition.
171    #[error("task state is invalid: {0}")]
172    StateError(#[from] TaskStateError),
173}
174
175impl<Fut, T, E> Future for LifecycleFuture<Fut>
176where
177    Fut: Future<Output = Result<T, E>>,
178    E: Into<BoxDynError>,
179{
180    type Output = Result<T, BoxDynError>;
181
182    fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output> {
183        let mut this = self.project();
184        if let Some(attempt) = this.record_attempt.take() {
185            let current = attempt.increment();
186            this.task_status.store(Status::Running);
187            trace!("task started with prev attempts: {}", current);
188        }
189
190        #[cfg(feature = "task-context")]
191        if this.task_ctx.is_cancelled() {
192            trace!("task.cancelled");
193            this.task_status.store(Status::Killed);
194            return Poll::Ready(Err(Box::new(AbortError::new(TaskLifecycleError::Exit(
195                this.task_ctx.clone(),
196            )))));
197        }
198
199        match this.fut.poll_unpin(cx).map(|res| res.map_err(Into::into)) {
200            Poll::Pending => Poll::Pending,
201            Poll::Ready(res) => {
202                let new_status = match res {
203                    Ok(_) => Status::Done,
204                    Err(ref e) => match e {
205                        e if e.downcast_ref::<AbortError>().is_some() => Status::Killed,
206                        e if (e.downcast_ref::<RetryAfterError>().is_some()
207                            || e.downcast_ref::<DeferredError>().is_some()) =>
208                        {
209                            Status::Pending
210                        }
211                        _ => Status::Failed,
212                    },
213                };
214
215                #[cfg(feature = "task-context")]
216                if let Err(e) = this.task_ctx.complete() {
217                    this.task_status.store(Status::Killed);
218                    return Poll::Ready(Err(e.into()));
219                }
220                this.task_status.store(new_status);
221                Poll::Ready(res)
222            }
223        }
224    }
225}
226
227#[pin_project::pinned_drop]
228impl<Fut> PinnedDrop for LifecycleFuture<Fut> {
229    fn drop(self: Pin<&mut Self>) {
230        let this = self.project();
231        if this.worker.remove_task(this.task_ctx).is_err() {
232            warn!("Dropped a task without removing it from the worker's context");
233        }
234        if this.worker.task_count() == 0 {
235            this.worker.wake();
236        }
237    }
238}