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}