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}