Skip to main content

siderite_core/
background.rs

1//! Bounded process-local tasks authorized by successful endpoint responses.
2//!
3//! Tasks run sequentially within a request batch after the endpoint produces
4//! a 2xx response. Failed extraction, errors, cancellation and panic discard
5//! queued work. This boundary does not guarantee streaming-body delivery or
6//! client receipt; middleware may subsequently replace the response.
7//! Accepted work is tracked by the application's lifespan and drained before
8//! resource teardown within its shutdown deadline. Task panic is logged and
9//! later tasks in the batch continue. A crash still loses process-local work.
10//!
11//! # Durable queues
12//! [`TaskQueue`] is the interface for a durable backend. Redis / RabbitMQ
13//! adapters are not provided in this phase.
14
15use crate::error::ApiError;
16use crate::extract::FromRequestParts;
17use http::request::Parts;
18use serde::Serialize;
19use serde::de::DeserializeOwned;
20use std::future::Future;
21use std::pin::Pin;
22use thiserror::Error;
23use uuid::Uuid;
24
25mod manager;
26mod request;
27pub(crate) use manager::TaskManager;
28pub(crate) use request::RequestGuard;
29use request::RequestTasks;
30
31/// Limits for each app's process-local background queue.
32///
33/// Mounted apps have separate queues. Admission counts pending and running
34/// tasks, including batches awaiting an execution slot.
35#[derive(Debug, Clone, Copy)]
36pub struct BackgroundTaskLimits {
37    /// Maximum concurrently running request batches (default: 64).
38    pub max_active_batches: usize,
39    /// Maximum admitted tasks across requests (default: 1024).
40    pub max_tasks: usize,
41    /// Maximum tasks queued by one request (default: 100).
42    pub max_tasks_per_request: usize,
43}
44
45impl Default for BackgroundTaskLimits {
46    fn default() -> Self {
47        Self {
48            max_active_batches: 64,
49            max_tasks: 1024,
50            max_tasks_per_request: 100,
51        }
52    }
53}
54
55/// Rejection while queuing process-local work.
56#[derive(Debug, Error, PartialEq, Eq)]
57#[non_exhaustive]
58pub enum BackgroundTaskError {
59    /// The request or application is no longer accepting work.
60    #[error("background queue is closed")]
61    Closed,
62    /// A request or app admission limit has been reached.
63    #[error("background queue capacity reached")]
64    Capacity,
65    /// No endpoint request or Tokio runtime owns this work.
66    #[error("background execution is unavailable")]
67    Unavailable,
68}
69
70type LocalTask = Pin<Box<dyn Future<Output = ()> + Send>>;
71
72/// Collect bounded process-local work for a successful endpoint response.
73///
74/// Default-constructed instances are inert. Extract through a handler to
75/// obtain request ownership. Use [`Self::try_add`] to handle overload;
76/// [`Self::add`] logs a rejection and drops the rejected future.
77#[derive(Default)]
78pub struct BackgroundTasks {
79    queue: Option<RequestTasks>,
80}
81
82impl BackgroundTasks {
83    /// Queue work, logging and discarding it when admission is rejected.
84    ///
85    /// Args:
86    ///     fut: Future executed after a successful endpoint response.
87    ///
88    /// Returns:
89    ///     Nothing; use [`Self::try_add`] to inspect rejection.
90    pub fn add<F>(&mut self, fut: F)
91    where
92        F: Future<Output = ()> + Send + 'static,
93    {
94        if let Err(error) = self.try_add(fut) {
95            tracing::warn!(%error, "background task rejected");
96        }
97    }
98
99    /// Reserve bounded admission for work within this request.
100    ///
101    /// Args:
102    ///     fut: Future whose execution requires a successful endpoint.
103    ///
104    /// Returns:
105    ///     Success after admission; cancellation still discards queued work.
106    ///
107    /// # Errors
108    /// Closed request/app, unavailable ownership, or exhausted capacity.
109    pub fn try_add<F>(&mut self, fut: F) -> Result<(), BackgroundTaskError>
110    where
111        F: Future<Output = ()> + Send + 'static,
112    {
113        self.queue
114            .as_ref()
115            .ok_or(BackgroundTaskError::Unavailable)?
116            .add(Box::pin(fut))
117    }
118
119    /// Queue the future returned by `f`, with [`Self::add`] admission policy.
120    ///
121    /// Args:
122    ///     f: Factory called immediately to construct the queued future.
123    ///
124    /// Returns:
125    ///     Nothing; the future is discarded if admission is rejected.
126    pub fn add_fn<F, Fut>(&mut self, f: F)
127    where
128        F: FnOnce() -> Fut,
129        Fut: Future<Output = ()> + Send + 'static,
130    {
131        self.add(f());
132    }
133}
134
135impl FromRequestParts for BackgroundTasks {
136    const BACKGROUND_TASKS: bool = true;
137
138    async fn from_request_parts(parts: &mut Parts) -> Result<Self, ApiError> {
139        let queue = parts
140            .extensions
141            .get::<RequestTasks>()
142            .cloned()
143            .ok_or_else(|| {
144                let message = "background extractor needs endpoint ownership";
145                ApiError::internal(message)
146            })?;
147        Ok(Self { queue: Some(queue) })
148    }
149}
150
151impl crate::App {
152    /// Configure admission and execution limits for this app's task queue.
153    ///
154    /// Args:
155    ///     limits: Positive limits within Tokio semaphore capacity.
156    ///
157    /// Returns:
158    ///     Configured app; invalid limits become a configuration error.
159    #[must_use]
160    pub fn background_tasks(mut self, limits: BackgroundTaskLimits) -> Self {
161        let capacity = tokio::sync::Semaphore::MAX_PERMITS;
162        if limits.max_active_batches == 0
163            || limits.max_tasks == 0
164            || limits.max_tasks_per_request == 0
165            || limits.max_active_batches > capacity
166            || limits.max_tasks > capacity
167            || limits.max_tasks_per_request > limits.max_tasks
168        {
169            self.config_errors
170                .push("invalid background task limits".into());
171        } else {
172            self.background_limits = limits;
173        }
174        self
175    }
176}
177
178fn panic_message(payload: &(dyn std::any::Any + Send)) -> String {
179    if let Some(s) = payload.downcast_ref::<&str>() {
180        (*s).to_owned()
181    } else if let Some(s) = payload.downcast_ref::<String>() {
182        s.clone()
183    } else {
184        "Box<dyn Any>".to_owned()
185    }
186}
187
188/// Identifier of an enqueued durable task.
189#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
190pub struct TaskId(pub Uuid);
191
192impl std::fmt::Display for TaskId {
193    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
194        self.0.fmt(f)
195    }
196}
197
198/// A serializable unit of work for a [`TaskQueue`].
199///
200/// Redis / RabbitMQ adapters that persist and execute these types are added
201/// in a later phase.
202pub trait Task: Serialize + DeserializeOwned + Send + 'static {
203    /// Stable task type name stored alongside the payload.
204    const NAME: &'static str;
205}
206
207/// Failure to enqueue a durable task.
208#[derive(Debug, Error)]
209#[non_exhaustive]
210pub enum TaskQueueError {
211    /// The backend rejected or failed to persist the task.
212    #[error("failed to enqueue task: {0}")]
213    Enqueue(String),
214    /// The backend is unavailable.
215    #[error("task queue unavailable: {0}")]
216    Unavailable(String),
217}
218
219/// Durable task queue. Implementations (Redis, RabbitMQ, …) ship later.
220pub trait TaskQueue: Send + Sync {
221    /// Persist `task` and return its identifier.
222    fn enqueue<T: Task>(
223        &self,
224        task: T,
225    ) -> impl Future<Output = Result<TaskId, TaskQueueError>> + Send;
226}