Skip to main content

boson_core/models/
task_config.rs

1//! Task config model — priority, pool, retry, rate limits, and idempotency.
2//!
3//! [`TaskConfig`] is persisted per task. On enqueue, Boson merges descriptor defaults with
4//! any stored config to set [`Job`](crate::Job) priority, pool, and policies.
5
6use chrono::{DateTime, Utc};
7use serde::{Deserialize, Serialize};
8
9/// How backends enforce enqueue idempotency when a job key is present.
10///
11/// On Scylla, [`IdempotencyMode::Lwt`] uses a lightweight transaction; [`IdempotencyMode::None`]
12/// skips that path (at-least-once). Mem/SQL honor the same policy for check-then-reuse.
13#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
14#[serde(rename_all = "snake_case")]
15pub enum IdempotencyMode {
16    /// Exactly-once under concurrent enqueue (default).
17    #[default]
18    Lwt,
19    /// At-least-once; skip idempotency coordination (higher throughput).
20    None,
21}
22
23impl IdempotencyMode {
24    /// Parse from a config string (`lwt` / `none`).
25    #[must_use]
26    pub fn parse(s: &str) -> Option<Self> {
27        match s.trim().to_ascii_lowercase().as_str() {
28            "lwt" => Some(Self::Lwt),
29            "none" => Some(Self::None),
30            _ => None,
31        }
32    }
33}
34
35/// Retry policy for a task.
36///
37/// On handler failure, the worker reschedules while `job.attempt < max_attempts`.
38/// Delay before the next attempt (milliseconds):
39///
40/// `min(base_delay_ms × backoff_multiplier^(attempt - 1), max_delay_ms)`
41///
42/// where `attempt` is the 1-based attempt number on the job.
43#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize)]
44pub struct RetryPolicy {
45    /// Maximum retry attempts after the first failure (`0` = no retries).
46    pub max_attempts: u32,
47    /// Base delay in ms before the first retry.
48    pub base_delay_ms: u64,
49    /// Exponential backoff multiplier applied per attempt.
50    pub backoff_multiplier: f64,
51    /// Upper cap on retry delay in ms.
52    pub max_delay_ms: u64,
53}
54
55/// Rate limiting for enqueue backpressure.
56///
57/// `max_in_flight == 0` and `max_enqueue_per_second == 0` mean unlimited (default).
58///
59/// When a limit is exceeded, [`Boson::enqueue`](https://docs.rs/boson-runtime/latest/boson_runtime/struct.Boson.html#method.enqueue) returns
60/// [`BosonError::RateLimited`](crate::BosonError::RateLimited); callers should retry with backoff.
61///
62/// # Example
63///
64/// ```rust
65/// use boson_core::RateLimitPolicy;
66///
67/// // At most 10 active jobs and 5 enqueues per second for one task.
68/// let policy = RateLimitPolicy {
69///     max_in_flight: 10,
70///     max_enqueue_per_second: 5,
71/// };
72/// ```
73#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize)]
74pub struct RateLimitPolicy {
75    /// Max jobs for this task in `queued` + `running` at once. `0` = no limit.
76    pub max_in_flight: u32,
77    /// Max successful enqueues per wall-clock second per process. `0` = no limit.
78    pub max_enqueue_per_second: u32,
79}
80
81/// Per-task config persisted for admin UI and enqueue defaults.
82///
83/// Seeded from [`task`](https://docs.rs/boson-macros) macro attributes on first enqueue; admins can
84/// later upsert via [`QueueBackend::upsert_task_config`](crate::QueueBackend::upsert_task_config)
85/// or the axum `/tasks/{name}/config` route. On enqueue, Boson merges descriptor defaults with any
86/// stored config to set [`Job`](crate::Job) priority, pool, and policies.
87///
88/// Getting started: [define tasks](https://docs.rs/uf-boson/latest/boson/index.html#3-define-tasks).
89///
90/// # Example — macro attrs become the initial config
91///
92/// ```ignore
93/// use boson::task;
94///
95/// #[task(
96///     name = "notify",
97///     priority = 10,
98///     pool = "alerts",
99///     max_attempts = 5,
100///     max_in_flight = 20
101/// )]
102/// async fn notify(ctx: Box<dyn boson::ExecutionContext>, message: String) -> boson_core::Result<()> {
103///     let _ = (ctx, message);
104///     Ok(())
105/// }
106/// // First Notify::send_with(...) persists a TaskConfig shaped like those attributes.
107/// ```
108#[derive(Debug, Clone, Serialize, Deserialize)]
109pub struct TaskConfig {
110    /// Task name (unique key).
111    pub task_name: String,
112    /// Override priority (lower = higher priority).
113    pub priority: i32,
114    /// Override pool name.
115    pub pool: String,
116    /// Retry policy override.
117    pub retry_policy: RetryPolicy,
118    /// Enqueue rate limits (optional).
119    pub rate_limit_policy: RateLimitPolicy,
120    /// Per-task idempotency override (`None` = inherit runtime / builder default).
121    #[serde(default, skip_serializing_if = "Option::is_none")]
122    pub idempotency_mode: Option<IdempotencyMode>,
123    /// When created/updated.
124    pub updated_at: DateTime<Utc>,
125}
126
127impl TaskConfig {
128    /// Create default config for a task name.
129    ///
130    /// # Examples
131    ///
132    /// ```
133    /// use boson_core::TaskConfig;
134    ///
135    /// let config = TaskConfig::default_for("notify");
136    /// assert_eq!(config.task_name, "notify");
137    /// assert_eq!(config.pool, "global");
138    /// assert_eq!(config.priority, 1);
139    /// ```
140    #[must_use]
141    pub fn default_for(task_name: &str) -> Self {
142        Self {
143            task_name: task_name.to_string(),
144            priority: 1,
145            pool: "global".to_string(),
146            retry_policy: RetryPolicy::default(),
147            rate_limit_policy: RateLimitPolicy::default(),
148            idempotency_mode: None,
149            updated_at: Utc::now(),
150        }
151    }
152
153    /// Resolve effective idempotency mode (task override, else `runtime_default`).
154    #[must_use]
155    pub fn resolved_idempotency_mode(&self, runtime_default: IdempotencyMode) -> IdempotencyMode {
156        self.idempotency_mode.unwrap_or(runtime_default)
157    }
158
159    /// Build config from explicit policy defaults (typically from a task descriptor).
160    #[must_use]
161    pub fn from_policy_defaults(
162        task_name: &str,
163        priority: i32,
164        pool: impl Into<String>,
165        retry_policy: RetryPolicy,
166        rate_limit_policy: RateLimitPolicy,
167        idempotency_mode: Option<IdempotencyMode>,
168    ) -> Self {
169        Self {
170            task_name: task_name.to_string(),
171            priority,
172            pool: pool.into(),
173            retry_policy,
174            rate_limit_policy,
175            idempotency_mode,
176            updated_at: Utc::now(),
177        }
178    }
179
180    /// Fill [`idempotency_mode`](Self::idempotency_mode) from the runtime default when unset.
181    #[must_use]
182    pub const fn with_runtime_idempotency_fallback(
183        mut self,
184        runtime_default: IdempotencyMode,
185    ) -> Self {
186        if self.idempotency_mode.is_none() {
187            self.idempotency_mode = Some(runtime_default);
188        }
189        self
190    }
191}
192
193#[cfg(test)]
194mod tests {
195    use super::*;
196
197    #[test]
198    fn from_policy_defaults_sets_fields() {
199        let config = TaskConfig::from_policy_defaults(
200            "notify",
201            2,
202            "urgent",
203            RetryPolicy {
204                max_attempts: 5,
205                base_delay_ms: 100,
206                backoff_multiplier: 2.0,
207                max_delay_ms: 1000,
208            },
209            RateLimitPolicy {
210                max_in_flight: 10,
211                max_enqueue_per_second: 3,
212            },
213            Some(IdempotencyMode::None),
214        );
215        assert_eq!(config.task_name, "notify");
216        assert_eq!(config.priority, 2);
217        assert_eq!(config.pool, "urgent");
218        assert_eq!(config.retry_policy.max_attempts, 5);
219        assert_eq!(config.rate_limit_policy.max_in_flight, 10);
220        assert_eq!(config.idempotency_mode, Some(IdempotencyMode::None));
221    }
222
223    #[test]
224    fn runtime_idempotency_fallback_fills_none_only() {
225        let filled = TaskConfig::default_for("t").with_runtime_idempotency_fallback(IdempotencyMode::Lwt);
226        assert_eq!(filled.idempotency_mode, Some(IdempotencyMode::Lwt));
227
228        let kept = TaskConfig::default_for("t");
229        let mut kept = kept;
230        kept.idempotency_mode = Some(IdempotencyMode::None);
231        let kept = kept.with_runtime_idempotency_fallback(IdempotencyMode::Lwt);
232        assert_eq!(kept.idempotency_mode, Some(IdempotencyMode::None));
233    }
234}