aster_forge_tasks/
dedupe.rs

1//! Dedupe keys for idempotent task enqueueing.
2//!
3//! Runtime leases prevent multiple instances from normally running the same
4//! scheduler, and task processing leases prevent duplicate execution of one
5//! persisted row. Dedupe keys cover the remaining boundary: enqueueing the same
6//! logical task more than once during leader handoff, retries, or split-brain
7//! windows. Product repositories should persist this key in a nullable unique
8//! column and return the existing row when a duplicate insert races.
9
10use chrono::{DateTime, SecondsFormat, Utc};
11
12use crate::{Result, TaskCoreError};
13
14/// Maximum length for persisted task dedupe keys.
15pub const TASK_DEDUPE_KEY_MAX_LEN: usize = 191;
16
17/// Validated task dedupe key.
18#[derive(Debug, Clone, PartialEq, Eq, Hash)]
19pub struct TaskDedupeKey(String);
20
21impl TaskDedupeKey {
22    /// Validates a product-provided dedupe key.
23    ///
24    /// # Errors
25    ///
26    /// Returns [`TaskCoreError`] when the key is empty or exceeds the persisted length limit.
27    pub fn new(value: impl Into<String>) -> Result<Self> {
28        let value = value.into();
29        if value.trim().is_empty() {
30            return Err(TaskCoreError::invalid_value(
31                "task dedupe key must not be empty",
32            ));
33        }
34        if value.len() > TASK_DEDUPE_KEY_MAX_LEN {
35            return Err(TaskCoreError::invalid_value(format!(
36                "task dedupe key must be at most {TASK_DEDUPE_KEY_MAX_LEN} bytes"
37            )));
38        }
39        Ok(Self(value))
40    }
41
42    /// Returns the validated key string.
43    #[must_use]
44    pub fn as_str(&self) -> &str {
45        &self.0
46    }
47
48    /// Consumes the key and returns the owned string.
49    #[must_use]
50    pub fn into_string(self) -> String {
51        self.0
52    }
53}
54
55/// Builds a stable dedupe key for one scheduled task firing.
56///
57/// # Errors
58///
59/// Returns [`TaskCoreError`] when the namespace is empty or the resulting key is too long.
60pub fn scheduled_task_dedupe_key(
61    namespace: &str,
62    task_name: &str,
63    scheduled_at: DateTime<Utc>,
64) -> Result<TaskDedupeKey> {
65    TaskDedupeKey::new(format!(
66        "schedule:{namespace}:{task_name}:{}",
67        scheduled_at.to_rfc3339_opts(SecondsFormat::Secs, true)
68    ))
69}
70
71#[cfg(test)]
72mod tests {
73    use chrono::{TimeZone, Utc};
74
75    use super::{TASK_DEDUPE_KEY_MAX_LEN, TaskDedupeKey, scheduled_task_dedupe_key};
76
77    #[test]
78    fn task_dedupe_key_rejects_empty_values() {
79        assert!(TaskDedupeKey::new("   ").is_err());
80    }
81
82    #[test]
83    fn task_dedupe_key_rejects_values_over_storage_limit() {
84        assert!(TaskDedupeKey::new("x".repeat(TASK_DEDUPE_KEY_MAX_LEN + 1)).is_err());
85    }
86
87    #[test]
88    fn scheduled_task_dedupe_key_is_stable_and_compact() {
89        let key = scheduled_task_dedupe_key(
90            "aster_yggdrasil",
91            "task-cleanup",
92            Utc.with_ymd_and_hms(2026, 6, 26, 1, 2, 3).unwrap(),
93        )
94        .unwrap();
95
96        assert_eq!(
97            key.as_str(),
98            "schedule:aster_yggdrasil:task-cleanup:2026-06-26T01:02:03Z"
99        );
100    }
101}