From 0bc4603fa442b775e19ac5f4083a98b026ff72e7 Mon Sep 17 00:00:00 2001 From: Todd Green Date: Fri, 5 Jun 2026 18:41:27 +0000 Subject: [PATCH 1/5] Make ctx.new_guid() return a standard UUID v4 generate_guid() derived its value from SystemTime nanoseconds plus a thread-local counter, which produced low-entropy, structured output (the leading groups were always zero and the rest was largely sequential). Switch to uuid::Uuid::new_v4(). The value is still recorded in history, so replay stays deterministic. --- CHANGELOG.md | 11 ++++++ Cargo.toml | 1 + docs/ORCHESTRATION-GUIDE.md | 2 +- src/lib.rs | 43 ++++++----------------- tests/system_calls_test.rs | 69 +++++++++++++++++++++++++++++++++++++ 5 files changed, 93 insertions(+), 33 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 71a396e..8234e42 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,17 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased] + +### Changed + +- **`ctx.new_guid()` now returns a standard UUID v4.** The previous + implementation derived the value from `SystemTime::now()` nanoseconds plus a + thread-local counter, which produced low-entropy, structured values (the + leading groups were always zero and the rest was largely sequential). It now + uses `uuid::Uuid::new_v4()`. The value is still recorded in history, so + replays remain deterministic. + ## [0.1.29] - 2026-05-08 **Release:** diff --git a/Cargo.toml b/Cargo.toml index 81dddba..0ca7b35 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -25,6 +25,7 @@ tokio-util = "0.7" # For CancellationToken serde = { version = "1", features = ["derive"] } serde_json = "1" async-trait = "0.1" +uuid = { version = "1", features = ["v4"] } tracing = { version = "0.1", features = ["std"] } tracing-subscriber = { version = "0.3", features = ["fmt", "env-filter", "json"] } semver = "1" diff --git a/docs/ORCHESTRATION-GUIDE.md b/docs/ORCHESTRATION-GUIDE.md index 38a0415..4121188 100644 --- a/docs/ORCHESTRATION-GUIDE.md +++ b/docs/ORCHESTRATION-GUIDE.md @@ -820,7 +820,7 @@ ctx.trace_error("Payment failed"); #### System Calls (Deterministic Non-Determinism) ```rust -// Generate deterministic GUID +// Generate a random (UUID v4) GUID, recorded in history so it is replay-stable async fn new_guid(&self) -> Result // Get deterministic UTC timestamp diff --git a/src/lib.rs b/src/lib.rs index 6faa754..45bfde7 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -2769,11 +2769,13 @@ impl OrchestrationContext { } } - /// Generate a new deterministic GUID. + /// Generate a new random GUID (UUID v4). /// - /// This schedules a built-in activity that generates a unique identifier. - /// The GUID is deterministic across replays (the same value is returned - /// when the orchestration replays). + /// This schedules a built-in activity that generates a random identifier. + /// The value is recorded in history, so it is stable across replays (the + /// same value is returned when the orchestration replays) — this is a safe, + /// deterministic replacement for calling `Uuid::new_v4()` directly inside an + /// orchestration. /// /// # Example /// @@ -2871,36 +2873,13 @@ impl OrchestrationContext { } } -/// Generate a deterministic GUID for use in orchestrations. +/// Generate a random GUID (UUID v4) for use in orchestrations. /// -/// Uses timestamp + thread-local counter for uniqueness. +/// The value is generated once inside the built-in syscall activity and +/// persisted in history, so replays deterministically return the recorded +/// value — using a random value here does not break replay determinism. pub(crate) fn generate_guid() -> String { - use std::time::{SystemTime, UNIX_EPOCH}; - - let timestamp = SystemTime::now() - .duration_since(UNIX_EPOCH) - .map(|d| d.as_nanos()) - .unwrap_or(0); - - // Thread-local counter for uniqueness within the same timestamp - thread_local! { - static COUNTER: std::cell::Cell = const { std::cell::Cell::new(0) }; - } - let counter = COUNTER.with(|c| { - let val = c.get(); - c.set(val.wrapping_add(1)); - val - }); - - // Format as UUID-like string - format!( - "{:08x}-{:04x}-{:04x}-{:04x}-{:012x}", - (timestamp >> 96) as u32, - ((timestamp >> 80) & 0xFFFF) as u16, - (counter & 0xFFFF) as u16, - ((timestamp >> 64) & 0xFFFF) as u16, - (timestamp & 0xFFFFFFFFFFFF) as u64 - ) + uuid::Uuid::new_v4().to_string() } impl OrchestrationContext { diff --git a/tests/system_calls_test.rs b/tests/system_calls_test.rs index c440469..b515e71 100644 --- a/tests/system_calls_test.rs +++ b/tests/system_calls_test.rs @@ -55,6 +55,75 @@ async fn test_new_guid() { rt.shutdown(None).await; } +#[tokio::test] +async fn test_new_guid_is_random_uuid_v4() { + // new_guid() should return a standard random UUID v4, not a structured + // timestamp+counter value. Assert RFC 4122 v4 structure and that values + // are unique and non-sequential. + let store = Arc::new( + duroxide::providers::sqlite::SqliteProvider::new_in_memory() + .await + .unwrap(), + ); + let activities = ActivityRegistry::builder().build(); + + let orchestrations = OrchestrationRegistry::builder() + .register("ManyGuids", |ctx: OrchestrationContext, _input: String| async move { + let mut out = Vec::new(); + for _ in 0..16 { + out.push(ctx.new_guid().await?); + } + Ok(out.join(",")) + }) + .build(); + + let rt = runtime::Runtime::start_with_store(store.clone(), activities, orchestrations).await; + let client = duroxide::Client::new(store.clone()); + client.start_orchestration("many-guids", "ManyGuids", "").await.unwrap(); + let status = client + .wait_for_orchestration("many-guids", Duration::from_secs(5)) + .await + .unwrap(); + + let duroxide::runtime::OrchestrationStatus::Completed { output, .. } = status else { + panic!("orchestration did not complete: {status:?}"); + }; + let guids: Vec<&str> = output.split(',').collect(); + assert_eq!(guids.len(), 16); + + for g in &guids { + let parts: Vec<&str> = g.split('-').collect(); + assert_eq!(parts.len(), 5, "expected 8-4-4-4-12 format, got {g}"); + assert_eq!(parts[0].len(), 8); + assert_eq!(parts[1].len(), 4); + assert_eq!(parts[2].len(), 4); + assert_eq!(parts[3].len(), 4); + assert_eq!(parts[4].len(), 12); + assert!(g.chars().filter(|c| *c != '-').all(|c| c.is_ascii_hexdigit())); + + // RFC 4122: version nibble (first char of group 3) must be '4'. + assert_eq!(&parts[2][0..1], "4", "guid {g} is not a UUID v4"); + // Variant: high bits of group 4 must be 10xx => first nibble in 8..=b. + let variant = u8::from_str_radix(&parts[3][0..1], 16).unwrap(); + assert!((0x8..=0xb).contains(&variant), "guid {g} has wrong variant"); + + // The old scheme always produced groups 1, 2, 4 = zero. + // A real v4 effectively never does. Assert they're not all-zero. + assert!( + !(parts[0] == "00000000" && parts[1] == "0000"), + "guid {g} matches the old timestamp-shift pattern" + ); + } + + // All 16 must be unique (no sequential counter collisions / reuse). + let mut sorted = guids.clone(); + sorted.sort_unstable(); + sorted.dedup(); + assert_eq!(sorted.len(), 16, "guids are not all unique"); + + rt.shutdown(None).await; +} + #[tokio::test] async fn test_utc_now_ms() { let store = Arc::new( From ebe12b1e83920536df68801eab967f956a9fccb2 Mon Sep 17 00:00:00 2001 From: Todd Green Date: Fri, 5 Jun 2026 19:24:34 +0000 Subject: [PATCH 2/5] Tighten new_guid comments --- src/lib.rs | 17 +++++++---------- tests/system_calls_test.rs | 14 ++++++-------- 2 files changed, 13 insertions(+), 18 deletions(-) diff --git a/src/lib.rs b/src/lib.rs index 45bfde7..2c39f77 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -2769,13 +2769,11 @@ impl OrchestrationContext { } } - /// Generate a new random GUID (UUID v4). + /// Generate a new random GUID. /// - /// This schedules a built-in activity that generates a random identifier. - /// The value is recorded in history, so it is stable across replays (the - /// same value is returned when the orchestration replays) — this is a safe, - /// deterministic replacement for calling `Uuid::new_v4()` directly inside an - /// orchestration. + /// Schedules a built-in activity that returns a random UUID. The value is + /// recorded in history, so it is stable across replays — a replay-safe + /// replacement for calling `Uuid::new_v4()` directly in an orchestration. /// /// # Example /// @@ -2873,11 +2871,10 @@ impl OrchestrationContext { } } -/// Generate a random GUID (UUID v4) for use in orchestrations. +/// Generate a random GUID for use in orchestrations. /// -/// The value is generated once inside the built-in syscall activity and -/// persisted in history, so replays deterministically return the recorded -/// value — using a random value here does not break replay determinism. +/// Recorded in history by the syscall activity, so replays return the same +/// value — using a random value here does not break determinism. pub(crate) fn generate_guid() -> String { uuid::Uuid::new_v4().to_string() } diff --git a/tests/system_calls_test.rs b/tests/system_calls_test.rs index b515e71..98bded5 100644 --- a/tests/system_calls_test.rs +++ b/tests/system_calls_test.rs @@ -57,9 +57,8 @@ async fn test_new_guid() { #[tokio::test] async fn test_new_guid_is_random_uuid_v4() { - // new_guid() should return a standard random UUID v4, not a structured - // timestamp+counter value. Assert RFC 4122 v4 structure and that values - // are unique and non-sequential. + // new_guid() should return random UUID v4s, not structured + // timestamp+counter values. let store = Arc::new( duroxide::providers::sqlite::SqliteProvider::new_in_memory() .await @@ -101,21 +100,20 @@ async fn test_new_guid_is_random_uuid_v4() { assert_eq!(parts[4].len(), 12); assert!(g.chars().filter(|c| *c != '-').all(|c| c.is_ascii_hexdigit())); - // RFC 4122: version nibble (first char of group 3) must be '4'. + // Version nibble must be '4'. assert_eq!(&parts[2][0..1], "4", "guid {g} is not a UUID v4"); - // Variant: high bits of group 4 must be 10xx => first nibble in 8..=b. + // Variant nibble must be 8..=b. let variant = u8::from_str_radix(&parts[3][0..1], 16).unwrap(); assert!((0x8..=0xb).contains(&variant), "guid {g} has wrong variant"); - // The old scheme always produced groups 1, 2, 4 = zero. - // A real v4 effectively never does. Assert they're not all-zero. + // The old scheme always zeroed groups 1, 2, 4; v4 effectively never does. assert!( !(parts[0] == "00000000" && parts[1] == "0000"), "guid {g} matches the old timestamp-shift pattern" ); } - // All 16 must be unique (no sequential counter collisions / reuse). + // All 16 must be unique. let mut sorted = guids.clone(); sorted.sort_unstable(); sorted.dedup(); From bfb5d61351f3d74201786e5c8dac68957a9a8970 Mon Sep 17 00:00:00 2001 From: Todd Green Date: Fri, 5 Jun 2026 19:38:05 +0000 Subject: [PATCH 3/5] Drop redundant new_guid test Existing test_new_guid and test_new_guid_as_activity_input_replays_correctly already cover uniqueness and replay; the removed test mostly asserted uuid crate behavior. --- tests/system_calls_test.rs | 67 -------------------------------------- 1 file changed, 67 deletions(-) diff --git a/tests/system_calls_test.rs b/tests/system_calls_test.rs index 98bded5..c440469 100644 --- a/tests/system_calls_test.rs +++ b/tests/system_calls_test.rs @@ -55,73 +55,6 @@ async fn test_new_guid() { rt.shutdown(None).await; } -#[tokio::test] -async fn test_new_guid_is_random_uuid_v4() { - // new_guid() should return random UUID v4s, not structured - // timestamp+counter values. - let store = Arc::new( - duroxide::providers::sqlite::SqliteProvider::new_in_memory() - .await - .unwrap(), - ); - let activities = ActivityRegistry::builder().build(); - - let orchestrations = OrchestrationRegistry::builder() - .register("ManyGuids", |ctx: OrchestrationContext, _input: String| async move { - let mut out = Vec::new(); - for _ in 0..16 { - out.push(ctx.new_guid().await?); - } - Ok(out.join(",")) - }) - .build(); - - let rt = runtime::Runtime::start_with_store(store.clone(), activities, orchestrations).await; - let client = duroxide::Client::new(store.clone()); - client.start_orchestration("many-guids", "ManyGuids", "").await.unwrap(); - let status = client - .wait_for_orchestration("many-guids", Duration::from_secs(5)) - .await - .unwrap(); - - let duroxide::runtime::OrchestrationStatus::Completed { output, .. } = status else { - panic!("orchestration did not complete: {status:?}"); - }; - let guids: Vec<&str> = output.split(',').collect(); - assert_eq!(guids.len(), 16); - - for g in &guids { - let parts: Vec<&str> = g.split('-').collect(); - assert_eq!(parts.len(), 5, "expected 8-4-4-4-12 format, got {g}"); - assert_eq!(parts[0].len(), 8); - assert_eq!(parts[1].len(), 4); - assert_eq!(parts[2].len(), 4); - assert_eq!(parts[3].len(), 4); - assert_eq!(parts[4].len(), 12); - assert!(g.chars().filter(|c| *c != '-').all(|c| c.is_ascii_hexdigit())); - - // Version nibble must be '4'. - assert_eq!(&parts[2][0..1], "4", "guid {g} is not a UUID v4"); - // Variant nibble must be 8..=b. - let variant = u8::from_str_radix(&parts[3][0..1], 16).unwrap(); - assert!((0x8..=0xb).contains(&variant), "guid {g} has wrong variant"); - - // The old scheme always zeroed groups 1, 2, 4; v4 effectively never does. - assert!( - !(parts[0] == "00000000" && parts[1] == "0000"), - "guid {g} matches the old timestamp-shift pattern" - ); - } - - // All 16 must be unique. - let mut sorted = guids.clone(); - sorted.sort_unstable(); - sorted.dedup(); - assert_eq!(sorted.len(), 16, "guids are not all unique"); - - rt.shutdown(None).await; -} - #[tokio::test] async fn test_utc_now_ms() { let store = Arc::new( From bd7f69906831d401ccb90d39c6702723d6a2e329 Mon Sep 17 00:00:00 2001 From: Todd Green Date: Fri, 5 Jun 2026 19:52:28 +0000 Subject: [PATCH 4/5] Address review: doc consistency, guid v4 guard, lock-token randomness - Reword residual 'deterministic GUID' doc references to 'random/replay-safe'. - Restore a minimal UUID v4 structural guard in test_new_guid so a revert to the old predictable scheme would be caught. - Generate SQLite provider lock tokens with uuid::Uuid::new_v4() instead of nanos + pid. --- CHANGELOG.md | 3 +++ docs/ORCHESTRATION-GUIDE.md | 4 ++-- docs/durable-futures-internals.md | 2 +- src/providers/sqlite.rs | 6 +----- tests/system_calls_test.rs | 14 ++++++++++++++ 5 files changed, 21 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8234e42..efe9995 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 leading groups were always zero and the rest was largely sequential). It now uses `uuid::Uuid::new_v4()`. The value is still recorded in history, so replays remain deterministic. +- **SQLite provider lock tokens now use a random UUID** instead of + `nanos + process id`, removing a predictable-token pattern in work-item + ownership checks. ## [0.1.29] - 2026-05-08 diff --git a/docs/ORCHESTRATION-GUIDE.md b/docs/ORCHESTRATION-GUIDE.md index 4121188..9a89393 100644 --- a/docs/ORCHESTRATION-GUIDE.md +++ b/docs/ORCHESTRATION-GUIDE.md @@ -325,7 +325,7 @@ async fn safe_orchestration(ctx: OrchestrationContext, count: i32) -> Result( ) -> impl Future> // Usage: -let session_id = ctx.new_guid().await?; // Deterministic, replay-safe +let session_id = ctx.new_guid().await?; // Random, replay-safe let result = ctx.schedule_activity_on_session("RunTurn", &input, &session_id).await?; ``` diff --git a/docs/durable-futures-internals.md b/docs/durable-futures-internals.md index d3542f5..12b7044 100644 --- a/docs/durable-futures-internals.md +++ b/docs/durable-futures-internals.md @@ -550,7 +550,7 @@ Some operations are provided as **built-in activities** (under reserved names) s `ActivityScheduled` + `ActivityCompleted` event flow as user activities. This ensures replay determinism without special-case logic in the replay engine. -- `ctx.new_guid()` – Generate a deterministic UUID +- `ctx.new_guid()` – Generate a random UUID (recorded in history, so replay-stable) - `ctx.utc_now()` – Get the current time (replay-safe) Example usage: diff --git a/src/providers/sqlite.rs b/src/providers/sqlite.rs index d8fe523..eda3c3a 100644 --- a/src/providers/sqlite.rs +++ b/src/providers/sqlite.rs @@ -491,11 +491,7 @@ impl SqliteProvider { /// Generate a unique lock token fn generate_lock_token() -> String { - let now = SystemTime::now() - .duration_since(UNIX_EPOCH) - .expect("system clock should be after UNIX epoch") - .as_nanos(); - format!("lock_{now}_{}", std::process::id()) + format!("lock_{}", uuid::Uuid::new_v4()) } /// Get current timestamp in milliseconds diff --git a/tests/system_calls_test.rs b/tests/system_calls_test.rs index c440469..fed2008 100644 --- a/tests/system_calls_test.rs +++ b/tests/system_calls_test.rs @@ -48,6 +48,20 @@ async fn test_new_guid() { let parts: Vec<&str> = output.split(',').collect(); assert_eq!(parts.len(), 2); assert_ne!(parts[0], parts[1]); + + // Guard the security-relevant contract: each value is a UUID v4, not the + // old predictable timestamp+counter scheme (which zeroed groups 1, 2, 4). + for guid in &parts { + let g: Vec<&str> = guid.split('-').collect(); + assert_eq!(g.len(), 5, "expected 8-4-4-4-12 format, got {guid}"); + assert_eq!(&g[2][0..1], "4", "{guid} is not a UUID v4"); + let variant = u8::from_str_radix(&g[3][0..1], 16).unwrap(); + assert!((0x8..=0xb).contains(&variant), "{guid} has wrong variant"); + assert!( + !(g[0] == "00000000" && g[1] == "0000"), + "{guid} matches the old timestamp-shift pattern" + ); + } } else { panic!("Orchestration did not complete successfully: {status:?}"); } From c3999f7ab37ea750f7b497cd000358f5178c7e8d Mon Sep 17 00:00:00 2001 From: Todd Green Date: Sat, 6 Jun 2026 00:03:43 +0000 Subject: [PATCH 5/5] Verify new_guid output via uuid parse_str instead of manual shape checks Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- tests/system_calls_test.rs | 15 ++++----------- 1 file changed, 4 insertions(+), 11 deletions(-) diff --git a/tests/system_calls_test.rs b/tests/system_calls_test.rs index fed2008..3806b73 100644 --- a/tests/system_calls_test.rs +++ b/tests/system_calls_test.rs @@ -49,18 +49,11 @@ async fn test_new_guid() { assert_eq!(parts.len(), 2); assert_ne!(parts[0], parts[1]); - // Guard the security-relevant contract: each value is a UUID v4, not the - // old predictable timestamp+counter scheme (which zeroed groups 1, 2, 4). + // Guard the security-relevant contract: each value is a standard UUID v4, + // not the old predictable timestamp+counter scheme. for guid in &parts { - let g: Vec<&str> = guid.split('-').collect(); - assert_eq!(g.len(), 5, "expected 8-4-4-4-12 format, got {guid}"); - assert_eq!(&g[2][0..1], "4", "{guid} is not a UUID v4"); - let variant = u8::from_str_radix(&g[3][0..1], 16).unwrap(); - assert!((0x8..=0xb).contains(&variant), "{guid} has wrong variant"); - assert!( - !(g[0] == "00000000" && g[1] == "0000"), - "{guid} matches the old timestamp-shift pattern" - ); + let parsed = uuid::Uuid::parse_str(guid).unwrap_or_else(|e| panic!("{guid} is not a valid UUID: {e}")); + assert_eq!(parsed.get_version_num(), 4, "{guid} is not a UUID v4"); } } else { panic!("Orchestration did not complete successfully: {status:?}");