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}