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