Skip to main content

apalis_core/task/
context.rs

1//! Task execution context and lifecycle tracking.
2//!
3//! This module provides [`TaskContext`], a lightweight handle for interacting
4//! with a task while it is being executed. A task context is associated with a
5//! single task and can be cloned and shared with sub-tasks.
6//!
7//! Unlike [`WorkerContext`], which represents state shared by all tasks running
8//! on a worker, [`TaskContext`] represents the lifecycle of one task. It can be
9//! used to request cancellation, observe completion, wait for execution to
10//! finish, access the task's [`ExecutionContext`], and track sub-tasks.
11//!
12//! A task context is considered executed once the task has either completed or
13//! been cancelled. Sub-tasks spawned through [`TaskContext::run_until_executed`]
14//! are tied to their parent context and are tracked until they complete.
15//!
16//! # Lifecycle
17//!
18//! A task context progresses through the following states:
19//!
20//! ```text
21//! Running
22//!    ├── cancel() ──> Cancelled
23//!    └── complete() ─> Completed
24//! ```
25//!
26//! Once a task reaches a terminal state, subsequent attempts to cancel or
27//! complete it return a [`TaskStateError`].
28//!
29//! # Waiting for execution
30//!
31//! [`TaskContext::executed`] returns a future that resolves when the task is
32//! either completed or cancelled. The future registers a waker and therefore
33//! does not require polling in a loop.
34//!
35//! # Sub-tasks
36//!
37//! [`TaskContext::run_until_executed`] can be used to associate additional
38//! asynchronous work with a task. A sub-task runs until it completes or its
39//! parent context is executed. Sub-tasks are counted by their parent context,
40//! allowing task execution to account for work spawned from the task.
41//!
42//! # Task state errors
43//!
44//! [`TaskStateError`] describes failures when transitioning a task between
45//! lifecycle states. [`TerminalState`] identifies the terminal state that
46//! prevented the requested transition.
47use futures_util::task::AtomicWaker;
48use std::{
49    borrow::Borrow,
50    fmt,
51    hash::{Hash, Hasher},
52    pin::Pin,
53    sync::{
54        Arc, Weak,
55        atomic::{AtomicUsize, Ordering},
56    },
57    task::{Context, Poll},
58    time::{Duration, Instant},
59};
60
61use crate::{
62    task::{ExecutionContext, Task, from_request::FromRequest},
63    worker::context::WorkerContext,
64};
65
66const RUNNING: usize = 0;
67const CANCELLED: usize = 1; // Task was cancelled during polling
68const COMPLETED: usize = 2; // Task was completed via polling
69
70/// The context for a task, which can be used to cancel the task or spawn sub-tasks.
71///
72/// Unlike [`WorkerContext`], which is shared across all tasks,
73/// a [`TaskContext`] is unique to a single task and can be cloned to share with sub-tasks.
74/// A task context is considered "executed" when the task has successfully completed or has been cancelled, and all sub-tasks have also completed.
75#[derive(Clone, Debug)]
76pub struct TaskContext {
77    task_id: Arc<str>,
78    state: Arc<AtomicUsize>,
79    /// Wakes any pending [`WaitForExecutionFuture`] future when `complete() or cancel()` is called.
80    waker: Arc<AtomicWaker>,
81    instant: Instant,
82    inner: Weak<ExecutionContext>,
83    /// The number of sub-tasks currently spawned by this task context.
84    sub_tasks_count: Arc<AtomicUsize>,
85}
86
87impl TaskContext {
88    /// Builds a new task context from the given [`ExecutionContext`].
89    ///
90    /// # Panics
91    ///
92    /// Panics if `ctx.task_id` is `None`; every execution context driving
93    /// a task is expected to carry one.
94    #[must_use]
95    pub(crate) fn new(ctx: &Arc<ExecutionContext>) -> Self {
96        Self {
97            task_id: ctx
98                .task_id
99                .as_ref()
100                .expect("A task id must be included")
101                .to_string()
102                .into(),
103            state: Arc::new(AtomicUsize::new(RUNNING)),
104            waker: Arc::new(AtomicWaker::new()),
105            instant: Instant::now(),
106            inner: Arc::downgrade(ctx),
107            sub_tasks_count: Arc::new(AtomicUsize::new(0)),
108        }
109    }
110
111    /// Requests cancellation of the task.
112    ///
113    /// Wakes any pending [WaitForExecutionFuture] futures waiting for execution to complete
114    ///
115    /// # Errors
116    ///
117    /// Returns an error if the task had already reached a terminal state
118    /// (already cancelled or already completed).
119    pub fn cancel(&self) -> Result<(), TaskStateError> {
120        self.state
121            .compare_exchange(RUNNING, CANCELLED, Ordering::AcqRel, Ordering::Acquire)
122            .map_err(|prev| {
123                TaskStateError::AlreadyExecuted(match prev {
124                    CANCELLED => TerminalState::Cancelled,
125                    COMPLETED => TerminalState::Completed,
126                    other => unreachable!("unexpected task state: {other}"),
127                })
128            })?;
129        self.waker.wake();
130        Ok(())
131    }
132
133    /// Marks the task as completed.
134    ///
135    /// Wakes any pending [WaitForExecutionFuture] futures.
136    ///
137    /// # Errors
138    ///
139    /// Returns an error if the task had already reached a terminal state
140    /// (already cancelled or already completed).
141    pub(crate) fn complete(&self) -> Result<(), TaskStateError> {
142        self.state
143            .compare_exchange(RUNNING, COMPLETED, Ordering::AcqRel, Ordering::Acquire)
144            .map_err(|prev| {
145                TaskStateError::AlreadyTerminated(match prev {
146                    CANCELLED => TerminalState::Cancelled,
147                    COMPLETED => TerminalState::Completed,
148                    other => unreachable!("unexpected task state: {other}"),
149                })
150            })?;
151        self.waker.wake();
152        Ok(())
153    }
154
155    /// Returns whether the task has been completed.
156    #[must_use]
157    pub fn is_completed(&self) -> bool {
158        self.state.load(Ordering::Acquire) == COMPLETED
159    }
160
161    /// Returns whether cancellation has been requested.
162    #[must_use]
163    pub fn is_cancelled(&self) -> bool {
164        self.state.load(Ordering::Acquire) == CANCELLED
165    }
166
167    /// Returns whether there is nothing left to wait on:
168    ///
169    /// If true the task has either completed or been cancelled.
170    #[must_use]
171    pub fn is_executed(&self) -> bool {
172        self.state.load(Ordering::Acquire) != RUNNING
173    }
174
175    /// Returns a future that resolves once the task is executed — either
176    /// completed or cancelled.
177    ///
178    /// Can be awaited standalone or raced against other futures, e.g.:
179    ///
180    /// ```ignore
181    /// futures_util::select! {
182    ///     _ = ctx.executed().fuse() => { /* task done */ }
183    ///     res = some_work.fuse() => { /* completed with `res` */ }
184    /// }
185    /// ```
186    pub fn executed(&self) -> WaitForExecutionFuture {
187        WaitForExecutionFuture {
188            inner: self.clone(),
189        }
190    }
191
192    /// Returns how long the task has been running.
193    #[must_use]
194    pub fn elapsed(&self) -> Duration {
195        self.instant.elapsed()
196    }
197
198    /// Returns the task id for this token.
199    #[must_use]
200    pub fn task_id(&self) -> &str {
201        &self.task_id
202    }
203
204    /// Recovers the execution context for the task, if it's still available.
205    ///
206    /// Returns `None` once the owning [`ExecutionContext`] has been dropped.
207    #[must_use]
208    pub fn execution_context(&self) -> Option<Arc<ExecutionContext>> {
209        self.inner.upgrade()
210    }
211
212    fn start_task(&self) {
213        self.sub_tasks_count.fetch_add(1, Ordering::Relaxed);
214    }
215
216    fn end_task(&self) {
217        self.sub_tasks_count.fetch_sub(1, Ordering::Relaxed);
218    }
219
220    /// Returns the number of sub-tasks currently spawned by this task context.
221    #[must_use]
222    pub fn len(&self) -> usize {
223        self.sub_tasks_count.load(Ordering::Relaxed)
224    }
225
226    /// Returns whether there are any subtasks running.
227    #[must_use]
228    pub fn is_empty(&self) -> bool {
229        self.len() == 0
230    }
231
232    /// Spawns a (sub-task) future that is tied to this task context.
233    ///
234    /// Runs a future to completion, returning its result unless this
235    /// [`TaskContext`] is completed first.
236    ///
237    /// Biased towards completion: if the future resolves and the task
238    /// is completed in the same poll, the future's result wins.
239    pub fn run_until_executed<F>(&self, fut: F) -> SubTaskFuture<F>
240    where
241        F: Future,
242    {
243        self.start_task();
244        SubTaskFuture {
245            parent: self.clone(),
246            future: fut,
247        }
248    }
249}
250
251/// Errors that can occur when transitioning a [`TaskContext`]'s lifecycle state.
252#[derive(Debug, thiserror::Error, Clone, Copy, PartialEq, Eq)]
253#[non_exhaustive]
254pub enum TaskStateError {
255    /// The task could not be cancelled because it had already reached a
256    /// terminal state.
257    #[error("task cannot be cancelled: already {0}")]
258    AlreadyExecuted(TerminalState),
259
260    /// The task could not be marked completed because it had already
261    /// reached a terminal state.
262    #[error("task cannot be completed: already {0}")]
263    AlreadyTerminated(TerminalState),
264
265    /// Task could not be found
266    #[error("task cannot be found")]
267    TaskNotFound,
268}
269
270/// The terminal state a task had already reached, when a state transition fails.
271#[derive(Debug, Clone, Copy, PartialEq, Eq)]
272#[non_exhaustive]
273pub enum TerminalState {
274    /// Task already canceled
275    Cancelled,
276    /// Task already completed
277    Completed,
278}
279
280impl fmt::Display for TerminalState {
281    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
282        match self {
283            Self::Cancelled => write!(f, "cancelled"),
284            Self::Completed => write!(f, "completed"),
285        }
286    }
287}
288
289/// A future that resolves once its parent [`TaskContext`] is executed —
290/// either completed or cancelled.
291///
292/// Unlike polling [`TaskContext::is_executed`] in a loop, this future
293/// registers a waker so it can be awaited on its own — e.g. raced against
294/// other work with `select!` — without needing another future to drive it.
295#[must_use = "futures do nothing unless you `.await` or poll them"]
296#[derive(Debug)]
297pub struct WaitForExecutionFuture {
298    inner: TaskContext,
299}
300
301impl Future for WaitForExecutionFuture {
302    type Output = ();
303
304    fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output> {
305        if self.inner.is_executed() {
306            return Poll::Ready(());
307        }
308        self.inner.waker.register(cx.waker());
309        if self.inner.is_executed() {
310            Poll::Ready(())
311        } else {
312            Poll::Pending
313        }
314    }
315}
316
317/// A future that runs a sub-task tied to a [`TaskContext`].
318///
319/// The sub-task is cancelled if the parent task context is completed before
320/// the future resolves. The sub-task is also tracked by the parent context,
321/// and the parent context will not be considered complete until all sub-tasks
322/// have completed.
323#[must_use = "futures do nothing unless polled"]
324#[pin_project::pin_project(PinnedDrop)]
325#[derive(Debug)]
326pub struct SubTaskFuture<F: Future> {
327    parent: TaskContext,
328    #[pin]
329    future: F,
330}
331
332#[pin_project::pinned_drop]
333impl<F: Future> PinnedDrop for SubTaskFuture<F> {
334    fn drop(self: Pin<&mut Self>) {
335        self.parent.end_task();
336    }
337}
338
339impl<F: Future> Future for SubTaskFuture<F> {
340    type Output = Option<F::Output>;
341
342    fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output> {
343        let this = self.project();
344        if let Poll::Ready(res) = this.future.poll(cx) {
345            Poll::Ready(Some(res))
346        } else if this.parent.is_executed() {
347            Poll::Ready(None)
348        } else {
349            Poll::Pending
350        }
351    }
352}
353
354impl PartialEq for TaskContext {
355    fn eq(&self, other: &Self) -> bool {
356        self.task_id() == other.task_id()
357    }
358}
359impl Eq for TaskContext {}
360
361impl Hash for TaskContext {
362    fn hash<H: Hasher>(&self, state: &mut H) {
363        self.task_id().hash(state);
364    }
365}
366
367impl Borrow<str> for TaskContext {
368    fn borrow(&self) -> &str {
369        &self.task_id
370    }
371}
372
373impl<Args: Sync> FromRequest<Task<Args>> for TaskContext {
374    type Error = TaskStateError;
375    async fn from_request(task: &Task<Args>) -> Result<Self, Self::Error> {
376        let worker: &WorkerContext = task
377            .data()
378            .get_checked()
379            .map_err(|_| TaskStateError::TaskNotFound)?;
380        let token = worker.get_task_context(&task.ctx)?;
381        Ok(token)
382    }
383}