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