diff --git a/CHANGELOG.md b/CHANGELOG.md index 9e88988..3c86001 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/README.md b/README.md index e5a6b23..a1c3e05 100644 --- a/README.md +++ b/README.md @@ -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)** @@ -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 diff --git a/demo/Clockworks.Demo/Demos/UuidV7Showcase.cs b/demo/Clockworks.Demo/Demos/UuidV7Showcase.cs index 939064a..0dec651 100644 --- a/demo/Clockworks.Demo/Demos/UuidV7Showcase.cs +++ b/demo/Clockworks.Demo/Demos/UuidV7Showcase.cs @@ -1,6 +1,7 @@ using System.Diagnostics; using Clockworks; using Clockworks.Distributed; +using Clockworks.Instrumentation; namespace Clockworks.Demo.Demos; @@ -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(); @@ -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)) diff --git a/docs/changelog.md b/docs/changelog.md index 3e4a493..cb05fd3 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -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. diff --git a/docs/guide/instrumentation.md b/docs/guide/instrumentation.md index 7d390a9..7fa8116 100644 --- a/docs/guide/instrumentation.md +++ b/docs/guide/instrumentation.md @@ -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`. @@ -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); ``` - diff --git a/docs/guide/uuidv7.md b/docs/guide/uuidv7.md index 8e4164c..7bb7ef2 100644 --- a/docs/guide/uuidv7.md +++ b/docs/guide/uuidv7.md @@ -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. @@ -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. diff --git a/property-tests/UuidV7FactoryProperties.fs b/property-tests/UuidV7FactoryProperties.fs index 95bf97e..5a1eb30 100644 --- a/property-tests/UuidV7FactoryProperties.fs +++ b/property-tests/UuidV7FactoryProperties.fs @@ -8,6 +8,7 @@ open Xunit open FsCheck open FsCheck.Xunit open Clockworks +open Clockworks.Instrumentation type private DeterministicRandomNumberGenerator(seed: int) = inherit RandomNumberGenerator() @@ -157,6 +158,47 @@ let ``UUIDs are unique`` (count: byte) = uniqueCount = safeCount +/// Property: UUIDv7 statistics count every successfully generated UUID exactly once. +[] +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. +[] +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 [] let ``UUIDs change with time`` (advanceMs: uint16) = diff --git a/src/Extensions.cs b/src/Extensions.cs index e0c5be2..9efdb1a 100644 --- a/src/Extensions.cs +++ b/src/Extensions.cs @@ -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; @@ -30,12 +31,66 @@ public IServiceCollection AddLockFreeGuidFactory( return services; } + /// + /// Adds the lock-free GUID factory with system time and opt-in statistics. + /// + /// Statistics instance updated by the registered singleton factory. + /// Behavior to apply when the per-millisecond counter overflows. + public IServiceCollection AddLockFreeGuidFactory( + UuidV7FactoryStatistics statistics, + CounterOverflowBehavior overflowBehavior = CounterOverflowBehavior.SpinWait) + { + ArgumentNullException.ThrowIfNull(statistics); + + services.TryAddSingleton(TimeProvider.System); + services.AddSingleton(statistics); + services.AddSingleton(sp => new UuidV7Factory( + sp.GetRequiredService(), + rng: null, + overflowBehavior: overflowBehavior, + statistics: sp.GetRequiredService())); + services.AddSingleton(sp => (UuidV7Factory)sp.GetRequiredService()); + + return services; + } + /// /// 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 and instances remain + /// caller-owned. + /// + /// 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, + CounterOverflowBehavior overflowBehavior = CounterOverflowBehavior.SpinWait) + { + ArgumentNullException.ThrowIfNull(timeProvider); + + services.TryAddSingleton(timeProvider); + services.AddSingleton(sp => new UuidV7Factory( + sp.GetRequiredService(), + rng, + overflowBehavior)); + services.AddSingleton(sp => (UuidV7Factory)sp.GetRequiredService()); + + return services; + } + + /// + /// 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 + /// and instances remain caller-owned. /// /// Time source used by the UUIDv7 factory. + /// Statistics instance updated by the registered singleton 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. @@ -43,11 +98,20 @@ public IServiceCollection AddLockFreeGuidFactory( /// Behavior to apply when the per-millisecond counter overflows. 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(new UuidV7Factory(timeProvider, rng, overflowBehavior)); + services.AddSingleton(statistics); + services.AddSingleton(sp => new UuidV7Factory( + sp.GetRequiredService(), + rng, + overflowBehavior, + sp.GetRequiredService())); services.AddSingleton(sp => (UuidV7Factory)sp.GetRequiredService()); return services; diff --git a/src/Instrumentation/UuidV7FactoryStatistics.cs b/src/Instrumentation/UuidV7FactoryStatistics.cs new file mode 100644 index 0000000..2080d07 --- /dev/null +++ b/src/Instrumentation/UuidV7FactoryStatistics.cs @@ -0,0 +1,140 @@ +namespace Clockworks.Instrumentation; + +/// +/// Lightweight counters for observing behavior. +/// +/// +/// Statistics are opt-in. Passing an instance to enables atomic counter updates on the +/// UUIDv7 generation path; leaving statistics unset avoids those atomic updates. Counters are diagnostic signals: under +/// lock-free contention, event counters such as and +/// describe observed path entries and wait attempts rather than globally serialized allocation decisions. +/// +public sealed class UuidV7FactoryStatistics +{ + private long _generatedCount; + private long _clockRollbackCount; + private long _counterOverflowCount; + private long _spinWaitCount; + private long _logicalTimestampAdvanceCount; + private long _maxLogicalDriftMs; + private long _casRetryCount; + private long _randomBufferRefillCount; + + /// + /// Total number of UUIDs generated. + /// + public long GeneratedCount => Volatile.Read(ref _generatedCount); + + /// + /// Number of successful allocation decisions made while physical time was behind the factory's logical frontier. + /// + public long ClockRollbackCount => Volatile.Read(ref _clockRollbackCount); + + /// + /// Number of times the 12-bit per-millisecond counter overflow path was reached. + /// + public long CounterOverflowCount => Volatile.Read(ref _counterOverflowCount); + + /// + /// Number of times generation had to wait for physical time to advance after counter overflow. + /// + public long SpinWaitCount => Volatile.Read(ref _spinWaitCount); + + /// + /// Number of successful allocation decisions that emitted a logical timestamp ahead of physical time. + /// + public long LogicalTimestampAdvanceCount => Volatile.Read(ref _logicalTimestampAdvanceCount); + + /// + /// Maximum observed distance, in milliseconds, between emitted logical time and physical time. + /// + public long MaxLogicalDriftMs => Volatile.Read(ref _maxLogicalDriftMs); + + /// + /// Number of failed compare-exchange attempts in the lock-free allocation loop. + /// + public long CasRetryCount => Volatile.Read(ref _casRetryCount); + + /// + /// Number of thread-local random buffer refills. + /// + public long RandomBufferRefillCount => Volatile.Read(ref _randomBufferRefillCount); + + /// + /// Captures a point-in-time snapshot of all UUIDv7 factory counters. + /// + /// + /// Each field is read atomically, but the snapshot is not a linearizable transaction across all counters. + /// + public UuidV7FactoryStatisticsSnapshot Snapshot() => new( + GeneratedCount: GeneratedCount, + ClockRollbackCount: ClockRollbackCount, + CounterOverflowCount: CounterOverflowCount, + SpinWaitCount: SpinWaitCount, + LogicalTimestampAdvanceCount: LogicalTimestampAdvanceCount, + MaxLogicalDriftMs: MaxLogicalDriftMs, + CasRetryCount: CasRetryCount, + RandomBufferRefillCount: RandomBufferRefillCount); + + /// + /// Resets all counters to zero. + /// + /// + /// Each counter is reset atomically, but the reset is not a linearizable transaction across all counters. + /// + public void Reset() + { + Interlocked.Exchange(ref _generatedCount, 0); + Interlocked.Exchange(ref _clockRollbackCount, 0); + Interlocked.Exchange(ref _counterOverflowCount, 0); + Interlocked.Exchange(ref _spinWaitCount, 0); + Interlocked.Exchange(ref _logicalTimestampAdvanceCount, 0); + Interlocked.Exchange(ref _maxLogicalDriftMs, 0); + Interlocked.Exchange(ref _casRetryCount, 0); + Interlocked.Exchange(ref _randomBufferRefillCount, 0); + } + + internal void RecordGenerated(long count) => Interlocked.Add(ref _generatedCount, count); + + internal void RecordClockRollback() => Interlocked.Increment(ref _clockRollbackCount); + + internal void RecordCounterOverflow() => Interlocked.Increment(ref _counterOverflowCount); + + internal void RecordSpinWait() => Interlocked.Increment(ref _spinWaitCount); + + internal void RecordLogicalTimestampAdvance(long driftMs) + { + Interlocked.Increment(ref _logicalTimestampAdvanceCount); + InterlockedMax(ref _maxLogicalDriftMs, driftMs); + } + + internal void RecordCasRetry() => Interlocked.Increment(ref _casRetryCount); + + internal void RecordRandomBufferRefill() => Interlocked.Increment(ref _randomBufferRefillCount); + + private static void InterlockedMax(ref long location, long value) + { + var current = Volatile.Read(ref location); + while (value > current) + { + var previous = Interlocked.CompareExchange(ref location, value, current); + if (previous == current) + break; + + current = previous; + } + } +} + +/// +/// Point-in-time UUIDv7 factory statistics. +/// +public readonly record struct UuidV7FactoryStatisticsSnapshot( + long GeneratedCount, + long ClockRollbackCount, + long CounterOverflowCount, + long SpinWaitCount, + long LogicalTimestampAdvanceCount, + long MaxLogicalDriftMs, + long CasRetryCount, + long RandomBufferRefillCount); diff --git a/src/UuidV7Factory.cs b/src/UuidV7Factory.cs index 3c66796..3e2046d 100644 --- a/src/UuidV7Factory.cs +++ b/src/UuidV7Factory.cs @@ -1,4 +1,5 @@ using Clockworks.Abstractions; +using Clockworks.Instrumentation; using System.Buffers.Binary; using System.Diagnostics; using System.Runtime.CompilerServices; @@ -51,6 +52,7 @@ public sealed class UuidV7Factory : IUuidV7Factory, IDisposable private readonly bool _ownsRng; private readonly CounterOverflowBehavior _overflowBehavior; private readonly CounterOverflowBehavior _effectiveOverflowBehavior; + private readonly UuidV7FactoryStatistics? _statistics; // Packed state: [48 bits timestamp][16 bits counter] // Using 64-bit atomic operations for lock-free updates @@ -86,17 +88,39 @@ public UuidV7Factory( TimeProvider timeProvider, RandomNumberGenerator? rng = null, CounterOverflowBehavior overflowBehavior = CounterOverflowBehavior.SpinWait) + : this(timeProvider, rng, overflowBehavior, statistics: null) + { + } + + /// + /// Creates a new UUIDv7 generator with optional statistics. + /// + /// Time source (use for production). + /// + /// 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. Seeded or deterministic RNGs are intended + /// only for reproducible tests and simulations. + /// + /// Behavior to apply when the per-millisecond counter overflows. + /// Statistics instance to update from this factory, or to disable statistics. + public UuidV7Factory( + TimeProvider timeProvider, + RandomNumberGenerator? rng, + CounterOverflowBehavior overflowBehavior, + UuidV7FactoryStatistics? statistics) { _timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider)); _rng = rng ?? RandomNumberGenerator.Create(); _ownsRng = rng is null; _overflowBehavior = overflowBehavior; + _statistics = statistics; _effectiveOverflowBehavior = overflowBehavior == CounterOverflowBehavior.Auto ? (_timeProvider is SimulatedTimeProvider ? CounterOverflowBehavior.IncrementTimestamp : CounterOverflowBehavior.SpinWait) : overflowBehavior; - _randomBuffer = new ThreadLocal(() => new RandomBuffer(_rng), trackAllValues: false); + _randomBuffer = new ThreadLocal(() => new RandomBuffer(_rng, _statistics), trackAllValues: false); // Initialize state with current time and random counter var initialTimestamp = _timeProvider.GetUtcNow().ToUnixTimeMilliseconds(); @@ -104,19 +128,28 @@ public UuidV7Factory( _packedState = PackState(initialTimestamp, initialCounter); } + /// + /// Statistics instance updated by this factory, or when statistics are disabled. + /// + public UuidV7FactoryStatistics? Statistics => _statistics; + /// [MethodImpl(MethodImplOptions.AggressiveInlining)] public Guid NewGuid() { var (timestampMs, counter) = AllocateTimestampAndCounter(); - return CreateGuidFromState(timestampMs, counter); + var guid = CreateGuidFromState(timestampMs, counter); + _statistics?.RecordGenerated(1); + return guid; } /// public (Guid Guid, long TimestampMs) NewGuidWithTimestamp() { var (timestampMs, counter) = AllocateTimestampAndCounter(); - return (CreateGuidFromState(timestampMs, counter), timestampMs); + var guid = CreateGuidFromState(timestampMs, counter); + _statistics?.RecordGenerated(1); + return (guid, timestampMs); } /// @@ -139,6 +172,7 @@ private void FillGuids(Span destination) var (currentTimestamp, currentCounter) = UnpackState(currentPacked); var physicalTime = _timeProvider.GetUtcNow().ToUnixTimeMilliseconds(); + var clockRollback = physicalTime < currentTimestamp; var baseTimestamp = physicalTime > currentTimestamp ? physicalTime : currentTimestamp; @@ -149,9 +183,11 @@ private void FillGuids(Span destination) if (startCounter > MaxCounterValue) { // Counter overflow at this millisecond. + _statistics?.RecordCounterOverflow(); switch (_effectiveOverflowBehavior) { case CounterOverflowBehavior.SpinWait: + _statistics?.RecordSpinWait(); SpinWaitForNextMillisecond(baseTimestamp); continue; @@ -178,15 +214,19 @@ private void FillGuids(Span destination) if (Interlocked.CompareExchange(ref _packedState, newPacked, currentPacked) != currentPacked) { + _statistics?.RecordCasRetry(); spinWait.SpinOnce(); continue; } + RecordAllocationDecision(clockRollback, baseTimestamp, physicalTime); + for (var j = 0; j < available; j++) { destination[i + j] = CreateGuidFromState(baseTimestamp, (ushort)(startCounter + j)); } + _statistics?.RecordGenerated(available); i += available; } } @@ -213,6 +253,7 @@ private void FillGuids(Span destination) var (currentTimestamp, currentCounter) = UnpackState(currentPacked); var physicalTime = _timeProvider.GetUtcNow().ToUnixTimeMilliseconds(); + var clockRollback = physicalTime < currentTimestamp; long newTimestamp; ushort newCounter; @@ -229,9 +270,11 @@ private void FillGuids(Span destination) if (currentCounter >= MaxCounterValue) { // Counter overflow + _statistics?.RecordCounterOverflow(); switch (_effectiveOverflowBehavior) { case CounterOverflowBehavior.SpinWait: + _statistics?.RecordSpinWait(); SpinWaitForNextMillisecond(physicalTime); continue; // Retry with new time @@ -259,6 +302,7 @@ private void FillGuids(Span destination) // Time went backwards; preserve monotonicity by continuing from the current state. if (currentCounter >= MaxCounterValue) { + _statistics?.RecordCounterOverflow(); newTimestamp = currentTimestamp + 1; newCounter = GetRandomCounterStart(); } @@ -273,13 +317,30 @@ private void FillGuids(Span destination) if (Interlocked.CompareExchange(ref _packedState, newPacked, currentPacked) == currentPacked) { + RecordAllocationDecision(clockRollback, newTimestamp, physicalTime); return (newTimestamp, newCounter); } + _statistics?.RecordCasRetry(); spinWait.SpinOnce(); } } + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private void RecordAllocationDecision(bool clockRollback, long logicalTimestamp, long physicalTime) + { + var statistics = _statistics; + if (statistics is null) + return; + + if (clockRollback) + statistics.RecordClockRollback(); + + var driftMs = logicalTimestamp - physicalTime; + if (driftMs > 0) + statistics.RecordLogicalTimestampAdvance(driftMs); + } + [MethodImpl(MethodImplOptions.AggressiveInlining)] private Guid CreateGuidFromState(long timestampMs, ushort counter) { @@ -362,14 +423,16 @@ public void Dispose() private sealed class RandomBuffer { private readonly RandomNumberGenerator _rng; + private readonly UuidV7FactoryStatistics? _statistics; private readonly byte[] _buffer; private int _position; private const int BufferSize = 256; // ~32 GUIDs worth - public RandomBuffer(RandomNumberGenerator rng) + public RandomBuffer(RandomNumberGenerator rng, UuidV7FactoryStatistics? statistics) { _rng = rng; + _statistics = statistics; _buffer = new byte[BufferSize]; _position = BufferSize; // Force initial fill } @@ -391,6 +454,7 @@ public ReadOnlySpan GetBytes(int count) private void Refill() { _rng.GetBytes(_buffer); + _statistics?.RecordRandomBufferRefill(); _position = 0; } } diff --git a/tests/Clockworks.Tests.csproj b/tests/Clockworks.Tests.csproj index 15e7a03..0ebe596 100644 --- a/tests/Clockworks.Tests.csproj +++ b/tests/Clockworks.Tests.csproj @@ -6,6 +6,7 @@ + diff --git a/tests/UuidV7FactoryStatisticsTests.cs b/tests/UuidV7FactoryStatisticsTests.cs new file mode 100644 index 0000000..557f4fd --- /dev/null +++ b/tests/UuidV7FactoryStatisticsTests.cs @@ -0,0 +1,251 @@ +using Clockworks.Abstractions; +using Clockworks.Instrumentation; +using Microsoft.Extensions.DependencyInjection; +using Xunit; + +namespace Clockworks.Tests; + +public sealed class UuidV7FactoryStatisticsTests +{ + [Fact] + public void Constructor_LeavesStatisticsDisabled_WhenStatisticsAreNotSupplied() + { + using var factory = new UuidV7Factory(SimulatedTimeProvider.FromUnixMs(1_700_000_000_000), null); + + Assert.Null(factory.Statistics); + } + + [Fact] + public void NewGuid_RecordsGeneratedCount_WhenStatisticsAreEnabled() + { + var statistics = new UuidV7FactoryStatistics(); + var time = SimulatedTimeProvider.FromUnixMs(1_700_000_000_000); + using var rng = new DeterministicRandomNumberGenerator(seed: 123); + using var factory = new UuidV7Factory(time, rng, CounterOverflowBehavior.SpinWait, statistics); + + statistics.Reset(); + + _ = factory.NewGuid(); + _ = factory.NewGuidWithTimestamp(); + Span batch = stackalloc Guid[7]; + factory.NewGuids(batch); + + Assert.Same(statistics, factory.Statistics); + Assert.Equal(9, statistics.GeneratedCount); + Assert.Equal(9, statistics.Snapshot().GeneratedCount); + } + + [Fact] + public void NewGuid_RecordsClockRollbackAndLogicalDrift_WhenPhysicalTimeMovesBackwards() + { + const long startMs = 1_700_000_000_000; + var statistics = new UuidV7FactoryStatistics(); + var time = SimulatedTimeProvider.FromUnixMs(startMs); + using var rng = new DeterministicRandomNumberGenerator(seed: 123); + using var factory = new UuidV7Factory(time, rng, CounterOverflowBehavior.SpinWait, statistics); + + statistics.Reset(); + + _ = factory.NewGuid(); + time.SetUnixMs(startMs - 25); + _ = factory.NewGuid(); + + var snapshot = statistics.Snapshot(); + Assert.Equal(2, snapshot.GeneratedCount); + Assert.Equal(1, snapshot.ClockRollbackCount); + Assert.Equal(1, snapshot.LogicalTimestampAdvanceCount); + Assert.Equal(25, snapshot.MaxLogicalDriftMs); + } + + [Fact] + public void NewGuid_RecordsCounterOverflowAndLogicalAdvance_WhenOverflowIncrementsTimestamp() + { + var statistics = new UuidV7FactoryStatistics(); + var time = SimulatedTimeProvider.FromUnixMs(1_700_000_000_000); + using var rng = new DeterministicRandomNumberGenerator(seed: 1); + using var factory = new UuidV7Factory( + time, + rng, + CounterOverflowBehavior.IncrementTimestamp, + statistics); + + statistics.Reset(); + + for (var i = 0; i < 5000; i++) + _ = factory.NewGuid(); + + var snapshot = statistics.Snapshot(); + Assert.Equal(5000, snapshot.GeneratedCount); + Assert.True(snapshot.CounterOverflowCount >= 1); + Assert.True(snapshot.LogicalTimestampAdvanceCount >= 1); + Assert.True(snapshot.MaxLogicalDriftMs >= 1); + } + + [Fact] + public void NewGuids_RecordsRandomBufferRefills_WhenBatchConsumesThreadLocalBuffer() + { + var statistics = new UuidV7FactoryStatistics(); + var time = SimulatedTimeProvider.FromUnixMs(1_700_000_000_000); + using var rng = new DeterministicRandomNumberGenerator(seed: 123); + using var factory = new UuidV7Factory(time, rng, CounterOverflowBehavior.SpinWait, statistics); + var batch = new Guid[64]; + + statistics.Reset(); + + factory.NewGuids(batch); + + var snapshot = statistics.Snapshot(); + Assert.Equal(batch.Length, snapshot.GeneratedCount); + Assert.True(snapshot.RandomBufferRefillCount >= 1); + } + + [Fact] + public async Task NewGuid_RecordsSpinWait_WhenOverflowWaitsForNextMillisecond() + { + var statistics = new UuidV7FactoryStatistics(); + var time = SimulatedTimeProvider.FromUnixMs(1_700_000_000_000); + using var rng = new DeterministicRandomNumberGenerator(seed: 1); + using var factory = new UuidV7Factory( + time, + rng, + CounterOverflowBehavior.SpinWait, + statistics); + + while (factory.NewGuid().GetCounter() != 0x0FFF) + { + } + + statistics.Reset(); + + var pending = Task.Run(factory.NewGuid); + + var observedSpin = SpinWait.SpinUntil( + () => statistics.CounterOverflowCount > 0 && statistics.SpinWaitCount > 0, + TimeSpan.FromSeconds(2)); + + try + { + Assert.True(observedSpin); + + time.AdvanceMs(1); + + await pending.WaitAsync(TimeSpan.FromSeconds(5)); + } + finally + { + if (!pending.IsCompleted) + { + time.AdvanceMs(1); + await pending.WaitAsync(TimeSpan.FromSeconds(5)); + } + + pending.Dispose(); + } + + var snapshot = statistics.Snapshot(); + Assert.Equal(1, snapshot.GeneratedCount); + Assert.Equal(1, snapshot.CounterOverflowCount); + Assert.Equal(1, snapshot.SpinWaitCount); + } + + [Fact] + public async Task NewGuid_RecordsCasRetries_WhenConcurrentAllocationsRace() + { + const int participantCount = 8; + var statistics = new UuidV7FactoryStatistics(); + var time = new BarrierTimeProvider(1_700_000_000_000); + using var factory = new UuidV7Factory( + time, + rng: null, + CounterOverflowBehavior.SpinWait, + statistics); + + statistics.Reset(); + time.Arm(participantCount); + + var tasks = Enumerable.Range(0, participantCount) + .Select(_ => Task.Run(factory.NewGuid)) + .ToArray(); + + await Task.WhenAll(tasks).WaitAsync(TimeSpan.FromSeconds(5)); + + var snapshot = statistics.Snapshot(); + Assert.Equal(participantCount, snapshot.GeneratedCount); + Assert.True(snapshot.CasRetryCount >= participantCount - 1); + Assert.Equal(participantCount, tasks.Select(static t => t.Result).Distinct().Count()); + } + + [Fact] + public void Reset_ClearsAllCounters() + { + var statistics = new UuidV7FactoryStatistics(); + var time = SimulatedTimeProvider.FromUnixMs(1_700_000_000_000); + using var rng = new DeterministicRandomNumberGenerator(seed: 1); + using var factory = new UuidV7Factory(time, rng, CounterOverflowBehavior.SpinWait, statistics); + + _ = factory.NewGuid(); + statistics.Reset(); + + Assert.Equal(default, statistics.Snapshot()); + } + + [Fact] + public void AddLockFreeGuidFactory_RegistersSharedStatistics() + { + var services = new ServiceCollection(); + var statistics = new UuidV7FactoryStatistics(); + var time = SimulatedTimeProvider.FromUnixMs(1_700_000_000_000); + using var rng = new DeterministicRandomNumberGenerator(seed: 1); + + services.AddLockFreeGuidFactory(time, statistics, rng); + + using var provider = services.BuildServiceProvider(); + var factory = provider.GetRequiredService(); + + _ = factory.NewGuid(); + + Assert.Same(statistics, provider.GetRequiredService()); + Assert.Equal(1, statistics.GeneratedCount); + } + + [Fact] + public void AddLockFreeGuidFactory_DisposesContainerOwnedFactory() + { + var services = new ServiceCollection(); + var time = SimulatedTimeProvider.FromUnixMs(1_700_000_000_000); + + services.AddLockFreeGuidFactory(time); + + var provider = services.BuildServiceProvider(); + var factory = provider.GetRequiredService(); + + provider.Dispose(); + + Assert.Throws(() => factory.NewGuid()); + } + + private sealed class BarrierTimeProvider(long unixMs) : TimeProvider + { + private DateTimeOffset _utcNow = DateTimeOffset.FromUnixTimeMilliseconds(unixMs); + private Barrier? _barrier; + private int _remainingParticipants; + + public void Arm(int participantCount) + { + _remainingParticipants = participantCount; + _barrier = new Barrier(participantCount); + } + + public override DateTimeOffset GetUtcNow() + { + var barrier = Volatile.Read(ref _barrier); + if (barrier is not null && Interlocked.Decrement(ref _remainingParticipants) >= 0) + { + if (!barrier.SignalAndWait(TimeSpan.FromSeconds(5))) + throw new TimeoutException("Timed out while coordinating UUIDv7 CAS contention test."); + } + + return _utcNow; + } + } +}