From b35c54205cb615f67d53ebad841f695c2a697a60 Mon Sep 17 00:00:00 2001 From: Dexter Ajoku Date: Fri, 15 May 2026 09:36:30 +0200 Subject: [PATCH 1/5] Add UUIDv7 node partitioning Refs #68. --- demo/Clockworks.Demo/Demos/UuidV7Showcase.cs | 69 ++++++++ src/Extensions.cs | 76 +++++++++ src/UuidV7Factory.cs | 46 +++++- src/UuidV7NodePartition.cs | 156 +++++++++++++++++++ 4 files changed, 345 insertions(+), 2 deletions(-) create mode 100644 src/UuidV7NodePartition.cs diff --git a/demo/Clockworks.Demo/Demos/UuidV7Showcase.cs b/demo/Clockworks.Demo/Demos/UuidV7Showcase.cs index 0dec651..6d64d05 100644 --- a/demo/Clockworks.Demo/Demos/UuidV7Showcase.cs +++ b/demo/Clockworks.Demo/Demos/UuidV7Showcase.cs @@ -237,6 +237,26 @@ private static async Task RunBenchmarks() Console.WriteLine($" Generated: {statistics.GeneratedCount:N0}, CAS retries: {statistics.CasRetryCount:N0}"); } + Console.WriteLine(); + Console.WriteLine("Lock-Free Factory + node partition (single-threaded):"); + { + var partition = new UuidV7NodePartition(nodeId: 42, nodeIdBitWidth: 10); + using var factory = new UuidV7Factory(TimeProvider.System, partition); + + 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(); Console.WriteLine($"Lock-Free Factory ({ThreadCount} threads contended):"); using (var factory = new UuidV7Factory(TimeProvider.System)) @@ -262,6 +282,55 @@ private static async Task RunBenchmarks() Console.WriteLine($" {opsPerSec:N0} ops/sec total ({opsPerSec / ThreadCount:N0} ops/sec/thread)"); } + Console.WriteLine(); + Console.WriteLine("Lock-Free Factory batch NewGuids:"); + double disabledBatchNsPerOp; + { + const int BatchSize = 1024; + using var factory = new UuidV7Factory(TimeProvider.System); + var batch = new Guid[BatchSize]; + var generated = 0; + + var sw = Stopwatch.StartNew(); + while (generated < BenchmarkIterations) + { + var take = Math.Min(BatchSize, BenchmarkIterations - generated); + factory.NewGuids(batch.AsSpan(0, take)); + generated += take; + } + sw.Stop(); + + var seconds = sw.Elapsed.TotalSeconds; + var opsPerSec = BenchmarkIterations / seconds; + disabledBatchNsPerOp = sw.Elapsed.TotalNanoseconds / BenchmarkIterations; + Console.WriteLine($" {opsPerSec:N0} ops/sec ({disabledBatchNsPerOp:N1} ns/op)"); + } + + Console.WriteLine(); + Console.WriteLine("Lock-Free Factory + node partition batch NewGuids:"); + { + const int BatchSize = 1024; + var partition = new UuidV7NodePartition(nodeId: 42, nodeIdBitWidth: 10); + using var factory = new UuidV7Factory(TimeProvider.System, partition); + var batch = new Guid[BatchSize]; + var generated = 0; + + var sw = Stopwatch.StartNew(); + while (generated < BenchmarkIterations) + { + var take = Math.Min(BatchSize, BenchmarkIterations - generated); + factory.NewGuids(batch.AsSpan(0, take)); + generated += take; + } + sw.Stop(); + + var seconds = sw.Elapsed.TotalSeconds; + var opsPerSec = BenchmarkIterations / seconds; + var nsPerOp = sw.Elapsed.TotalNanoseconds / BenchmarkIterations; + var overhead = (nsPerOp / disabledBatchNsPerOp - 1.0) * 100.0; + Console.WriteLine($" {opsPerSec:N0} ops/sec ({nsPerOp:N1} ns/op, {overhead:N1}% vs batch disabled)"); + } + Console.WriteLine(); Console.WriteLine("HLC Factory (single-threaded):"); using (var factory = new HlcGuidFactory(TimeProvider.System, nodeId: 0)) diff --git a/src/Extensions.cs b/src/Extensions.cs index 9efdb1a..dc91c40 100644 --- a/src/Extensions.cs +++ b/src/Extensions.cs @@ -117,6 +117,63 @@ public IServiceCollection AddLockFreeGuidFactory( return services; } + /// + /// Adds the lock-free GUID factory with system time and an opt-in UUIDv7 node partition. + /// Use this for distributed fleets that can assign unique node, shard, process, or deployment IDs. + /// + /// Node partition to embed into the most-significant bits of UUIDv7 rand_b. + /// Behavior to apply when the per-millisecond counter overflows. + public IServiceCollection AddNodePartitionedGuidFactory( + UuidV7NodePartition nodePartition, + CounterOverflowBehavior overflowBehavior = CounterOverflowBehavior.SpinWait) + { + services.TryAddSingleton(TimeProvider.System); + services.AddSingleton(sp => new UuidV7Factory( + sp.GetRequiredService(), + nodePartition, + overflowBehavior: overflowBehavior)); + services.AddSingleton(sp => (UuidV7Factory)sp.GetRequiredService()); + + return services; + } + + /// + /// Adds the lock-free GUID factory with a custom TimeProvider and an opt-in UUIDv7 node partition. + /// 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. + /// Node partition to embed into the most-significant bits of UUIDv7 rand_b. + /// + /// 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. + /// Optional statistics instance updated by the registered singleton factory. + public IServiceCollection AddNodePartitionedGuidFactory( + TimeProvider timeProvider, + UuidV7NodePartition nodePartition, + RandomNumberGenerator? rng = null, + CounterOverflowBehavior overflowBehavior = CounterOverflowBehavior.SpinWait, + UuidV7FactoryStatistics? statistics = null) + { + ArgumentNullException.ThrowIfNull(timeProvider); + + services.TryAddSingleton(timeProvider); + if (statistics is not null) + services.AddSingleton(statistics); + + services.AddSingleton(sp => new UuidV7Factory( + sp.GetRequiredService(), + nodePartition, + rng, + overflowBehavior, + statistics)); + services.AddSingleton(sp => (UuidV7Factory)sp.GetRequiredService()); + + return services; + } + /// /// Adds the HLC GUID factory with system time. /// Use this for distributed systems requiring causal ordering. @@ -234,6 +291,25 @@ public static class GuidExtensions return (ushort)(((bytes[8] & 0x3F) << 8) | bytes[9]); } + /// + /// For node-partitioned UUIDv7s, extracts the configured node partition from the most-significant + /// rand_b bits. + /// + /// Number of rand_b bits reserved for the node partition. + /// The node partition ID, or if this GUID is not UUIDv7. + public ushort? GetNodePartitionId(byte nodeIdBitWidth) + { + UuidV7NodePartition.ValidateBitWidth(nodeIdBitWidth); + + Span bytes = stackalloc byte[16]; + guid.TryWriteBytes(bytes, bigEndian: true, out _); + + if ((bytes[6] & 0xF0) != 0x70 || (bytes[8] & 0xC0) != 0x80) + return null; + + return UuidV7NodePartition.ReadNodeId(bytes, nodeIdBitWidth); + } + /// /// Checks if this GUID is a valid UUIDv7. /// diff --git a/src/UuidV7Factory.cs b/src/UuidV7Factory.cs index 3e2046d..1b013d4 100644 --- a/src/UuidV7Factory.cs +++ b/src/UuidV7Factory.cs @@ -53,6 +53,7 @@ public sealed class UuidV7Factory : IUuidV7Factory, IDisposable private readonly CounterOverflowBehavior _overflowBehavior; private readonly CounterOverflowBehavior _effectiveOverflowBehavior; private readonly UuidV7FactoryStatistics? _statistics; + private readonly UuidV7NodePartition _nodePartition; // Packed state: [48 bits timestamp][16 bits counter] // Using 64-bit atomic operations for lock-free updates @@ -88,7 +89,7 @@ public UuidV7Factory( TimeProvider timeProvider, RandomNumberGenerator? rng = null, CounterOverflowBehavior overflowBehavior = CounterOverflowBehavior.SpinWait) - : this(timeProvider, rng, overflowBehavior, statistics: null) + : this(timeProvider, rng, overflowBehavior, statistics: null, nodePartition: default) { } @@ -109,12 +110,45 @@ public UuidV7Factory( RandomNumberGenerator? rng, CounterOverflowBehavior overflowBehavior, UuidV7FactoryStatistics? statistics) + : this(timeProvider, rng, overflowBehavior, statistics, nodePartition: default) + { + } + + /// + /// Creates a new UUIDv7 generator with an opt-in node partition embedded into rand_b. + /// + /// Time source (use for production). + /// Node, shard, process, or deployment discriminator to embed in generated UUIDs. + /// + /// 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. + /// + /// Behavior to apply when the per-millisecond counter overflows. + /// Statistics instance to update from this factory, or to disable statistics. + public UuidV7Factory( + TimeProvider timeProvider, + UuidV7NodePartition nodePartition, + RandomNumberGenerator? rng = null, + CounterOverflowBehavior overflowBehavior = CounterOverflowBehavior.SpinWait, + UuidV7FactoryStatistics? statistics = null) + : this(timeProvider, rng, overflowBehavior, statistics, nodePartition) + { + } + + private UuidV7Factory( + TimeProvider timeProvider, + RandomNumberGenerator? rng, + CounterOverflowBehavior overflowBehavior, + UuidV7FactoryStatistics? statistics, + UuidV7NodePartition nodePartition) { _timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider)); _rng = rng ?? RandomNumberGenerator.Create(); _ownsRng = rng is null; _overflowBehavior = overflowBehavior; _statistics = statistics; + _nodePartition = nodePartition; _effectiveOverflowBehavior = overflowBehavior == CounterOverflowBehavior.Auto ? (_timeProvider is SimulatedTimeProvider ? CounterOverflowBehavior.IncrementTimestamp : CounterOverflowBehavior.SpinWait) @@ -133,6 +167,11 @@ public UuidV7Factory( /// public UuidV7FactoryStatistics? Statistics => _statistics; + /// + /// Node partition embedded into generated UUIDs, or when node partitioning is disabled. + /// + public UuidV7NodePartition? NodePartition => _nodePartition.IsConfigured ? _nodePartition : null; + /// [MethodImpl(MethodImplOptions.AggressiveInlining)] public Guid NewGuid() @@ -364,7 +403,10 @@ private Guid CreateGuidFromState(long timestampMs, ushort counter) randomBytes.CopyTo(bytes.Slice(8, 8)); // Set variant bits: 10xxxxxx - bytes[8] = (byte)((bytes[8] & VariantMask) | VariantRfc4122); + if (_nodePartition.IsConfigured) + _nodePartition.ApplyTo(bytes); + else + bytes[8] = (byte)((bytes[8] & VariantMask) | VariantRfc4122); return new Guid(bytes, bigEndian: true); } diff --git a/src/UuidV7NodePartition.cs b/src/UuidV7NodePartition.cs new file mode 100644 index 0000000..b6a90b3 --- /dev/null +++ b/src/UuidV7NodePartition.cs @@ -0,0 +1,156 @@ +namespace Clockworks; + +/// +/// Defines an opt-in node or shard discriminator embedded into the most-significant bits of UUIDv7 rand_b. +/// +/// +/// The discriminator is intended for fleets that can assign unique node, shard, process, or deployment IDs without a +/// central UUID allocator. It preserves the 48-bit timestamp and 12-bit monotonic counter layout used by +/// , while trading some random-tail entropy for deterministic namespace partitioning. +/// +public readonly record struct UuidV7NodePartition +{ + /// + /// Number of effective random bits available in UUIDv7 rand_b after the RFC variant bits. + /// + public const int EffectiveRandomTailBits = 62; + + /// + /// Minimum supported node discriminator width. + /// + public const byte MinNodeIdBitWidth = 1; + + /// + /// Maximum supported node discriminator width. This leaves at least 46 random bits in rand_b. + /// + public const byte MaxNodeIdBitWidth = 16; + + /// + /// Creates a node partition. + /// + /// Node, shard, process, or deployment discriminator value. + /// Number of most-significant rand_b bits reserved for . + /// + /// Thrown when is outside the supported range or + /// cannot fit in the configured width. + /// + public UuidV7NodePartition(ushort nodeId, byte nodeIdBitWidth) + { + ValidateBitWidth(nodeIdBitWidth); + + var maxNodeId = GetMaxNodeId(nodeIdBitWidth); + if (nodeId > maxNodeId) + { + throw new ArgumentOutOfRangeException( + nameof(nodeId), + nodeId, + $"Node ID must be less than or equal to {maxNodeId} for a {nodeIdBitWidth}-bit partition."); + } + + NodeId = nodeId; + NodeIdBitWidth = nodeIdBitWidth; + } + + /// + /// Node, shard, process, or deployment discriminator value. + /// + public ushort NodeId { get; } + + /// + /// Number of most-significant rand_b bits reserved for . + /// + public byte NodeIdBitWidth { get; } + + /// + /// Number of effective random-tail bits that remain after reserving bits. + /// + public int RemainingRandomTailBits => EffectiveRandomTailBits - NodeIdBitWidth; + + internal bool IsConfigured => NodeIdBitWidth != 0; + + /// + /// Gets the largest node ID representable by . + /// + public static ushort GetMaxNodeId(byte nodeIdBitWidth) + { + ValidateBitWidth(nodeIdBitWidth); + return (ushort)((1 << nodeIdBitWidth) - 1); + } + + internal static void ValidateBitWidth(byte nodeIdBitWidth) + { + if (nodeIdBitWidth is < MinNodeIdBitWidth or > MaxNodeIdBitWidth) + { + throw new ArgumentOutOfRangeException( + nameof(nodeIdBitWidth), + nodeIdBitWidth, + $"Node ID bit width must be between {MinNodeIdBitWidth} and {MaxNodeIdBitWidth}."); + } + } + + internal void ApplyTo(Span uuidBytes) + { + uuidBytes[8] = (byte)((uuidBytes[8] & 0x3F) | 0x80); + WriteNodeId(uuidBytes, NodeId, NodeIdBitWidth); + } + + internal static ushort ReadNodeId(ReadOnlySpan uuidBytes, byte nodeIdBitWidth) + { + ValidateBitWidth(nodeIdBitWidth); + + if (nodeIdBitWidth <= 6) + { + var mask = (1 << nodeIdBitWidth) - 1; + return (ushort)((uuidBytes[8] >> (6 - nodeIdBitWidth)) & mask); + } + + var remaining = nodeIdBitWidth - 6; + var nodeId = (ushort)((uuidBytes[8] & 0x3F) << remaining); + + if (remaining <= 8) + { + var mask = (1 << remaining) - 1; + nodeId |= (ushort)((uuidBytes[9] >> (8 - remaining)) & mask); + return nodeId; + } + + var finalBits = remaining - 8; + var finalMask = (1 << finalBits) - 1; + nodeId |= (ushort)(uuidBytes[9] << finalBits); + nodeId |= (ushort)((uuidBytes[10] >> (8 - finalBits)) & finalMask); + return nodeId; + } + + private static void WriteNodeId(Span uuidBytes, ushort nodeId, byte nodeIdBitWidth) + { + if (nodeIdBitWidth <= 6) + { + var shift = 6 - nodeIdBitWidth; + var mask = ((1 << nodeIdBitWidth) - 1) << shift; + var value = nodeId << shift; + uuidBytes[8] = (byte)((uuidBytes[8] & ~mask) | value); + return; + } + + var remaining = nodeIdBitWidth - 6; + uuidBytes[8] = (byte)((uuidBytes[8] & 0xC0) | ((nodeId >> remaining) & 0x3F)); + + var remainingValue = nodeId & ((1 << remaining) - 1); + if (remaining <= 8) + { + var shift = 8 - remaining; + var mask = ((1 << remaining) - 1) << shift; + var value = remainingValue << shift; + uuidBytes[9] = (byte)((uuidBytes[9] & ~mask) | value); + return; + } + + var finalBits = remaining - 8; + uuidBytes[9] = (byte)(remainingValue >> finalBits); + + var finalShift = 8 - finalBits; + var finalMask = ((1 << finalBits) - 1) << finalShift; + var finalValue = (remainingValue & ((1 << finalBits) - 1)) << finalShift; + uuidBytes[10] = (byte)((uuidBytes[10] & ~finalMask) | finalValue); + } +} From de2a4282f04913f883d34e49998409dbc717ada1 Mon Sep 17 00:00:00 2001 From: Dexter Ajoku Date: Fri, 15 May 2026 09:36:56 +0200 Subject: [PATCH 2/5] Test UUIDv7 node partitioning Refs #68. --- property-tests/UuidV7FactoryProperties.fs | 38 ++++++++ tests/UuidV7NodePartitionTests.cs | 100 ++++++++++++++++++++++ 2 files changed, 138 insertions(+) create mode 100644 tests/UuidV7NodePartitionTests.cs diff --git a/property-tests/UuidV7FactoryProperties.fs b/property-tests/UuidV7FactoryProperties.fs index 5a1eb30..81ac8cc 100644 --- a/property-tests/UuidV7FactoryProperties.fs +++ b/property-tests/UuidV7FactoryProperties.fs @@ -199,6 +199,44 @@ let ``Statistics max drift tracks wall clock rollback`` (rollbackMs: uint16) = && snapshot.LogicalTimestampAdvanceCount = 1L && snapshot.MaxLogicalDriftMs = safeRollbackMs +/// Property: node-partitioned UUIDv7 embeds and round-trips the configured node ID. +[] +let ``Node partition round trips configured node ID`` (rawWidth: byte) (rawNodeId: uint16) = + let width = byte ((int rawWidth % int UuidV7NodePartition.MaxNodeIdBitWidth) + 1) + let maxNodeId = UuidV7NodePartition.GetMaxNodeId(width) + let nodeId = uint16 (int rawNodeId % (int maxNodeId + 1)) + let partition = UuidV7NodePartition(nodeId, width) + let timeProvider = new SimulatedTimeProvider() + use factory = new UuidV7Factory(timeProvider, partition) + + let uuid = factory.NewGuid() + let extracted = uuid.GetNodePartitionId(width) + + extracted.HasValue && extracted.Value = nodeId + +/// Property: different node partitions separate otherwise identical deterministic UUID streams. +[] +let ``Node partitions separate identical deterministic UUID streams`` (rawWidth: byte) (count: byte) = + let width = byte ((int rawWidth % int UuidV7NodePartition.MaxNodeIdBitWidth) + 1) + let maxNodeId = UuidV7NodePartition.GetMaxNodeId(width) + let safeCount = int (count % 32uy) + 1 + let startMs = 1_700_000_000_000L + + let leftPartition = UuidV7NodePartition(0us, width) + let rightPartition = UuidV7NodePartition(maxNodeId, width) + use leftRng = new DeterministicRandomNumberGenerator(42) + use rightRng = new DeterministicRandomNumberGenerator(42) + use left = new UuidV7Factory(SimulatedTimeProvider.FromUnixMs(startMs), leftPartition, leftRng) + use right = new UuidV7Factory(SimulatedTimeProvider.FromUnixMs(startMs), rightPartition, rightRng) + + [| for _ in 1..safeCount -> left.NewGuid(), right.NewGuid() |] + |> Array.forall (fun (leftId, rightId) -> + leftId <> rightId + && leftId.GetTimestampMs() = rightId.GetTimestampMs() + && leftId.GetCounter() = rightId.GetCounter() + && leftId.GetNodePartitionId(width).Value = 0us + && rightId.GetNodePartitionId(width).Value = maxNodeId) + /// Property: UUIDs generated at different times are different [] let ``UUIDs change with time`` (advanceMs: uint16) = diff --git a/tests/UuidV7NodePartitionTests.cs b/tests/UuidV7NodePartitionTests.cs new file mode 100644 index 0000000..cf08ac4 --- /dev/null +++ b/tests/UuidV7NodePartitionTests.cs @@ -0,0 +1,100 @@ +using Clockworks.Abstractions; +using Microsoft.Extensions.DependencyInjection; +using Xunit; + +namespace Clockworks.Tests; + +public sealed class UuidV7NodePartitionTests +{ + [Fact] + public void Constructor_ValidatesBitWidthAndNodeId() + { + Assert.Equal(1023, UuidV7NodePartition.GetMaxNodeId(10)); + Assert.Equal(65535, UuidV7NodePartition.GetMaxNodeId(16)); + + Assert.Throws(() => new UuidV7NodePartition(0, 0)); + Assert.Throws(() => new UuidV7NodePartition(0, 17)); + Assert.Throws(() => new UuidV7NodePartition(4, 2)); + } + + [Fact] + public void NewGuid_EmbedsConfiguredNodePartition() + { + var partition = new UuidV7NodePartition(nodeId: 42, nodeIdBitWidth: 10); + var time = SimulatedTimeProvider.FromUnixMs(1_700_000_000_000); + using var rng = new DeterministicRandomNumberGenerator(seed: 123); + using var factory = new UuidV7Factory(time, partition, rng); + + var guid = factory.NewGuid(); + + Assert.Equal(partition, factory.NodePartition); + Assert.True(guid.IsVersion7()); + Assert.Equal((ushort?)42, guid.GetNodePartitionId(10)); + Assert.Equal(52, partition.RemainingRandomTailBits); + } + + [Fact] + public void NewGuid_SeparatesNodes_WhenTimeCounterAndRandomStreamMatch() + { + const long startMs = 1_700_000_000_000; + const int count = 64; + var leftPartition = new UuidV7NodePartition(nodeId: 17, nodeIdBitWidth: 10); + var rightPartition = new UuidV7NodePartition(nodeId: 273, nodeIdBitWidth: 10); + using var leftRng = new DeterministicRandomNumberGenerator(seed: 42); + using var rightRng = new DeterministicRandomNumberGenerator(seed: 42); + using var left = new UuidV7Factory(SimulatedTimeProvider.FromUnixMs(startMs), leftPartition, leftRng); + using var right = new UuidV7Factory(SimulatedTimeProvider.FromUnixMs(startMs), rightPartition, rightRng); + + for (var i = 0; i < count; i++) + { + var leftId = left.NewGuid(); + var rightId = right.NewGuid(); + + Assert.NotEqual(leftId, rightId); + Assert.Equal(leftId.GetTimestampMs(), rightId.GetTimestampMs()); + Assert.Equal(leftId.GetCounter(), rightId.GetCounter()); + Assert.Equal((ushort?)17, leftId.GetNodePartitionId(10)); + Assert.Equal((ushort?)273, rightId.GetNodePartitionId(10)); + } + } + + [Fact] + public void NewGuids_EmbedsNodePartitionAcrossBatch() + { + var partition = new UuidV7NodePartition(nodeId: 0xBEEF, nodeIdBitWidth: 16); + var time = SimulatedTimeProvider.FromUnixMs(1_700_000_000_000); + using var rng = new DeterministicRandomNumberGenerator(seed: 123); + using var factory = new UuidV7Factory(time, partition, rng); + var ids = new Guid[128]; + + factory.NewGuids(ids); + + Assert.All(ids, id => Assert.Equal((ushort?)0xBEEF, id.GetNodePartitionId(16))); + } + + [Fact] + public void GetNodePartitionId_ReturnsNull_WhenGuidIsNotUuidV7() + { + Assert.Null(Guid.NewGuid().GetNodePartitionId(10)); + } + + [Fact] + public void AddNodePartitionedGuidFactory_RegistersSingletonFactory() + { + var services = new ServiceCollection(); + var partition = new UuidV7NodePartition(nodeId: 7, nodeIdBitWidth: 10); + var time = SimulatedTimeProvider.FromUnixMs(1_700_000_000_000); + using var rng = new DeterministicRandomNumberGenerator(seed: 1); + + services.AddNodePartitionedGuidFactory(time, partition, rng); + + using var provider = services.BuildServiceProvider(); + var factory = provider.GetRequiredService(); + var concrete = provider.GetRequiredService(); + var id = factory.NewGuid(); + + Assert.Same(factory, concrete); + Assert.Equal(partition, concrete.NodePartition); + Assert.Equal((ushort?)7, id.GetNodePartitionId(10)); + } +} From 1c034556b752d645d3cd171439c768dcf4b30b95 Mon Sep 17 00:00:00 2001 From: Dexter Ajoku Date: Fri, 15 May 2026 09:37:19 +0200 Subject: [PATCH 3/5] Document UUIDv7 node partitioning Refs #68. --- CHANGELOG.md | 2 + README.md | 1 + docs/.vitepress/config.ts | 1 + docs/changelog.md | 2 + docs/concepts/uuidv7-node-partitioning.md | 57 +++++++++++++++++++++++ docs/guide/uuidv7.md | 24 ++++++++++ 6 files changed, 87 insertions(+) create mode 100644 docs/concepts/uuidv7-node-partitioning.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 3c86001..7da7765 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,11 +9,13 @@ and this project aims to follow [Semantic Versioning](https://semver.org/spec/v2 ### Added - Add opt-in `UuidV7FactoryStatistics` counters for generated UUIDs, clock rollback, counter overflow, spin-wait, logical drift, CAS retries, and random-buffer refills. +- Add opt-in UUIDv7 node partitioning that reserves 1 to 16 `rand_b` bits for a node, shard, process, or deployment discriminator. ### 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 node partitioning trade-offs versus default UUIDv7, `HlcGuidFactory`, Snowflake-style IDs, and database allocators. - 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 a1c3e05..e395ca7 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 `rand_b` node partitioning for distributed fleets with assigned node/shard IDs - 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 diff --git a/docs/.vitepress/config.ts b/docs/.vitepress/config.ts index 18e3ffa..4b54332 100644 --- a/docs/.vitepress/config.ts +++ b/docs/.vitepress/config.ts @@ -63,6 +63,7 @@ export default defineConfig({ items: [ { text: 'Why Clockworks?', link: '/concepts/why-clockworks' }, { text: 'HLC vs Vector Clocks', link: '/concepts/hlc-vs-vector' }, + { text: 'UUIDv7 Node Partitioning', link: '/concepts/uuidv7-node-partitioning' }, { text: 'Determinism Model', link: '/concepts/determinism' }, { text: 'Security Considerations', link: '/concepts/security' }, ], diff --git a/docs/changelog.md b/docs/changelog.md index cb05fd3..e8f7ffd 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -12,11 +12,13 @@ This page mirrors the repository root `CHANGELOG.md`. ### Added - Add opt-in `UuidV7FactoryStatistics` counters for generated UUIDs, clock rollback, counter overflow, spin-wait, logical drift, CAS retries, and random-buffer refills. +- Add opt-in UUIDv7 node partitioning that reserves 1 to 16 `rand_b` bits for a node, shard, process, or deployment discriminator. ### 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 node partitioning trade-offs versus default UUIDv7, `HlcGuidFactory`, Snowflake-style IDs, and database allocators. - 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/concepts/uuidv7-node-partitioning.md b/docs/concepts/uuidv7-node-partitioning.md new file mode 100644 index 0000000..f73f8bf --- /dev/null +++ b/docs/concepts/uuidv7-node-partitioning.md @@ -0,0 +1,57 @@ +--- +title: UUIDv7 Node Partitioning +--- + +# UUIDv7 Node Partitioning + +`UuidV7Factory` defaults to RFC 9562 UUIDv7 with a 48-bit timestamp, a 12-bit monotonic counter in `rand_a`, and 62 effective random bits in `rand_b` after the RFC variant bits. This gives deterministic per-factory ordering and probabilistic cross-factory uniqueness. + +Node partitioning is an opt-in distributed mitigation for fleets that can assign stable node, shard, process, or deployment IDs. It reserves the most-significant bits of `rand_b` for that discriminator and leaves the remaining tail bits random. + +```mermaid +flowchart LR + A["TimeProvider"] --> B["48-bit unix_ms"] + C["Lock-free factory state"] --> D["12-bit counter"] + E["UuidV7NodePartition"] --> F["1-16 rand_b node bits"] + G["CSPRNG"] --> H["remaining rand_b bits"] + B --> I["UUIDv7"] + D --> I + F --> I + H --> I +``` + +## Layout + +With a 10-bit partition, the high-level layout is: + +| Field | Bits | Source | +|---|---:|---| +| `unix_ts_ms` | 48 | Physical/logical time | +| version | 4 | UUIDv7 | +| `rand_a` | 12 | Monotonic counter | +| variant | 2 | RFC variant | +| node partition | 10 | Configured node/shard ID | +| random tail | 52 | CSPRNG bytes | + +Clockworks supports 1 to 16 partition bits. A 16-bit partition allows 65,536 configured IDs and leaves 46 random bits in `rand_b`. + +## Comparison + +| Approach | Strength | Trade-off | +|---|---|---| +| Default `UuidV7Factory` | Fast, simple, 62 random tail bits, strict per-instance ordering | Cross-factory uniqueness is probabilistic; no deterministic namespace separation | +| Node-partitioned `UuidV7Factory` | Deterministic separation between correctly assigned node IDs | Less random entropy; requires correct node/shard assignment | +| `HlcGuidFactory` | Encodes node ID and HLC state for causal/distributed ordering | Different semantics and wire contract; choose it for causal ordering, not just collision mitigation | +| Snowflake-style ID | Compact deterministic allocation with explicit worker bits and sequence | Usually custom format rather than UUID; requires clock discipline and worker coordination | +| Database allocator | Strong central uniqueness and ordering policy | Central dependency, added latency, and availability coupling | + +## Failure Modes + +Node partitioning does not remove the need for operational discipline: + +- Two live nodes configured with the same partition can still collide probabilistically. +- A node ID reused during overlapping deployments is not a unique partition. +- Random entropy is reduced by the configured partition width. +- Storage uniqueness constraints remain the final guardrail for high-assurance domains. + +Use node partitioning when you need UUIDv7 compatibility plus deterministic fleet partitioning and can reliably assign non-overlapping IDs. diff --git a/docs/guide/uuidv7.md b/docs/guide/uuidv7.md index 7bb7ef2..e6d6b8b 100644 --- a/docs/guide/uuidv7.md +++ b/docs/guide/uuidv7.md @@ -68,6 +68,29 @@ 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. +## Node Partitioning + +For fleets that can assign stable node, shard, process, or deployment IDs, `UuidV7Factory` can reserve 1 to 16 of the most-significant UUIDv7 `rand_b` bits as a deterministic partition: + +```csharp +var partition = new UuidV7NodePartition(nodeId: 42, nodeIdBitWidth: 10); +using var factory = new UuidV7Factory(TimeProvider.System, partition); + +var id = factory.NewGuid(); +Console.WriteLine(id.GetNodePartitionId(10)); // 42 +``` + +This is opt-in. The default factory keeps all 62 effective `rand_b` bits random. Partitioning trades entropy for deterministic namespace separation: a 10-bit partition supports 1,024 IDs and leaves 52 random bits; a 16-bit partition supports 65,536 IDs and leaves 46 random bits. + +The built-in DI helpers can register a node-partitioned singleton: + +```csharp +services.AddNodePartitionedGuidFactory( + new UuidV7NodePartition(nodeId: 42, nodeIdBitWidth: 10)); +``` + +See [UUIDv7 Node Partitioning](/concepts/uuidv7-node-partitioning) for the design trade-offs. + ## 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: @@ -204,3 +227,4 @@ 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. +It also compares node-partitioned generation against default `NewGuid()` and `NewGuids(Span)`. From 19b8642d1db50e8f6e3c0721f6c8ab1d97b59c80 Mon Sep 17 00:00:00 2001 From: "openai-code-agent[bot]" <242516109+Codex@users.noreply.github.com> Date: Fri, 15 May 2026 09:57:07 +0000 Subject: [PATCH 4/5] Fix node partition helper and DI registration Co-authored-by: dexcompiler <115876036+dexcompiler@users.noreply.github.com> --- src/Extensions.cs | 9 ++++++--- src/UuidV7NodePartition.cs | 2 +- tests/UuidV7NodePartitionTests.cs | 33 +++++++++++++++++++++++++++++++ 3 files changed, 40 insertions(+), 4 deletions(-) diff --git a/src/Extensions.cs b/src/Extensions.cs index dc91c40..2416e80 100644 --- a/src/Extensions.cs +++ b/src/Extensions.cs @@ -168,7 +168,7 @@ public IServiceCollection AddNodePartitionedGuidFactory( nodePartition, rng, overflowBehavior, - statistics)); + statistics is null ? null : sp.GetRequiredService())); services.AddSingleton(sp => (UuidV7Factory)sp.GetRequiredService()); return services; @@ -296,10 +296,13 @@ public static class GuidExtensions /// rand_b bits. /// /// Number of rand_b bits reserved for the node partition. - /// The node partition ID, or if this GUID is not UUIDv7. + /// + /// The node partition ID, or if this GUID is not UUIDv7 or the bit width is invalid. + /// public ushort? GetNodePartitionId(byte nodeIdBitWidth) { - UuidV7NodePartition.ValidateBitWidth(nodeIdBitWidth); + if (nodeIdBitWidth is < UuidV7NodePartition.MinNodeIdBitWidth or > UuidV7NodePartition.MaxNodeIdBitWidth) + return null; Span bytes = stackalloc byte[16]; guid.TryWriteBytes(bytes, bigEndian: true, out _); diff --git a/src/UuidV7NodePartition.cs b/src/UuidV7NodePartition.cs index b6a90b3..8034ee8 100644 --- a/src/UuidV7NodePartition.cs +++ b/src/UuidV7NodePartition.cs @@ -77,7 +77,7 @@ public static ushort GetMaxNodeId(byte nodeIdBitWidth) return (ushort)((1 << nodeIdBitWidth) - 1); } - internal static void ValidateBitWidth(byte nodeIdBitWidth) + private static void ValidateBitWidth(byte nodeIdBitWidth) { if (nodeIdBitWidth is < MinNodeIdBitWidth or > MaxNodeIdBitWidth) { diff --git a/tests/UuidV7NodePartitionTests.cs b/tests/UuidV7NodePartitionTests.cs index cf08ac4..d1dcbfa 100644 --- a/tests/UuidV7NodePartitionTests.cs +++ b/tests/UuidV7NodePartitionTests.cs @@ -78,6 +78,39 @@ public void GetNodePartitionId_ReturnsNull_WhenGuidIsNotUuidV7() Assert.Null(Guid.NewGuid().GetNodePartitionId(10)); } + [Theory] + [InlineData((byte)0)] + [InlineData((byte)17)] + public void GetNodePartitionId_ReturnsNull_WhenBitWidthInvalid(byte nodeIdBitWidth) + { + var partition = new UuidV7NodePartition(nodeId: 42, nodeIdBitWidth: 10); + var time = SimulatedTimeProvider.FromUnixMs(1_700_000_000_000); + using var rng = new DeterministicRandomNumberGenerator(seed: 123); + using var factory = new UuidV7Factory(time, partition, rng); + + var guid = factory.NewGuid(); + + Assert.Null(guid.GetNodePartitionId(nodeIdBitWidth)); + } + + [Fact] + public void AddNodePartitionedGuidFactory_SystemTime_RegistersSingletonFactory() + { + var services = new ServiceCollection(); + var partition = new UuidV7NodePartition(nodeId: 7, nodeIdBitWidth: 10); + + services.AddNodePartitionedGuidFactory(partition); + + using var provider = services.BuildServiceProvider(); + var factory = provider.GetRequiredService(); + var concrete = provider.GetRequiredService(); + var id = factory.NewGuid(); + + Assert.Same(factory, concrete); + Assert.Equal(partition, concrete.NodePartition); + Assert.Equal((ushort?)7, id.GetNodePartitionId(10)); + } + [Fact] public void AddNodePartitionedGuidFactory_RegistersSingletonFactory() { From a7c24653b45bb4d3a73f4ff80b542cd8592b6203 Mon Sep 17 00:00:00 2001 From: Dexter Ajoku Date: Fri, 15 May 2026 12:10:23 +0200 Subject: [PATCH 5/5] Precompute UUIDv7 node partition masks Refs #68. --- src/UuidV7NodePartition.cs | 44 ++++++++++++++++++++++++-------------- 1 file changed, 28 insertions(+), 16 deletions(-) diff --git a/src/UuidV7NodePartition.cs b/src/UuidV7NodePartition.cs index 8034ee8..2bc75cd 100644 --- a/src/UuidV7NodePartition.cs +++ b/src/UuidV7NodePartition.cs @@ -10,6 +10,13 @@ namespace Clockworks; /// public readonly record struct UuidV7NodePartition { + private readonly byte _byte8AndMask; + private readonly byte _byte8OrValue; + private readonly byte _byte9AndMask; + private readonly byte _byte9OrValue; + private readonly byte _byte10AndMask; + private readonly byte _byte10OrValue; + /// /// Number of effective random bits available in UUIDv7 rand_b after the RFC variant bits. /// @@ -49,6 +56,8 @@ public UuidV7NodePartition(ushort nodeId, byte nodeIdBitWidth) NodeId = nodeId; NodeIdBitWidth = nodeIdBitWidth; + (_byte8AndMask, _byte8OrValue, _byte9AndMask, _byte9OrValue, _byte10AndMask, _byte10OrValue) = + CreateApplyPlan(nodeId, nodeIdBitWidth); } /// @@ -90,8 +99,9 @@ private static void ValidateBitWidth(byte nodeIdBitWidth) internal void ApplyTo(Span uuidBytes) { - uuidBytes[8] = (byte)((uuidBytes[8] & 0x3F) | 0x80); - WriteNodeId(uuidBytes, NodeId, NodeIdBitWidth); + uuidBytes[8] = (byte)((uuidBytes[8] & _byte8AndMask) | _byte8OrValue); + uuidBytes[9] = (byte)((uuidBytes[9] & _byte9AndMask) | _byte9OrValue); + uuidBytes[10] = (byte)((uuidBytes[10] & _byte10AndMask) | _byte10OrValue); } internal static ushort ReadNodeId(ReadOnlySpan uuidBytes, byte nodeIdBitWidth) @@ -121,36 +131,38 @@ internal static ushort ReadNodeId(ReadOnlySpan uuidBytes, byte nodeIdBitWi return nodeId; } - private static void WriteNodeId(Span uuidBytes, ushort nodeId, byte nodeIdBitWidth) + private static (byte Byte8AndMask, byte Byte8OrValue, byte Byte9AndMask, byte Byte9OrValue, byte Byte10AndMask, byte Byte10OrValue) + CreateApplyPlan(ushort nodeId, byte nodeIdBitWidth) { if (nodeIdBitWidth <= 6) { var shift = 6 - nodeIdBitWidth; - var mask = ((1 << nodeIdBitWidth) - 1) << shift; - var value = nodeId << shift; - uuidBytes[8] = (byte)((uuidBytes[8] & ~mask) | value); - return; + var nodeMask = ((1 << nodeIdBitWidth) - 1) << shift; + var byte8AndMask = (byte)(0x3F & ~nodeMask); + var byte8Value = (byte)(0x80 | (nodeId << shift)); + return (byte8AndMask, byte8Value, 0xFF, 0, 0xFF, 0); } var remaining = nodeIdBitWidth - 6; - uuidBytes[8] = (byte)((uuidBytes[8] & 0xC0) | ((nodeId >> remaining) & 0x3F)); + var byte8OrValue = (byte)(0x80 | ((nodeId >> remaining) & 0x3F)); var remainingValue = nodeId & ((1 << remaining) - 1); if (remaining <= 8) { var shift = 8 - remaining; - var mask = ((1 << remaining) - 1) << shift; - var value = remainingValue << shift; - uuidBytes[9] = (byte)((uuidBytes[9] & ~mask) | value); - return; + var nodeMask = ((1 << remaining) - 1) << shift; + var byte9AndMask = (byte)~nodeMask; + var byte9PartialValue = (byte)(remainingValue << shift); + return (0, byte8OrValue, byte9AndMask, byte9PartialValue, 0xFF, 0); } var finalBits = remaining - 8; - uuidBytes[9] = (byte)(remainingValue >> finalBits); + var byte9FullValue = (byte)(remainingValue >> finalBits); var finalShift = 8 - finalBits; - var finalMask = ((1 << finalBits) - 1) << finalShift; - var finalValue = (remainingValue & ((1 << finalBits) - 1)) << finalShift; - uuidBytes[10] = (byte)((uuidBytes[10] & ~finalMask) | finalValue); + var finalNodeMask = ((1 << finalBits) - 1) << finalShift; + var byte10AndMask = (byte)~finalNodeMask; + var byte10OrValue = (byte)((remainingValue & ((1 << finalBits) - 1)) << finalShift); + return (0, byte8OrValue, 0, byte9FullValue, byte10AndMask, byte10OrValue); } }