Skip to main content

aster_forge_utils/
id.rs

1//! UUID and token generation helpers.
2//!
3//! The module provides plain random tokens as well as retry helpers for business-unique UUIDs.
4//! Callers remain responsible for enforcing uniqueness at the database layer, but the helpers
5//! centralize collision retry loops and error messages.
6
7use std::future::Future;
8
9use uuid::Uuid;
10
11/// Maximum number of UUID candidates generated by unique UUID helpers.
12pub const UNIQUE_UUID_MAX_ATTEMPTS: usize = 5;
13
14/// Result of attempting to reserve or accept a generated UUID.
15pub enum UniqueUuidAttempt<T> {
16    /// Candidate was accepted and produced a value.
17    Accepted(T),
18    /// Candidate collided and should be retried with a new UUID.
19    Collision,
20}
21
22/// Generates a UUID v4 string.
23pub fn new_uuid() -> String {
24    Uuid::new_v4().to_string()
25}
26
27fn unique_uuid_exhausted_message(value_name: &str) -> String {
28    format!("failed to create unique {value_name} UUID after {UNIQUE_UUID_MAX_ATTEMPTS} attempts")
29}
30
31/// Runs an operation with a business-unique UUID and caller-owned error type.
32///
33/// The callback receives a fresh UUID candidate on every attempt. Returning
34/// [`UniqueUuidAttempt::Collision`] asks the helper to retry with a new candidate, while returning
35/// an error stops immediately and gives that error back to the caller. If all candidates collide,
36/// the standard retry-budget error is converted into the caller's error type.
37pub async fn with_unique_uuid<F, Fut, T, E>(
38    value_name: &str,
39    mut try_candidate: F,
40) -> std::result::Result<T, E>
41where
42    F: FnMut(Uuid) -> Fut,
43    Fut: Future<Output = std::result::Result<UniqueUuidAttempt<T>, E>>,
44    E: From<crate::UtilsError>,
45{
46    for attempt in 1..=UNIQUE_UUID_MAX_ATTEMPTS {
47        let candidate = Uuid::new_v4();
48        match try_candidate(candidate).await? {
49            UniqueUuidAttempt::Accepted(value) => return Ok(value),
50            UniqueUuidAttempt::Collision => {
51                tracing::warn!(
52                    value_name,
53                    attempt,
54                    candidate = %candidate,
55                    "uuid collision, retrying"
56                );
57            }
58        }
59    }
60
61    Err(crate::UtilsError::invalid_value(unique_uuid_exhausted_message(value_name)).into())
62}
63
64/// Generates a business UUID and filters occupied candidates through caller-provided lookup.
65///
66/// This helper does not atomically reserve the value; callers must still rely on a database
67/// uniqueness constraint in the later write path. Use [`with_unique_uuid`] when check-and-reserve
68/// semantics are needed, and perform the write inside the callback.
69pub async fn new_best_effort_uuid<F, Fut, E>(
70    value_name: &str,
71    mut is_taken: F,
72) -> std::result::Result<Uuid, E>
73where
74    F: FnMut(Uuid) -> Fut,
75    Fut: Future<Output = std::result::Result<bool, E>>,
76    E: From<crate::UtilsError>,
77{
78    with_unique_uuid(value_name, |candidate| {
79        let taken = is_taken(candidate);
80        async move {
81            if taken.await? {
82                Ok(UniqueUuidAttempt::Collision)
83            } else {
84                Ok(UniqueUuidAttempt::Accepted(candidate))
85            }
86        }
87    })
88    .await
89}
90
91/// Generates a short 32-character hex token.
92pub fn new_short_token() -> String {
93    Uuid::new_v4().simple().to_string()
94}
95
96/// Generates an ephemeral runtime instance identifier.
97///
98/// Runtime IDs are process-level identifiers for ownership and notification
99/// echo filtering. They are intentionally not stable business IDs or
100/// deployment node names.
101pub fn new_runtime_id() -> String {
102    format!("runtime-{}", new_short_token())
103}
104
105#[cfg(test)]
106mod tests {
107    use super::*;
108    use crate::{Result, UtilsError};
109    use std::sync::{
110        Arc,
111        atomic::{AtomicUsize, Ordering},
112    };
113
114    #[tokio::test]
115    async fn new_best_effort_uuid_returns_first_free_candidate() {
116        let attempts = Arc::new(AtomicUsize::new(0));
117        let result: Result<Uuid> = new_best_effort_uuid("test value", {
118            let attempts = Arc::clone(&attempts);
119            move |_| {
120                let attempts = Arc::clone(&attempts);
121                async move {
122                    attempts.fetch_add(1, Ordering::SeqCst);
123                    Ok(false)
124                }
125            }
126        })
127        .await;
128
129        assert!(result.is_ok());
130        assert_eq!(attempts.load(Ordering::SeqCst), 1);
131    }
132
133    #[tokio::test]
134    async fn with_unique_uuid_retries_collisions_and_returns_callback_value() {
135        let attempts = Arc::new(AtomicUsize::new(0));
136        let result: Result<String> = with_unique_uuid("test value", {
137            let attempts = Arc::clone(&attempts);
138            move |candidate| {
139                let attempts = Arc::clone(&attempts);
140                async move {
141                    let attempt = attempts.fetch_add(1, Ordering::SeqCst);
142                    if attempt < 2 {
143                        Ok(UniqueUuidAttempt::Collision)
144                    } else {
145                        Ok(UniqueUuidAttempt::Accepted(candidate.to_string()))
146                    }
147                }
148            }
149        })
150        .await;
151        let result = result.expect("third candidate should be accepted");
152
153        assert!(Uuid::parse_str(&result).is_ok());
154        assert_eq!(attempts.load(Ordering::SeqCst), 3);
155    }
156
157    #[tokio::test]
158    async fn with_unique_uuid_propagates_callback_errors_without_retrying() {
159        let attempts = Arc::new(AtomicUsize::new(0));
160        let result: Result<String> = with_unique_uuid("test value", {
161            let attempts = Arc::clone(&attempts);
162            move |_| {
163                let attempts = Arc::clone(&attempts);
164                async move {
165                    attempts.fetch_add(1, Ordering::SeqCst);
166                    Err(UtilsError::invalid_value("candidate check failed"))
167                }
168            }
169        })
170        .await;
171
172        let error = result.expect_err("callback error should be returned");
173        assert!(matches!(error, UtilsError::InvalidValue(_)));
174        assert_eq!(attempts.load(Ordering::SeqCst), 1);
175    }
176
177    #[derive(Debug, PartialEq, Eq)]
178    enum CustomUuidError {
179        Callback,
180        Exhausted(String),
181    }
182
183    impl From<UtilsError> for CustomUuidError {
184        fn from(error: UtilsError) -> Self {
185            Self::Exhausted(error.to_string())
186        }
187    }
188
189    #[tokio::test]
190    async fn with_unique_uuid_supports_caller_error_type() {
191        let attempts = Arc::new(AtomicUsize::new(0));
192        let result: std::result::Result<String, CustomUuidError> =
193            with_unique_uuid("custom value", {
194                let attempts = Arc::clone(&attempts);
195                move |_| {
196                    let attempts = Arc::clone(&attempts);
197                    async move {
198                        attempts.fetch_add(1, Ordering::SeqCst);
199                        Err(CustomUuidError::Callback)
200                    }
201                }
202            })
203            .await;
204
205        assert_eq!(result, Err(CustomUuidError::Callback));
206        assert_eq!(attempts.load(Ordering::SeqCst), 1);
207    }
208
209    #[tokio::test]
210    async fn with_unique_uuid_uses_caller_error_for_exhaustion() {
211        let attempts = Arc::new(AtomicUsize::new(0));
212        let result: std::result::Result<String, CustomUuidError> =
213            with_unique_uuid("custom value", {
214                let attempts = Arc::clone(&attempts);
215                move |_| {
216                    let attempts = Arc::clone(&attempts);
217                    async move {
218                        attempts.fetch_add(1, Ordering::SeqCst);
219                        Ok(UniqueUuidAttempt::Collision)
220                    }
221                }
222            })
223            .await;
224
225        assert_eq!(
226            result,
227            Err(CustomUuidError::Exhausted(
228                "failed to create unique custom value UUID after 5 attempts".to_string()
229            ))
230        );
231        assert_eq!(attempts.load(Ordering::SeqCst), UNIQUE_UUID_MAX_ATTEMPTS);
232    }
233
234    #[tokio::test]
235    async fn new_best_effort_uuid_retries_taken_candidates() {
236        let attempts = Arc::new(AtomicUsize::new(0));
237        let result: Result<Uuid> = new_best_effort_uuid("test value", {
238            let attempts = Arc::clone(&attempts);
239            move |_| {
240                let attempts = Arc::clone(&attempts);
241                async move {
242                    let attempt = attempts.fetch_add(1, Ordering::SeqCst);
243                    Ok(attempt < 2)
244                }
245            }
246        })
247        .await;
248
249        assert!(result.is_ok());
250        assert_eq!(attempts.load(Ordering::SeqCst), 3);
251    }
252
253    #[tokio::test]
254    async fn new_best_effort_uuid_stops_after_retry_budget_is_exhausted() {
255        let attempts = Arc::new(AtomicUsize::new(0));
256        let result: Result<Uuid> = new_best_effort_uuid("test value", {
257            let attempts = Arc::clone(&attempts);
258            move |_| {
259                let attempts = Arc::clone(&attempts);
260                async move {
261                    attempts.fetch_add(1, Ordering::SeqCst);
262                    Ok(true)
263                }
264            }
265        })
266        .await;
267
268        let error = result.expect_err("all candidates were reported as taken");
269        assert!(matches!(error, UtilsError::InvalidValue(_)));
270        assert_eq!(attempts.load(Ordering::SeqCst), UNIQUE_UUID_MAX_ATTEMPTS);
271    }
272
273    #[test]
274    fn new_short_token_uses_uuid_v4_hex_entropy() {
275        let token = new_short_token();
276
277        assert_eq!(token.len(), 32);
278        assert!(!token.contains('-'));
279        assert!(Uuid::parse_str(&token).is_ok());
280    }
281
282    #[test]
283    fn new_runtime_id_uses_runtime_prefix_and_uuid_entropy() {
284        let runtime_id = new_runtime_id();
285        let token = runtime_id
286            .strip_prefix("runtime-")
287            .expect("runtime id should use runtime prefix");
288
289        assert_eq!(token.len(), 32);
290        assert!(Uuid::parse_str(token).is_ok());
291    }
292}