Skip to main content

reinhardt_tasks/
settings.rs

1//! Settings fragments for task queues, workers, webhooks, and broker backends.
2//!
3//! These fragments are the settings-first configuration entry points for the
4//! task system. Each maps to a `[tasks_*]` TOML section and can be composed
5//! into a project's settings with the `#[settings]` macro. Conversions into the
6//! deprecated compatibility `XxxConfig` types are provided for the migration
7//! window; new code should prefer the fragments and the
8//! `create_*_from_settings` constructors.
9
10#![allow(deprecated)] // Conversions target legacy config types during the compatibility window.
11
12use std::collections::HashMap;
13use std::time::Duration;
14
15use reinhardt_core::macros::settings;
16use serde::{Deserialize, Serialize};
17
18use crate::webhook::{HttpWebhookSender, RetryConfig, WebhookConfig};
19use crate::worker::{Worker, WorkerConfig};
20
21// --- defaults -------------------------------------------------------------
22
23fn default_queue_name() -> String {
24	"default".to_string()
25}
26fn default_max_retries() -> u32 {
27	3
28}
29fn default_worker_name() -> String {
30	"worker".to_string()
31}
32fn default_concurrency() -> usize {
33	4
34}
35fn default_poll_interval_ms() -> u64 {
36	1000
37}
38fn default_webhook_method() -> String {
39	"POST".to_string()
40}
41fn default_webhook_timeout_secs() -> u64 {
42	5
43}
44fn default_retry_max_retries() -> u32 {
45	3
46}
47fn default_retry_initial_backoff_ms() -> u64 {
48	100
49}
50fn default_retry_max_backoff_ms() -> u64 {
51	30_000
52}
53fn default_retry_backoff_multiplier() -> f64 {
54	2.0
55}
56
57// --- queue ----------------------------------------------------------------
58
59/// Task queue settings fragment.
60///
61/// Maps to the `[tasks_queue]` section. This fragment defines the queue
62/// configuration section; applying `name` / `max_retries` to a running
63/// queue is deferred to the post-deprecation queue model, because the
64/// current `TaskQueue` is a stateless, zero-sized delegator. See
65/// reinhardt-web#5068 for the rationale.
66#[settings(fragment = true, section = "tasks_queue")]
67#[non_exhaustive]
68#[derive(Clone, Debug, Serialize, Deserialize)]
69pub struct QueueSettings {
70	/// The name of the queue.
71	#[serde(default = "default_queue_name")]
72	pub name: String,
73	/// Maximum number of retry attempts for failed tasks.
74	#[serde(default = "default_max_retries")]
75	pub max_retries: u32,
76}
77
78impl Default for QueueSettings {
79	fn default() -> Self {
80		Self {
81			name: default_queue_name(),
82			max_retries: default_max_retries(),
83		}
84	}
85}
86
87// --- webhook --------------------------------------------------------------
88
89/// Retry policy value object embedded in [`WebhookSettings`].
90///
91/// This is not an independently loadable section; it is nested under
92/// `[tasks_webhook.retry]`.
93#[settings(fragment = true)]
94#[derive(Clone, Debug, Serialize, Deserialize)]
95pub struct WebhookRetrySettings {
96	/// Maximum number of retry attempts.
97	#[serde(default = "default_retry_max_retries")]
98	pub max_retries: u32,
99	/// Initial backoff between retries, in milliseconds.
100	#[serde(default = "default_retry_initial_backoff_ms")]
101	pub initial_backoff_ms: u64,
102	/// Maximum backoff between retries, in milliseconds.
103	#[serde(default = "default_retry_max_backoff_ms")]
104	pub max_backoff_ms: u64,
105	/// Backoff multiplier for exponential backoff.
106	#[serde(default = "default_retry_backoff_multiplier")]
107	pub backoff_multiplier: f64,
108}
109
110impl Default for WebhookRetrySettings {
111	fn default() -> Self {
112		Self {
113			max_retries: default_retry_max_retries(),
114			initial_backoff_ms: default_retry_initial_backoff_ms(),
115			max_backoff_ms: default_retry_max_backoff_ms(),
116			backoff_multiplier: default_retry_backoff_multiplier(),
117		}
118	}
119}
120
121impl From<&WebhookRetrySettings> for RetryConfig {
122	fn from(settings: &WebhookRetrySettings) -> Self {
123		Self {
124			max_retries: settings.max_retries,
125			initial_backoff: Duration::from_millis(settings.initial_backoff_ms),
126			max_backoff: Duration::from_millis(settings.max_backoff_ms),
127			backoff_multiplier: settings.backoff_multiplier,
128		}
129	}
130}
131
132/// Webhook delivery settings fragment.
133///
134/// Maps to the `[tasks_webhook]` section.
135#[settings(fragment = true, section = "tasks_webhook")]
136#[non_exhaustive]
137#[derive(Clone, Debug, Serialize, Deserialize)]
138pub struct WebhookSettings {
139	/// Target URL for webhook delivery.
140	#[serde(default)]
141	pub url: String,
142	/// HTTP method to use.
143	#[serde(default = "default_webhook_method")]
144	pub method: String,
145	/// Additional headers to include with each request.
146	#[serde(default)]
147	pub headers: HashMap<String, String>,
148	/// Request timeout, in seconds.
149	#[serde(default = "default_webhook_timeout_secs")]
150	pub timeout_secs: u64,
151	/// Retry policy.
152	#[setting(node)]
153	#[serde(default)]
154	pub retry: WebhookRetrySettings,
155}
156
157impl Default for WebhookSettings {
158	fn default() -> Self {
159		Self {
160			url: String::new(),
161			method: default_webhook_method(),
162			headers: HashMap::new(),
163			timeout_secs: default_webhook_timeout_secs(),
164			retry: WebhookRetrySettings::default(),
165		}
166	}
167}
168
169impl From<&WebhookSettings> for WebhookConfig {
170	fn from(settings: &WebhookSettings) -> Self {
171		Self {
172			url: settings.url.clone(),
173			method: settings.method.clone(),
174			headers: settings.headers.clone(),
175			timeout: Duration::from_secs(settings.timeout_secs),
176			retry_config: RetryConfig::from(&settings.retry),
177		}
178	}
179}
180
181/// Build an [`HttpWebhookSender`] from a [`WebhookSettings`] fragment.
182pub fn create_webhook_sender_from_settings(settings: &WebhookSettings) -> HttpWebhookSender {
183	HttpWebhookSender::new(WebhookConfig::from(settings))
184}
185
186// --- worker ---------------------------------------------------------------
187
188/// Task worker settings fragment.
189///
190/// Maps to the `[tasks_worker]` section.
191#[settings(fragment = true, section = "tasks_worker")]
192#[non_exhaustive]
193#[derive(Clone, Debug, Serialize, Deserialize)]
194pub struct WorkerSettings {
195	/// Name of this worker instance.
196	#[serde(default = "default_worker_name")]
197	pub name: String,
198	/// Number of concurrent task handlers.
199	#[serde(default = "default_concurrency")]
200	pub concurrency: usize,
201	/// How long to wait between queue polls, in milliseconds.
202	#[serde(default = "default_poll_interval_ms")]
203	pub poll_interval_ms: u64,
204	/// Webhook delivery targets for task completion notifications.
205	#[setting(node)]
206	#[serde(default)]
207	pub webhooks: Vec<WebhookSettings>,
208}
209
210impl Default for WorkerSettings {
211	fn default() -> Self {
212		Self {
213			name: default_worker_name(),
214			concurrency: default_concurrency(),
215			poll_interval_ms: default_poll_interval_ms(),
216			webhooks: Vec::new(),
217		}
218	}
219}
220
221impl From<&WorkerSettings> for WorkerConfig {
222	fn from(settings: &WorkerSettings) -> Self {
223		Self {
224			name: settings.name.clone(),
225			concurrency: settings.concurrency,
226			poll_interval: Duration::from_millis(settings.poll_interval_ms),
227			webhook_configs: settings.webhooks.iter().map(WebhookConfig::from).collect(),
228		}
229	}
230}
231
232/// Build a [`Worker`] from a [`WorkerSettings`] fragment.
233pub fn create_worker_from_settings(settings: &WorkerSettings) -> Worker {
234	Worker::new(WorkerConfig::from(settings))
235}
236
237// --- sqs backend ----------------------------------------------------------
238
239#[cfg(feature = "sqs-backend")]
240fn default_sqs_visibility_timeout() -> i32 {
241	30
242}
243#[cfg(feature = "sqs-backend")]
244fn default_sqs_max_messages() -> i32 {
245	1
246}
247#[cfg(feature = "sqs-backend")]
248fn default_sqs_wait_time_seconds() -> i32 {
249	0
250}
251
252/// Amazon SQS backend settings fragment.
253///
254/// Maps to the `[tasks_sqs]` section. Available with the `sqs-backend` feature.
255#[cfg(feature = "sqs-backend")]
256#[settings(fragment = true, section = "tasks_sqs")]
257#[non_exhaustive]
258#[derive(Clone, Debug, Serialize, Deserialize)]
259pub struct SqsSettings {
260	/// The SQS queue URL.
261	#[serde(default)]
262	pub queue_url: String,
263	/// Message visibility timeout, in seconds.
264	#[serde(default = "default_sqs_visibility_timeout")]
265	pub visibility_timeout: i32,
266	/// Maximum number of messages to receive per poll (capped at 10 by SQS).
267	#[serde(default = "default_sqs_max_messages")]
268	pub max_messages: i32,
269	/// Wait time for long polling, in seconds.
270	#[serde(default = "default_sqs_wait_time_seconds")]
271	pub wait_time_seconds: i32,
272}
273
274#[cfg(feature = "sqs-backend")]
275impl Default for SqsSettings {
276	fn default() -> Self {
277		Self {
278			queue_url: String::new(),
279			visibility_timeout: default_sqs_visibility_timeout(),
280			max_messages: default_sqs_max_messages(),
281			wait_time_seconds: default_sqs_wait_time_seconds(),
282		}
283	}
284}
285
286#[cfg(feature = "sqs-backend")]
287impl From<&SqsSettings> for crate::backends::sqs::SqsConfig {
288	fn from(settings: &SqsSettings) -> Self {
289		// SqsConfig fields are private; rebuild through the builder API.
290		crate::backends::sqs::SqsConfig::new(settings.queue_url.clone())
291			.with_visibility_timeout(settings.visibility_timeout)
292			.with_max_messages(settings.max_messages)
293			.with_wait_time_seconds(settings.wait_time_seconds)
294	}
295}
296
297/// Build an [`SqsBackend`](crate::backends::sqs::SqsBackend) from an
298/// [`SqsSettings`] fragment.
299#[cfg(feature = "sqs-backend")]
300pub async fn create_sqs_backend_from_settings(
301	settings: &SqsSettings,
302) -> Result<crate::backends::sqs::SqsBackend, crate::TaskExecutionError> {
303	crate::backends::sqs::SqsBackend::new(crate::backends::sqs::SqsConfig::from(settings)).await
304}
305
306// --- rabbitmq backend -----------------------------------------------------
307
308#[cfg(feature = "rabbitmq-backend")]
309fn default_rabbitmq_url() -> String {
310	"amqp://localhost:5672/%2f".to_string()
311}
312#[cfg(feature = "rabbitmq-backend")]
313fn default_rabbitmq_queue_name() -> String {
314	"reinhardt_tasks".to_string()
315}
316#[cfg(feature = "rabbitmq-backend")]
317fn default_rabbitmq_routing_key() -> String {
318	"reinhardt_tasks".to_string()
319}
320
321/// RabbitMQ backend settings fragment.
322///
323/// Maps to the `[tasks_rabbitmq]` section. Available with the
324/// `rabbitmq-backend` feature.
325#[cfg(feature = "rabbitmq-backend")]
326#[settings(fragment = true, section = "tasks_rabbitmq")]
327#[non_exhaustive]
328#[derive(Clone, Debug, Serialize, Deserialize)]
329pub struct RabbitMQSettings {
330	/// The AMQP connection URL.
331	#[serde(default = "default_rabbitmq_url")]
332	pub url: String,
333	/// The queue name to publish to.
334	#[serde(default = "default_rabbitmq_queue_name")]
335	pub queue_name: String,
336	/// The exchange name (empty string for the default exchange).
337	#[serde(default)]
338	pub exchange_name: String,
339	/// The routing key.
340	#[serde(default = "default_rabbitmq_routing_key")]
341	pub routing_key: String,
342}
343
344#[cfg(feature = "rabbitmq-backend")]
345impl Default for RabbitMQSettings {
346	fn default() -> Self {
347		Self {
348			url: default_rabbitmq_url(),
349			queue_name: default_rabbitmq_queue_name(),
350			exchange_name: String::new(),
351			routing_key: default_rabbitmq_routing_key(),
352		}
353	}
354}
355
356#[cfg(feature = "rabbitmq-backend")]
357impl From<&RabbitMQSettings> for crate::backends::rabbitmq::RabbitMQConfig {
358	fn from(settings: &RabbitMQSettings) -> Self {
359		Self {
360			url: settings.url.clone(),
361			queue_name: settings.queue_name.clone(),
362			exchange_name: settings.exchange_name.clone(),
363			routing_key: settings.routing_key.clone(),
364		}
365	}
366}
367
368/// Build a [`RabbitMQBackend`](crate::backends::rabbitmq::RabbitMQBackend) from
369/// a [`RabbitMQSettings`] fragment.
370#[cfg(feature = "rabbitmq-backend")]
371pub async fn create_rabbitmq_backend_from_settings(
372	settings: &RabbitMQSettings,
373) -> Result<crate::backends::rabbitmq::RabbitMQBackend, lapin::Error> {
374	crate::backends::rabbitmq::RabbitMQBackend::new(
375		crate::backends::rabbitmq::RabbitMQConfig::from(settings),
376	)
377	.await
378}
379
380#[cfg(test)]
381mod tests {
382	use super::*;
383	use reinhardt_conf::settings::fragment::SettingsFragment;
384
385	#[rstest::rstest]
386	fn section_names_are_crate_prefixed() {
387		// Arrange / Act / Assert
388		assert_eq!(QueueSettings::section(), "tasks_queue");
389		assert_eq!(WorkerSettings::section(), "tasks_worker");
390		assert_eq!(WebhookSettings::section(), "tasks_webhook");
391	}
392
393	#[rstest::rstest]
394	fn queue_settings_default_has_expected_values() {
395		// Arrange
396		let settings = QueueSettings::default();
397
398		// Act / Assert
399		assert_eq!(settings.name, "default");
400		assert_eq!(settings.max_retries, 3);
401	}
402
403	#[rstest::rstest]
404	fn worker_settings_convert_milliseconds_to_duration() {
405		// Arrange
406		let settings = WorkerSettings {
407			name: "ingest".to_string(),
408			concurrency: 8,
409			poll_interval_ms: 2500,
410			webhooks: Vec::new(),
411		};
412
413		// Act
414		let config = WorkerConfig::from(&settings);
415
416		// Assert
417		assert_eq!(config.name, "ingest");
418		assert_eq!(config.concurrency, 8);
419		assert_eq!(config.poll_interval, Duration::from_millis(2500));
420		assert!(config.webhook_configs.is_empty());
421	}
422
423	#[rstest::rstest]
424	fn webhook_settings_convert_seconds_and_nested_retry() {
425		// Arrange
426		let settings = WebhookSettings::default();
427
428		// Act
429		let config = WebhookConfig::from(&settings);
430
431		// Assert
432		assert_eq!(config.method, "POST");
433		assert_eq!(config.timeout, Duration::from_secs(5));
434		assert_eq!(config.retry_config.max_retries, 3);
435		assert_eq!(
436			config.retry_config.initial_backoff,
437			Duration::from_millis(100)
438		);
439		assert_eq!(config.retry_config.max_backoff, Duration::from_secs(30));
440		assert_eq!(config.retry_config.backoff_multiplier, 2.0);
441	}
442
443	#[rstest::rstest]
444	fn worker_settings_map_nested_webhooks() {
445		// Arrange
446		let settings = WorkerSettings {
447			name: "w".to_string(),
448			concurrency: 1,
449			poll_interval_ms: 100,
450			webhooks: vec![WebhookSettings {
451				url: "https://example.com/hook".to_string(),
452				..WebhookSettings::default()
453			}],
454		};
455
456		// Act
457		let config = WorkerConfig::from(&settings);
458
459		// Assert
460		assert_eq!(config.webhook_configs.len(), 1);
461		assert_eq!(config.webhook_configs[0].url, "https://example.com/hook");
462	}
463
464	#[rstest::rstest]
465	fn webhook_settings_deserialize_with_defaults() {
466		// Arrange — only `url` is provided; everything else falls back to defaults.
467		let json = r#"{ "url": "https://example.com/hook", "timeout_secs": 10 }"#;
468
469		// Act
470		let settings: WebhookSettings = serde_json::from_str(json).unwrap();
471		let config = WebhookConfig::from(&settings);
472
473		// Assert
474		assert_eq!(config.url, "https://example.com/hook");
475		assert_eq!(config.method, "POST");
476		assert_eq!(config.timeout, Duration::from_secs(10));
477		assert_eq!(config.retry_config.max_retries, 3);
478	}
479
480	#[cfg(feature = "sqs-backend")]
481	#[rstest::rstest]
482	fn sqs_settings_default_converts_to_config() {
483		// Arrange
484		let settings = SqsSettings {
485			queue_url: "https://sqs.example.com/q".to_string(),
486			..SqsSettings::default()
487		};
488
489		// Act
490		let config = crate::backends::sqs::SqsConfig::from(&settings);
491
492		// Assert — round-trips through the builder, which caps max_messages at 10.
493		assert!(format!("{config:?}").contains("https://sqs.example.com/q"));
494	}
495
496	#[cfg(feature = "rabbitmq-backend")]
497	#[rstest::rstest]
498	fn rabbitmq_settings_default_converts_to_config() {
499		// Arrange
500		let settings = RabbitMQSettings::default();
501
502		// Act
503		let config = crate::backends::rabbitmq::RabbitMQConfig::from(&settings);
504
505		// Assert
506		assert_eq!(config.queue_name, "reinhardt_tasks");
507		assert_eq!(config.routing_key, "reinhardt_tasks");
508	}
509}