Skip to main content

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    pub fn new(value: impl Into<String>) -> Result<Self> {
24        let value = value.into();
25        if value.trim().is_empty() {
26            return Err(TaskCoreError::invalid_value(
27                "task dedupe key must not be empty",
28            ));
29        }
30        if value.len() > TASK_DEDUPE_KEY_MAX_LEN {
31            return Err(TaskCoreError::invalid_value(format!(
32                "task dedupe key must be at most {TASK_DEDUPE_KEY_MAX_LEN} bytes"
33            )));
34        }
35        Ok(Self(value))
36    }
37
38    /// Returns the validated key string.
39    pub fn as_str(&self) -> &str {
40        &self.0
41    }
42
43    /// Consumes the key and returns the owned string.
44    pub fn into_string(self) -> String {
45        self.0
46    }
47}
48
49/// Builds a stable dedupe key for one scheduled task firing.
50pub fn scheduled_task_dedupe_key(
51    namespace: &str,
52    task_name: &str,
53    scheduled_at: DateTime<Utc>,
54) -> Result<TaskDedupeKey> {
55    TaskDedupeKey::new(format!(
56        "schedule:{namespace}:{task_name}:{}",
57        scheduled_at.to_rfc3339_opts(SecondsFormat::Secs, true)
58    ))
59}
60
61#[cfg(test)]
62mod tests {
63    use chrono::{TimeZone, Utc};
64
65    use super::{TASK_DEDUPE_KEY_MAX_LEN, TaskDedupeKey, scheduled_task_dedupe_key};
66
67    #[test]
68    fn task_dedupe_key_rejects_empty_values() {
69        assert!(TaskDedupeKey::new("   ").is_err());
70    }
71
72    #[test]
73    fn task_dedupe_key_rejects_values_over_storage_limit() {
74        assert!(TaskDedupeKey::new("x".repeat(TASK_DEDUPE_KEY_MAX_LEN + 1)).is_err());
75    }
76
77    #[test]
78    fn scheduled_task_dedupe_key_is_stable_and_compact() {
79        let key = scheduled_task_dedupe_key(
80            "aster_yggdrasil",
81            "task-cleanup",
82            Utc.with_ymd_and_hms(2026, 6, 26, 1, 2, 3).unwrap(),
83        )
84        .unwrap();
85
86        assert_eq!(
87            key.as_str(),
88            "schedule:aster_yggdrasil:task-cleanup:2026-06-26T01:02:03Z"
89        );
90    }
91}