Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions demo/Clockworks.Demo/Demos/UuidV7Showcase.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
28 changes: 21 additions & 7 deletions demo/Clockworks.Demo/DeterministicRandomNumberGenerator.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

namespace Clockworks.Demo;

// <summary>
/// <summary>
/// Deterministic pseudo-random number generator for test replay and simulation.
///
/// <para>
Expand All @@ -16,10 +16,9 @@ namespace Clockworks.Demo;
/// </para>
///
/// <para>
/// <b>Mathematical Basis:</b>
/// Uses .NET's implementation of xoshiro256** algorithm internally.
/// Period: 2^256 - 1
/// Good statistical properties for testing, but predictable.
/// <b>Implementation Notes:</b>
/// Uses <see cref="Random"/> 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.
/// </para>
/// </summary>
internal sealed class DeterministicRandomNumberGenerator : RandomNumberGenerator
Expand Down Expand Up @@ -78,6 +77,21 @@ public override void GetNonZeroBytes(Span<byte> data)
/// </summary>
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;
}
}
}
1 change: 1 addition & 0 deletions docs/changelog.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
18 changes: 18 additions & 0 deletions docs/guide/uuidv7.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand Down
31 changes: 31 additions & 0 deletions property-tests/UuidV7FactoryProperties.fs
Original file line number Diff line number Diff line change
@@ -1,13 +1,28 @@
module Clockworks.PropertyTests.UuidV7FactoryProperties

open System
open System.Security.Cryptography
open System.Threading
open System.Threading.Tasks
open Xunit
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<byte>) =
random.NextBytes(data)

override _.GetBytes(data: byte[], offset: int, count: int) =
random.NextBytes(data.AsSpan(offset, count))

/// Property: Sequential UUIDs should maintain monotonic ordering
[<Property(MaxTest = 100)>]
let ``Sequential UUIDs are monotonically increasing`` (count: uint16) =
Expand All @@ -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.
[<Property(MaxTest = 50)>]
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
[<Property(MaxTest = 50)>]
let ``UUIDs at same millisecond have same timestamp prefix`` () =
Expand Down
6 changes: 6 additions & 0 deletions src/Extensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
/// </summary>
/// <param name="timeProvider">Time source used by the UUIDv7 factory.</param>
/// <param name="rng">
/// 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.
/// </param>
/// <param name="overflowBehavior">Behavior to apply when the per-millisecond counter overflows.</param>
public IServiceCollection AddLockFreeGuidFactory(
TimeProvider timeProvider,
RandomNumberGenerator? rng = null,
Expand Down
3 changes: 2 additions & 1 deletion src/UuidV7Factory.cs
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,8 @@ public sealed class UuidV7Factory : IUuidV7Factory, IDisposable
/// <param name="rng">
/// Random number generator to use for the random portion of the UUID. If <see langword="null"/>, 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.
/// </param>
/// <param name="overflowBehavior">Behavior to apply when the per-millisecond counter overflows.</param>
public UuidV7Factory(
Expand Down
31 changes: 23 additions & 8 deletions tests/DeterministicComponents.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

namespace Clockworks.Tests;

// <summary>
/// <summary>
/// Deterministic pseudo-random number generator for test replay and simulation.
///
/// <para>
Expand All @@ -16,10 +16,9 @@ namespace Clockworks.Tests;
/// </para>
///
/// <para>
/// <b>Mathematical Basis:</b>
/// Uses .NET's implementation of xoshiro256** algorithm internally.
/// Period: 2^256 - 1
/// Good statistical properties for testing, but predictable.
/// <b>Implementation Notes:</b>
/// Uses <see cref="Random"/> 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.
/// </para>
/// </summary>
public sealed class DeterministicRandomNumberGenerator : RandomNumberGenerator
Expand Down Expand Up @@ -73,12 +72,27 @@ public override void GetNonZeroBytes(Span<byte> data)
}
}

// <summary>
/// <summary>
/// Create a new generator with a derived seed for parallel test isolation.
/// </summary>
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;
}
}
}

Expand Down Expand Up @@ -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);
}

Expand Down
52 changes: 52 additions & 0 deletions tests/UuidV7FactoryTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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()
{
Expand Down
Loading