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
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,14 @@ and this project aims to follow [Semantic Versioning](https://semver.org/spec/v2

## [Unreleased]

### Added
- Add opt-in `UuidV7FactoryStatistics` counters for generated UUIDs, clock rollback, counter overflow, spin-wait, logical drift, CAS retries, and random-buffer refills.

### Changed
- `HlcGuidFactory` constructor now enforces a 14-bit node ID constraint and throws `ArgumentOutOfRangeException` for values above `HlcGuidFactory.MaxNodeId` (16383). Previously, higher values were silently truncated in generated UUIDv7 values.

### Documentation
- Document UUIDv7 factory statistics and counter semantics.
- 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.

Expand Down
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ It is built around `TimeProvider` so that *time becomes an injectable dependency
- `UuidV7Factory` produces RFC 9562 UUIDv7 values as `Guid`
- Works with real or simulated time
- Configurable counter overflow behavior
- Optional statistics for rollback, overflow, spin-wait, contention, and random-buffer refill diagnostics
- Per-instance monotonicity under clock rollback; cross-factory uniqueness remains probabilistic unless coordinated externally

- **Hybrid Logical Clock (HLC)**
Expand All @@ -35,7 +36,7 @@ It is built around `TimeProvider` so that *time becomes an injectable dependency
- Canonical binary wire format (`VectorClock.WriteTo`/`ReadFrom`) and string form for HTTP/gRPC headers

- **Lightweight instrumentation**
- Counters for timers, advances, and timeouts useful in simulation/test assertions
- Counters for timers, advances, timeouts, and UUIDv7 factory behavior useful in simulation/test assertions

## Installation

Expand Down
28 changes: 28 additions & 0 deletions demo/Clockworks.Demo/Demos/UuidV7Showcase.cs
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
using System.Diagnostics;
using Clockworks;
using Clockworks.Distributed;
using Clockworks.Instrumentation;

namespace Clockworks.Demo.Demos;

Expand Down Expand Up @@ -194,6 +195,7 @@ private static async Task RunBenchmarks()

Console.WriteLine();
Console.WriteLine("Lock-Free Factory (single-threaded):");
double disabledNsPerOp;
using (var factory = new UuidV7Factory(TimeProvider.System))
{
var sw = Stopwatch.StartNew();
Expand All @@ -206,9 +208,35 @@ private static async Task RunBenchmarks()
var seconds = sw.Elapsed.TotalSeconds;
var opsPerSec = BenchmarkIterations / seconds;
var nsPerOp = sw.Elapsed.TotalNanoseconds / BenchmarkIterations;
disabledNsPerOp = nsPerOp;
Console.WriteLine($" {opsPerSec:N0} ops/sec ({nsPerOp:N1} ns/op)");
}

Console.WriteLine();
Console.WriteLine("Lock-Free Factory + statistics (single-threaded):");
{
var statistics = new UuidV7FactoryStatistics();
using var factory = new UuidV7Factory(
TimeProvider.System,
rng: null,
CounterOverflowBehavior.SpinWait,
statistics);

var sw = Stopwatch.StartNew();
for (int i = 0; i < BenchmarkIterations; i++)
{
_ = factory.NewGuid();
}
sw.Stop();

var seconds = sw.Elapsed.TotalSeconds;
var opsPerSec = BenchmarkIterations / seconds;
var nsPerOp = sw.Elapsed.TotalNanoseconds / BenchmarkIterations;
var overhead = (nsPerOp / disabledNsPerOp - 1.0) * 100.0;
Console.WriteLine($" {opsPerSec:N0} ops/sec ({nsPerOp:N1} ns/op, {overhead:N1}% vs disabled)");
Console.WriteLine($" Generated: {statistics.GeneratedCount:N0}, CAS retries: {statistics.CasRetryCount:N0}");
}

Console.WriteLine();
Console.WriteLine($"Lock-Free Factory ({ThreadCount} threads contended):");
using (var factory = new UuidV7Factory(TimeProvider.System))
Expand Down
4 changes: 4 additions & 0 deletions docs/changelog.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,14 @@ This page mirrors the repository root `CHANGELOG.md`.

## [Unreleased]

### Added
- Add opt-in `UuidV7FactoryStatistics` counters for generated UUIDs, clock rollback, counter overflow, spin-wait, logical drift, CAS retries, and random-buffer refills.

### Changed
- `HlcGuidFactory` constructor now enforces a 14-bit node ID constraint and throws `ArgumentOutOfRangeException` for values above `HlcGuidFactory.MaxNodeId` (16383). Previously, higher values were silently truncated in generated UUIDv7 values.

### Documentation
- Document UUIDv7 factory statistics and counter semantics.
- 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.

Expand Down
40 changes: 39 additions & 1 deletion docs/guide/instrumentation.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,45 @@ You can reset counters between test phases:
tp.Statistics.Reset();
```

## UUIDv7 factory statistics

`UuidV7Factory` can update opt-in `UuidV7FactoryStatistics` counters:

```csharp
var stats = new UuidV7FactoryStatistics();
using var factory = new UuidV7Factory(
TimeProvider.System,
rng: null,
overflowBehavior: CounterOverflowBehavior.SpinWait,
statistics: stats);

factory.NewGuid();

var snapshot = stats.Snapshot();
Console.WriteLine(snapshot.GeneratedCount);
Console.WriteLine(snapshot.ClockRollbackCount);
Console.WriteLine(snapshot.CasRetryCount);
```

Statistics are disabled unless you pass an instance to the factory. When enabled, counters use atomic operations so they can be read safely while UUIDs are being generated concurrently. `Snapshot()` and `Reset()` operate counter-by-counter; they are suitable for diagnostics and phase-isolated tests, not as a linearizable multi-counter transaction.

Useful counters include:

- `GeneratedCount`
- `ClockRollbackCount`
- `CounterOverflowCount`
- `SpinWaitCount`
- `LogicalTimestampAdvanceCount`
- `MaxLogicalDriftMs`
- `CasRetryCount`
- `RandomBufferRefillCount`

Use `Snapshot()` when you need a point-in-time value object, and `Reset()` to isolate test phases:

```csharp
stats.Reset();
```

## Timeout statistics

Timeout factory helpers (`Timeouts`) can record aggregate timeout activity via `TimeoutStatistics`.
Expand All @@ -58,4 +97,3 @@ If you prefer isolation per test (or per component), pass your own `TimeoutStati
var stats = new TimeoutStatistics();
using var handle = Timeouts.CreateTimeoutHandle(tp, TimeSpan.FromSeconds(1), stats);
```

42 changes: 42 additions & 0 deletions docs/guide/uuidv7.md
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,46 @@ 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.

## Statistics

`UuidV7Factory` statistics are opt-in. Leave them disabled for the lowest-overhead path, or pass a `UuidV7FactoryStatistics` instance when you want to observe clock rollback, counter overflow, spin-wait pressure, and lock-free contention:

```csharp
var stats = new UuidV7FactoryStatistics();
using var factory = new UuidV7Factory(
TimeProvider.System,
rng: null,
overflowBehavior: CounterOverflowBehavior.SpinWait,
statistics: stats);

var id = factory.NewGuid();
var snapshot = stats.Snapshot();

Console.WriteLine(snapshot.GeneratedCount);
Console.WriteLine(snapshot.CounterOverflowCount);
```

The built-in DI helpers can register the same shared statistics object as the singleton factory:

```csharp
services.AddLockFreeGuidFactory(new UuidV7FactoryStatistics());
```

Key counters:

| Counter | Meaning |
|---|---|
| `GeneratedCount` | Number of UUIDs successfully generated. Batch generation increments by the span length. |
| `ClockRollbackCount` | Successful allocation decisions made while physical time was behind the factory's logical frontier. |
| `CounterOverflowCount` | Times the 12-bit per-millisecond counter overflow path was reached. |
| `SpinWaitCount` | Times overflow handling had to wait for physical time to advance. |
| `LogicalTimestampAdvanceCount` | Successful allocation decisions that emitted logical time ahead of physical time. |
| `MaxLogicalDriftMs` | Maximum observed distance between emitted logical time and physical time. |
| `CasRetryCount` | Failed compare-exchange attempts in the lock-free allocation loop. |
| `RandomBufferRefillCount` | Thread-local random buffer refills. |

Most counters are diagnostic event counts, not rates. For example, a single `NewGuids(span)` call may reserve many UUIDs with one successful allocation decision, so `GeneratedCount` increases by `span.Length` while rollback or drift counters increase once for that reservation. Under lock-free contention, overflow and spin-wait counters describe observed path entries and wait attempts rather than a globally serialized event log.

## 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.
Expand Down Expand Up @@ -162,3 +202,5 @@ dotnet run --project demo/Clockworks.Demo -- uuidv7-sortability
# Benchmark mode
dotnet run --project demo/Clockworks.Demo -- uuidv7 --bench
```

Benchmark mode includes a side-by-side single-threaded comparison of the default hot path and statistics-enabled hot path.
42 changes: 42 additions & 0 deletions property-tests/UuidV7FactoryProperties.fs
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ open Xunit
open FsCheck
open FsCheck.Xunit
open Clockworks
open Clockworks.Instrumentation

type private DeterministicRandomNumberGenerator(seed: int) =
inherit RandomNumberGenerator()
Expand Down Expand Up @@ -157,6 +158,47 @@ let ``UUIDs are unique`` (count: byte) =

uniqueCount = safeCount

/// Property: UUIDv7 statistics count every successfully generated UUID exactly once.
[<Property(MaxTest = 50)>]
let ``Statistics generated count matches successful UUID generation`` (count: uint16) =
let safeCount = int (count % 512us) + 1
let statistics = UuidV7FactoryStatistics()
let timeProvider = new SimulatedTimeProvider()
use factory =
new UuidV7Factory(
timeProvider,
null,
CounterOverflowBehavior.IncrementTimestamp,
statistics)

statistics.Reset()

for _ in 1..safeCount do
factory.NewGuid() |> ignore

statistics.Snapshot().GeneratedCount = int64 safeCount

/// Property: UUIDv7 statistics expose the maximum logical drift caused by wall-clock rollback.
[<Property(MaxTest = 50)>]
let ``Statistics max drift tracks wall clock rollback`` (rollbackMs: uint16) =
let safeRollbackMs = int64 (rollbackMs % 1000us) + 1L
let startMs = 1_700_000_000_000L
let statistics = UuidV7FactoryStatistics()
let timeProvider = SimulatedTimeProvider.FromUnixMs(startMs)
use factory = new UuidV7Factory(timeProvider, null, CounterOverflowBehavior.SpinWait, statistics)

statistics.Reset()

factory.NewGuid() |> ignore
timeProvider.SetUnixMs(startMs - safeRollbackMs)
factory.NewGuid() |> ignore

let snapshot = statistics.Snapshot()
snapshot.GeneratedCount = 2L
&& snapshot.ClockRollbackCount = 1L
&& snapshot.LogicalTimestampAdvanceCount = 1L
&& snapshot.MaxLogicalDriftMs = safeRollbackMs

/// Property: UUIDs generated at different times are different
[<Property(MaxTest = 50)>]
let ``UUIDs change with time`` (advanceMs: uint16) =
Expand Down
68 changes: 66 additions & 2 deletions src/Extensions.cs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
using Clockworks.Abstractions;
using Clockworks.Distributed;
using Clockworks.Instrumentation;
using System.Security.Cryptography;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
Expand Down Expand Up @@ -30,24 +31,87 @@ public IServiceCollection AddLockFreeGuidFactory(
return services;
}

/// <summary>
/// Adds the lock-free GUID factory with system time and opt-in statistics.
/// </summary>
/// <param name="statistics">Statistics instance updated by the registered singleton factory.</param>
/// <param name="overflowBehavior">Behavior to apply when the per-millisecond counter overflows.</param>
public IServiceCollection AddLockFreeGuidFactory(
UuidV7FactoryStatistics statistics,
CounterOverflowBehavior overflowBehavior = CounterOverflowBehavior.SpinWait)
{
ArgumentNullException.ThrowIfNull(statistics);

services.TryAddSingleton(TimeProvider.System);
services.AddSingleton(statistics);
services.AddSingleton<IUuidV7Factory>(sp => new UuidV7Factory(
sp.GetRequiredService<TimeProvider>(),
rng: null,
overflowBehavior: overflowBehavior,
statistics: sp.GetRequiredService<UuidV7FactoryStatistics>()));
services.AddSingleton(sp => (UuidV7Factory)sp.GetRequiredService<IUuidV7Factory>());

return services;
}

/// <summary>
/// Adds the lock-free GUID factory with a custom TimeProvider.
/// Use this for testing or simulation. Registers a singleton factory so per-instance monotonic state is shared
/// across callers in the process.
/// across callers in the process. The service provider disposes the created factory when the provider is
/// disposed; externally supplied <paramref name="timeProvider"/> and <paramref name="rng"/> instances remain
/// caller-owned.
/// </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,
CounterOverflowBehavior overflowBehavior = CounterOverflowBehavior.SpinWait)
{
ArgumentNullException.ThrowIfNull(timeProvider);

services.TryAddSingleton(timeProvider);
services.AddSingleton<IUuidV7Factory>(sp => new UuidV7Factory(
sp.GetRequiredService<TimeProvider>(),
rng,
overflowBehavior));
services.AddSingleton(sp => (UuidV7Factory)sp.GetRequiredService<IUuidV7Factory>());

return services;
}

/// <summary>
/// Adds the lock-free GUID factory with a custom TimeProvider and opt-in statistics.
/// The service provider disposes the created factory when the provider is disposed; externally supplied
/// <paramref name="timeProvider"/> and <paramref name="rng"/> instances remain caller-owned.
/// </summary>
/// <param name="timeProvider">Time source used by the UUIDv7 factory.</param>
/// <param name="statistics">Statistics instance updated by the registered singleton 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,
UuidV7FactoryStatistics statistics,
RandomNumberGenerator? rng = null,
CounterOverflowBehavior overflowBehavior = CounterOverflowBehavior.SpinWait)
{
ArgumentNullException.ThrowIfNull(timeProvider);
ArgumentNullException.ThrowIfNull(statistics);

services.TryAddSingleton(timeProvider);
services.AddSingleton<IUuidV7Factory>(new UuidV7Factory(timeProvider, rng, overflowBehavior));
services.AddSingleton(statistics);
services.AddSingleton<IUuidV7Factory>(sp => new UuidV7Factory(
sp.GetRequiredService<TimeProvider>(),
rng,
overflowBehavior,
sp.GetRequiredService<UuidV7FactoryStatistics>()));
services.AddSingleton(sp => (UuidV7Factory)sp.GetRequiredService<IUuidV7Factory>());

return services;
Expand Down
Loading
Loading