diff --git a/CHANGELOG.md b/CHANGELOG.md index 5e5227b..9e88988 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ and this project aims to follow [Semantic Versioning](https://semver.org/spec/v2 ### Documentation - Clarify `UuidV7Factory` collision and clock-skew guarantees, including the distinction between per-instance deterministic monotonicity and probabilistic cross-factory uniqueness. +- Document the custom RNG contract for `UuidV7Factory`, including deterministic replay behavior and production CSPRNG guidance. ## [1.3.1] - 2026-04-02 diff --git a/README.md b/README.md index 2cee190..e5a6b23 100644 --- a/README.md +++ b/README.md @@ -77,6 +77,8 @@ var id = factory.NewGuid(); For production services, prefer one shared `UuidV7Factory` instance per process. Its monotonic `(timestamp, counter)` allocation is deterministic within that live factory, including when wall time moves backwards. Independent factories, restarts, and multi-node fleets do not share that logical frontier; global uniqueness remains probabilistic and should be backed by storage uniqueness constraints where collisions are unacceptable. +`UuidV7Factory` owns a cryptographically secure RNG by default. Custom deterministic RNGs are useful for replayable tests and simulations, but identical deterministic RNG state plus identical time and call patterns can intentionally reproduce the same UUID sequence. Do not use seeded or deterministic RNGs for production UUID issuance. + ### Vector Clock usage ```csharp diff --git a/demo/Clockworks.Demo/Demos/UuidV7Showcase.cs b/demo/Clockworks.Demo/Demos/UuidV7Showcase.cs index a4df212..939064a 100644 --- a/demo/Clockworks.Demo/Demos/UuidV7Showcase.cs +++ b/demo/Clockworks.Demo/Demos/UuidV7Showcase.cs @@ -49,6 +49,7 @@ public static async Task Run(string[] args) Console.WriteLine($" Factory 1: {deterministicGuid1}"); Console.WriteLine($" Factory 2: {deterministicGuid2}"); Console.WriteLine($" Match: {deterministicGuid1 == deterministicGuid2}"); + Console.WriteLine(" Note: deterministic RNG injection is for tests/simulations, not production UUID issuance."); Console.WriteLine(); time1.AdvanceMs(100); diff --git a/demo/Clockworks.Demo/DeterministicRandomNumberGenerator.cs b/demo/Clockworks.Demo/DeterministicRandomNumberGenerator.cs index 3e272a0..4bd7e6b 100644 --- a/demo/Clockworks.Demo/DeterministicRandomNumberGenerator.cs +++ b/demo/Clockworks.Demo/DeterministicRandomNumberGenerator.cs @@ -2,7 +2,7 @@ namespace Clockworks.Demo; -// +/// /// Deterministic pseudo-random number generator for test replay and simulation. /// /// @@ -16,10 +16,9 @@ namespace Clockworks.Demo; /// /// /// -/// Mathematical Basis: -/// Uses .NET's implementation of xoshiro256** algorithm internally. -/// Period: 2^256 - 1 -/// Good statistical properties for testing, but predictable. +/// Implementation Notes: +/// Uses internally. The exact PRNG algorithm is a runtime detail and may change between .NET +/// versions. Determinism is intended for same-runtime replay in tests/simulations, not cross-version reproducibility. /// /// internal sealed class DeterministicRandomNumberGenerator : RandomNumberGenerator @@ -78,6 +77,21 @@ public override void GetNonZeroBytes(Span data) /// public DeterministicRandomNumberGenerator Derive(int index) { - return new DeterministicRandomNumberGenerator(HashCode.Combine(_seed, index)); + return new DeterministicRandomNumberGenerator(StableMix(_seed, index)); } -} \ No newline at end of file + + private static int StableMix(int seed, int index) + { + unchecked + { + var value = (uint)seed + 0x9E37_79B9u; + value ^= (uint)index + 0x85EB_CA6Bu + (value << 6) + (value >> 2); + value ^= value >> 16; + value *= 0x7FEB_352Du; + value ^= value >> 15; + value *= 0x846C_A68Bu; + value ^= value >> 16; + return (int)value; + } + } +} diff --git a/docs/changelog.md b/docs/changelog.md index 4f80cb8..3e4a493 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -15,6 +15,7 @@ This page mirrors the repository root `CHANGELOG.md`. ### Documentation - Clarify `UuidV7Factory` collision and clock-skew guarantees, including the distinction between per-instance deterministic monotonicity and probabilistic cross-factory uniqueness. +- Document the custom RNG contract for `UuidV7Factory`, including deterministic replay behavior and production CSPRNG guidance. ## [1.3.1] - 2026-04-02 diff --git a/docs/guide/uuidv7.md b/docs/guide/uuidv7.md index ab0a95a..8e4164c 100644 --- a/docs/guide/uuidv7.md +++ b/docs/guide/uuidv7.md @@ -15,6 +15,8 @@ var id = factory.NewGuid(); `UuidV7Factory` accepts any `TimeProvider`, including `TimeProvider.System` for production use and `SimulatedTimeProvider` for tests. +By default, each factory owns a cryptographically secure random number generator for UUID random-tail bytes. You can pass a custom `RandomNumberGenerator` for deterministic tests, but production factories should use independent CSPRNG state. + ## With Simulated Time ```csharp @@ -66,6 +68,22 @@ For production services, prefer a single `UuidV7Factory` singleton per process o For high-assurance shared namespaces, use a storage uniqueness constraint as the final guardrail and retry on conflict. If you need node-aware ordering semantics, consider `HlcGuidFactory`; it embeds a node ID and HLC timestamp, but it should be chosen for causal/node-aware ordering rather than treated as a blanket substitute for storage-level uniqueness. +## Custom RNGs and Deterministic Tests + +Custom RNG injection exists so tests and simulations can replay exact UUID sequences. Clockworks does not ship a deterministic RNG implementation; provide your own test-only `RandomNumberGenerator` when you need replay. + +Identical deterministic RNG state, identical time, and identical call patterns intentionally produce identical UUIDv7 output: + +```csharp +var time = SimulatedTimeProvider.FromUnixMs(1_700_000_000_000); +using RandomNumberGenerator rng = new MyDeterministicTestRng(seed: 42); // your test-only implementation +using var factory = new UuidV7Factory(time, rng); +``` + +This is useful for fixtures, demos, and simulation replay. It is not production entropy. In production, leave `rng` as `null` unless you are supplying a cryptographically secure generator with independent state for each factory. + +When modeling independent deterministic nodes in tests, derive a separate deterministic RNG stream per node instead of reusing the same seed directly. Reusing the same deterministic stream across factories is a way to model exact replay, not independent UUID issuance. + ## Counter Overflow Behavior The overflow behavior is configurable via the constructor: diff --git a/property-tests/UuidV7FactoryProperties.fs b/property-tests/UuidV7FactoryProperties.fs index 1d0f5c0..95bf97e 100644 --- a/property-tests/UuidV7FactoryProperties.fs +++ b/property-tests/UuidV7FactoryProperties.fs @@ -1,6 +1,7 @@ module Clockworks.PropertyTests.UuidV7FactoryProperties open System +open System.Security.Cryptography open System.Threading open System.Threading.Tasks open Xunit @@ -8,6 +9,20 @@ open FsCheck open FsCheck.Xunit open Clockworks +type private DeterministicRandomNumberGenerator(seed: int) = + inherit RandomNumberGenerator() + + let random = Random(seed) + + override _.GetBytes(data: byte[]) = + random.NextBytes(data) + + override _.GetBytes(data: Span) = + random.NextBytes(data) + + override _.GetBytes(data: byte[], offset: int, count: int) = + random.NextBytes(data.AsSpan(offset, count)) + /// Property: Sequential UUIDs should maintain monotonic ordering [] let ``Sequential UUIDs are monotonically increasing`` (count: uint16) = @@ -25,6 +40,22 @@ let ``Sequential UUIDs are monotonically increasing`` (count: uint16) = isMonotonic +/// Property: Deterministic RNG injection should replay the same UUID sequence for the same seed/time/call pattern. +[] +let ``Deterministic RNG replays UUID sequence`` (seed: int) (count: byte) = + let safeCount = int (count % 32uy) + 1 + let startMs = 1_700_000_000_000L + + use leftRng = new DeterministicRandomNumberGenerator(seed) + use rightRng = new DeterministicRandomNumberGenerator(seed) + use left = new UuidV7Factory(SimulatedTimeProvider.FromUnixMs(startMs), leftRng) + use right = new UuidV7Factory(SimulatedTimeProvider.FromUnixMs(startMs), rightRng) + + let leftIds = [| for _ in 1..safeCount -> left.NewGuid() |] + let rightIds = [| for _ in 1..safeCount -> right.NewGuid() |] + + leftIds = rightIds + /// Property: UUIDs generated at the same millisecond should differ only in counter/random parts [] let ``UUIDs at same millisecond have same timestamp prefix`` () = diff --git a/src/Extensions.cs b/src/Extensions.cs index 1b3af62..e0c5be2 100644 --- a/src/Extensions.cs +++ b/src/Extensions.cs @@ -35,6 +35,12 @@ public IServiceCollection AddLockFreeGuidFactory( /// Use this for testing or simulation. Registers a singleton factory so per-instance monotonic state is shared /// across callers in the process. /// + /// Time source used by the UUIDv7 factory. + /// + /// Random number generator used for the UUID random tail. Leave null for a per-factory CSPRNG. Seeded or + /// deterministic RNGs are intended only for reproducible tests and simulations. + /// + /// Behavior to apply when the per-millisecond counter overflows. public IServiceCollection AddLockFreeGuidFactory( TimeProvider timeProvider, RandomNumberGenerator? rng = null, diff --git a/src/UuidV7Factory.cs b/src/UuidV7Factory.cs index 8b16070..3c66796 100644 --- a/src/UuidV7Factory.cs +++ b/src/UuidV7Factory.cs @@ -78,7 +78,8 @@ public sealed class UuidV7Factory : IUuidV7Factory, IDisposable /// /// Random number generator to use for the random portion of the UUID. If , a new /// cryptographically-secure RNG is created and owned by this instance. Production deployments should use a - /// cryptographically strong RNG with independent state for each factory. + /// cryptographically strong RNG with independent state for each factory. Seeded or deterministic RNGs are intended + /// only for reproducible tests and simulations. /// /// Behavior to apply when the per-millisecond counter overflows. public UuidV7Factory( diff --git a/tests/DeterministicComponents.cs b/tests/DeterministicComponents.cs index 40bcd9d..aec7e74 100644 --- a/tests/DeterministicComponents.cs +++ b/tests/DeterministicComponents.cs @@ -2,7 +2,7 @@ namespace Clockworks.Tests; -// +/// /// Deterministic pseudo-random number generator for test replay and simulation. /// /// @@ -16,10 +16,9 @@ namespace Clockworks.Tests; /// /// /// -/// Mathematical Basis: -/// Uses .NET's implementation of xoshiro256** algorithm internally. -/// Period: 2^256 - 1 -/// Good statistical properties for testing, but predictable. +/// Implementation Notes: +/// Uses internally. The exact PRNG algorithm is a runtime detail and may change between .NET +/// versions. Determinism is intended for same-runtime replay in tests/simulations, not cross-version reproducibility. /// /// public sealed class DeterministicRandomNumberGenerator : RandomNumberGenerator @@ -73,12 +72,27 @@ public override void GetNonZeroBytes(Span data) } } - // + /// /// Create a new generator with a derived seed for parallel test isolation. /// public DeterministicRandomNumberGenerator Derive(int index) { - return new DeterministicRandomNumberGenerator(HashCode.Combine(_seed, index)); + return new DeterministicRandomNumberGenerator(StableMix(_seed, index)); + } + + private static int StableMix(int seed, int index) + { + unchecked + { + var value = (uint)seed + 0x9E37_79B9u; + value ^= (uint)index + 0x85EB_CA6Bu + (value << 6) + (value >> 2); + value ^= value >> 16; + value *= 0x7FEB_352Du; + value ^= value >> 15; + value *= 0x846C_A68Bu; + value ^= value >> 16; + return (int)value; + } } } @@ -111,11 +125,12 @@ public static (HlcGuidFactory[] Factories, SimulatedTimeProvider[] Times) { var factories = new HlcGuidFactory[nodeCount]; var times = new SimulatedTimeProvider[nodeCount]; + using var baseRng = new DeterministicRandomNumberGenerator(baseSeed); for (int i = 0; i < nodeCount; i++) { times[i] = SimulatedTimeProvider.FromUnixMs(startTimeUnixMs); - var rng = new DeterministicRandomNumberGenerator(HashCode.Combine(baseSeed, i)); + var rng = baseRng.Derive(i); factories[i] = new HlcGuidFactory(times[i], (ushort)i, rng: rng); } diff --git a/tests/UuidV7FactoryTests.cs b/tests/UuidV7FactoryTests.cs index ed7a4fa..432069d 100644 --- a/tests/UuidV7FactoryTests.cs +++ b/tests/UuidV7FactoryTests.cs @@ -4,6 +4,58 @@ namespace Clockworks.Tests; public sealed class UuidV7FactoryTests { + [Fact] + public void NewGuid_ReplaysSameSequence_WhenDeterministicFactoriesUseSameSeedAndTime() + { + const long startMs = 1_700_000_000_000; + const int count = 32; + + using var leftRng = new DeterministicRandomNumberGenerator(seed: 42); + using var rightRng = new DeterministicRandomNumberGenerator(seed: 42); + using var left = new UuidV7Factory(SimulatedTimeProvider.FromUnixMs(startMs), leftRng); + using var right = new UuidV7Factory(SimulatedTimeProvider.FromUnixMs(startMs), rightRng); + + var leftIds = new Guid[count]; + var rightIds = new Guid[count]; + + for (var i = 0; i < count; i++) + { + leftIds[i] = left.NewGuid(); + rightIds[i] = right.NewGuid(); + } + + Assert.Equal(leftIds, rightIds); + } + + [Fact] + public void NewGuid_UsesIndependentReplayableStreams_WhenDeterministicFactoriesUseDerivedSeeds() + { + const long startMs = 1_700_000_000_000; + const int count = 32; + + using var baseRng = new DeterministicRandomNumberGenerator(seed: 42); + using var replayBaseRng = new DeterministicRandomNumberGenerator(seed: 42); + + var leftIds = GenerateWithRng(baseRng.Derive(0)); + var rightIds = GenerateWithRng(baseRng.Derive(1)); + var leftReplayIds = GenerateWithRng(replayBaseRng.Derive(0)); + + Assert.NotEqual(leftIds, rightIds); + Assert.Equal(leftIds, leftReplayIds); + + static Guid[] GenerateWithRng(DeterministicRandomNumberGenerator rng) + { + using (rng) + using (var factory = new UuidV7Factory(SimulatedTimeProvider.FromUnixMs(startMs), rng)) + { + var ids = new Guid[count]; + for (var i = 0; i < ids.Length; i++) + ids[i] = factory.NewGuid(); + return ids; + } + } + } + [Fact] public void NewGuids_IsMonotonic_AndUnique_WhenTimeDoesNotAdvance() {