Skip to main content

durable_lambda_core/
operation_id.rs

1//! Deterministic operation ID generation for replay correctness.
2//!
3//! Operation IDs must be identical across replays for the same code path.
4//! This module implements the Python SDK's ID generation strategy:
5//! `blake2b("{counter}")` for root operations, `blake2b("{parent_id}-{counter}")`
6//! for child operations, truncated to 64 hex characters.
7
8use blake2::{Blake2b512, Digest};
9
10/// Generate deterministic operation IDs for durable operations.
11///
12/// Each `OperationIdGenerator` maintains a monotonically increasing counter.
13/// IDs are computed as `blake2b(input)` truncated to 64 hex characters, where
14/// `input` is `"{counter}"` for root operations or `"{parent_id}-{counter}"`
15/// for child operations.
16///
17/// # Determinism Invariant
18///
19/// The same code path executing the same sequence of durable operations
20/// **must** produce the same operation IDs across replays. This is the
21/// fundamental correctness requirement for the replay engine.
22///
23/// # Examples
24///
25/// ```
26/// use durable_lambda_core::operation_id::OperationIdGenerator;
27///
28/// let mut gen = OperationIdGenerator::new(None);
29/// let id1 = gen.next_id();
30/// let id2 = gen.next_id();
31///
32/// // IDs are deterministic — same counter produces same ID.
33/// let mut gen2 = OperationIdGenerator::new(None);
34/// assert_eq!(id1, gen2.next_id());
35/// assert_eq!(id2, gen2.next_id());
36///
37/// // Different IDs for different counters.
38/// assert_ne!(id1, id2);
39/// ```
40#[derive(Debug, Clone)]
41pub struct OperationIdGenerator {
42    counter: u64,
43    parent_id: Option<String>,
44}
45
46impl OperationIdGenerator {
47    /// Create a new generator, optionally scoped under a parent operation.
48    ///
49    /// - `parent_id: None` — root-level generator, hashes `"{counter}"`
50    /// - `parent_id: Some(id)` — child generator, hashes `"{parent_id}-{counter}"`
51    ///
52    /// # Examples
53    ///
54    /// ```
55    /// use durable_lambda_core::operation_id::OperationIdGenerator;
56    ///
57    /// // Root-level generator.
58    /// let root = OperationIdGenerator::new(None);
59    ///
60    /// // Child generator scoped to a parent operation.
61    /// let child = OperationIdGenerator::new(Some("abc123".to_string()));
62    /// ```
63    pub fn new(parent_id: Option<String>) -> Self {
64        Self {
65            counter: 0,
66            parent_id,
67        }
68    }
69
70    /// Generate the next deterministic operation ID.
71    ///
72    /// Increments the internal counter and returns a 64-character hex string
73    /// derived from `blake2b` hashing.
74    ///
75    /// # Examples
76    ///
77    /// ```
78    /// use durable_lambda_core::operation_id::OperationIdGenerator;
79    ///
80    /// let mut gen = OperationIdGenerator::new(None);
81    /// let id = gen.next_id();
82    /// assert_eq!(id.len(), 64);
83    /// ```
84    pub fn next_id(&mut self) -> String {
85        self.counter += 1;
86        let input = match &self.parent_id {
87            Some(parent) => format!("{}-{}", parent, self.counter),
88            None => self.counter.to_string(),
89        };
90        blake2b_hash_64(&input)
91    }
92}
93
94/// Compute blake2b hash of input, returning first 64 hex characters.
95fn blake2b_hash_64(input: &str) -> String {
96    let mut hasher = Blake2b512::new();
97    hasher.update(input.as_bytes());
98    let result = hasher.finalize();
99    let full_hex = hex::encode(result);
100    full_hex[..64].to_string()
101}
102
103#[cfg(test)]
104mod tests {
105    use super::*;
106
107    #[test]
108    fn root_id_is_64_hex_chars() {
109        let mut gen = OperationIdGenerator::new(None);
110        let id = gen.next_id();
111        assert_eq!(id.len(), 64);
112        assert!(id.chars().all(|c| c.is_ascii_hexdigit()));
113    }
114
115    #[test]
116    fn root_ids_are_deterministic() {
117        let mut gen1 = OperationIdGenerator::new(None);
118        let mut gen2 = OperationIdGenerator::new(None);
119
120        for _ in 0..5 {
121            assert_eq!(gen1.next_id(), gen2.next_id());
122        }
123    }
124
125    #[test]
126    fn root_ids_are_unique() {
127        let mut gen = OperationIdGenerator::new(None);
128        let ids: Vec<String> = (0..10).map(|_| gen.next_id()).collect();
129
130        for i in 0..ids.len() {
131            for j in (i + 1)..ids.len() {
132                assert_ne!(ids[i], ids[j], "IDs at {} and {} should differ", i, j);
133            }
134        }
135    }
136
137    #[test]
138    fn child_ids_differ_from_root() {
139        let mut root = OperationIdGenerator::new(None);
140        let mut child = OperationIdGenerator::new(Some("parent123".to_string()));
141
142        // Same counter value but different parent → different IDs.
143        assert_ne!(root.next_id(), child.next_id());
144    }
145
146    #[test]
147    fn child_ids_are_deterministic() {
148        let parent = "my-parent-id".to_string();
149        let mut gen1 = OperationIdGenerator::new(Some(parent.clone()));
150        let mut gen2 = OperationIdGenerator::new(Some(parent));
151
152        for _ in 0..5 {
153            assert_eq!(gen1.next_id(), gen2.next_id());
154        }
155    }
156
157    #[test]
158    fn different_parents_produce_different_ids() {
159        let mut gen_a = OperationIdGenerator::new(Some("parent-a".to_string()));
160        let mut gen_b = OperationIdGenerator::new(Some("parent-b".to_string()));
161
162        assert_ne!(gen_a.next_id(), gen_b.next_id());
163    }
164
165    #[test]
166    fn counter_increments_correctly() {
167        // Verify the hash input is "{counter}" for root:
168        // counter=1 → hash("1"), counter=2 → hash("2")
169        let expected_1 = blake2b_hash_64("1");
170        let expected_2 = blake2b_hash_64("2");
171
172        let mut gen = OperationIdGenerator::new(None);
173        assert_eq!(gen.next_id(), expected_1);
174        assert_eq!(gen.next_id(), expected_2);
175    }
176
177    #[test]
178    fn child_counter_format() {
179        // Verify the hash input is "{parent_id}-{counter}" for children.
180        let parent = "abc";
181        let expected_1 = blake2b_hash_64("abc-1");
182        let expected_2 = blake2b_hash_64("abc-2");
183
184        let mut gen = OperationIdGenerator::new(Some(parent.to_string()));
185        assert_eq!(gen.next_id(), expected_1);
186        assert_eq!(gen.next_id(), expected_2);
187    }
188}