From 9dd73d68c086db21fa4e68cd4e23c0c8812a3a3f Mon Sep 17 00:00:00 2001 From: Dexter Ajoku Date: Thu, 14 May 2026 23:25:11 +0200 Subject: [PATCH 1/3] Fix HLC UUID node id contract Fixes #71 --- property-tests/HlcCoordinatorProperties.fs | 14 +++++++++ src/Extensions.cs | 4 +-- src/HlcGuidFactory.cs | 19 +++++++++++- tests/HlcGuidFactoryNodeIdTests.cs | 35 ++++++++++++++++++++++ 4 files changed, 69 insertions(+), 3 deletions(-) create mode 100644 tests/HlcGuidFactoryNodeIdTests.cs diff --git a/property-tests/HlcCoordinatorProperties.fs b/property-tests/HlcCoordinatorProperties.fs index 9b2d996..02db445 100644 --- a/property-tests/HlcCoordinatorProperties.fs +++ b/property-tests/HlcCoordinatorProperties.fs @@ -7,6 +7,20 @@ open FsCheck.Xunit open Clockworks open Clockworks.Distributed +/// Property: HLC UUIDv7 encoding preserves every supported 14-bit node ID. +[] +let ``HlcGuidFactory UUID preserves supported node id`` (nodeId: uint16) = + let safeNodeId = nodeId &&& HlcGuidFactory.MaxNodeId + let timeProvider = new SimulatedTimeProvider() + use factory = new HlcGuidFactory(timeProvider, nodeId = safeNodeId) + + let struct (guid, timestamp) = factory.NewGuidWithHlc() + let decoded = guid.ToHlcTimestamp() + + timestamp.NodeId = safeNodeId + && decoded.HasValue + && decoded.Value.NodeId = safeNodeId + /// Property: Sequential sends should always produce increasing timestamps. [] let ``BeforeSend timestamps are strictly increasing`` (advances: uint16 list) = diff --git a/src/Extensions.cs b/src/Extensions.cs index 249cb2c..eb6158c 100644 --- a/src/Extensions.cs +++ b/src/Extensions.cs @@ -141,7 +141,7 @@ public static class GuidExtensions } /// - /// For HLC-encoded UUIDv7s, extracts the node ID. + /// For HLC-encoded UUIDv7s, extracts the 14-bit node ID. /// Returns null if not a valid UUIDv7 or node ID not encoded. /// public ushort? GetNodeId() @@ -152,7 +152,7 @@ public static class GuidExtensions if ((bytes[6] & 0xF0) != 0x70) return null; - // Node ID is in bytes 8-9 (after variant bits) + // Node ID is in bytes 8-9 after masking off the two RFC variant bits. return (ushort)(((bytes[8] & 0x3F) << 8) | bytes[9]); } diff --git a/src/HlcGuidFactory.cs b/src/HlcGuidFactory.cs index 8007da0..5378504 100644 --- a/src/HlcGuidFactory.cs +++ b/src/HlcGuidFactory.cs @@ -55,6 +55,11 @@ namespace Clockworks; /// public sealed class HlcGuidFactory : IHlcGuidFactory, IDisposable { + /// + /// Maximum node identifier that can be encoded in an HLC UUIDv7 value. + /// + public const ushort MaxNodeId = 0x3FFF; + private readonly TimeProvider _timeProvider; private readonly RandomNumberGenerator _rng; private readonly bool _ownsRng; @@ -82,9 +87,13 @@ public sealed class HlcGuidFactory : IHlcGuidFactory, IDisposable /// Creates a new HLC-based GUID factory. /// /// Time source - /// Unique identifier for this node (0-65535) + /// Unique 14-bit identifier for this node (0-16383). /// HLC configuration options /// Random number generator (null = create new CSPRNG) + /// + /// Thrown when exceeds and cannot be encoded in the + /// HLC UUIDv7 node field. + /// public HlcGuidFactory( TimeProvider timeProvider, ushort nodeId = 0, @@ -92,6 +101,14 @@ public HlcGuidFactory( RandomNumberGenerator? rng = null) { _timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider)); + if (nodeId > MaxNodeId) + { + throw new ArgumentOutOfRangeException( + nameof(nodeId), + nodeId, + $"HLC UUIDv7 node IDs are encoded in 14 bits and must be less than or equal to {MaxNodeId}."); + } + _nodeId = nodeId; _options = options ?? HlcOptions.Default; _rng = rng ?? RandomNumberGenerator.Create(); diff --git a/tests/HlcGuidFactoryNodeIdTests.cs b/tests/HlcGuidFactoryNodeIdTests.cs new file mode 100644 index 0000000..dd76fa9 --- /dev/null +++ b/tests/HlcGuidFactoryNodeIdTests.cs @@ -0,0 +1,35 @@ +using Xunit; + +namespace Clockworks.Tests; + +public sealed class HlcGuidFactoryNodeIdTests +{ + [Fact] + public void Constructor_AllowsMaximumEncodableNodeId() + { + var time = SimulatedTimeProvider.FromUnixMs(1_700_000_000_000); + using var rng = new DeterministicRandomNumberGenerator(seed: 1); + using var factory = new HlcGuidFactory(time, HlcGuidFactory.MaxNodeId, rng: rng); + + var (guid, timestamp) = factory.NewGuidWithHlc(); + var decoded = guid.ToHlcTimestamp(); + + Assert.Equal(HlcGuidFactory.MaxNodeId, timestamp.NodeId); + Assert.Equal(HlcGuidFactory.MaxNodeId, guid.GetNodeId()); + Assert.NotNull(decoded); + Assert.Equal(HlcGuidFactory.MaxNodeId, decoded.Value.NodeId); + } + + [Fact] + public void Constructor_RejectsNodeIdsThatCannotBeEncodedInUuid() + { + var time = SimulatedTimeProvider.FromUnixMs(1_700_000_000_000); + var unsupportedNodeId = (ushort)(HlcGuidFactory.MaxNodeId + 1); + + var ex = Assert.Throws( + () => new HlcGuidFactory(time, unsupportedNodeId)); + + Assert.Equal("nodeId", ex.ParamName); + Assert.Equal(unsupportedNodeId, ex.ActualValue); + } +} From a28be2f325d6032fa8dca0d8b56b178d9f2f08be Mon Sep 17 00:00:00 2001 From: Dexter Ajoku Date: Thu, 14 May 2026 23:27:35 +0200 Subject: [PATCH 2/3] Document HLC UUID node id range Refs #71 --- README.md | 2 ++ docs/guide/hlc.md | 4 +++- docs/guide/index.md | 1 + src/Distributed/HlcClusterRegistry.cs | 4 ++++ src/Extensions.cs | 6 ++++++ src/HlcGuidFactory.cs | 2 +- 6 files changed, 17 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index f44b3fb..5c8e68a 100644 --- a/README.md +++ b/README.md @@ -126,6 +126,8 @@ var t2 = b.BeforeSend(); Console.WriteLine(t1 < t2); // true ``` +When using `HlcGuidFactory`, `nodeId` is encoded into generated UUIDv7 values as a 14-bit field. Use values in `0..HlcGuidFactory.MaxNodeId` (`0..16383`); larger values are rejected because the UUID variant bits leave only 14 recoverable node-id bits. + ## Distributed Systems Support ### Hybrid Logical Clock (HLC) diff --git a/docs/guide/hlc.md b/docs/guide/hlc.md index 9e77c8a..8c498de 100644 --- a/docs/guide/hlc.md +++ b/docs/guide/hlc.md @@ -80,7 +80,7 @@ Console.WriteLine(t1 < t2); // true ## HLC GUIDs (UUIDv7 encoding) -`HlcGuidFactory` also generates UUIDv7 `Guid` values that embed the HLC wall time and counter (and a node id field). +`HlcGuidFactory` also generates UUIDv7 `Guid` values that embed the HLC wall time and counter, plus a 14-bit node ID field. Node IDs must be in the range `0..HlcGuidFactory.MaxNodeId` (`0..16383`) because the UUID variant consumes the top two bits of the UUID bytes that carry the node field. ```csharp using var factory = new HlcGuidFactory(TimeProvider.System, nodeId: 42); @@ -99,6 +99,8 @@ Console.WriteLine(decoded?.Counter); Console.WriteLine(decoded?.NodeId); // node id is stored in 14 bits in the UUID ``` +`HlcTimestamp` itself can still represent a full `ushort` node ID in its canonical 80-bit `WriteTo` / `ReadFrom` format. The 14-bit limit applies specifically to node IDs used with `HlcGuidFactory`, where the node ID must be recoverable from an RFC-compatible UUIDv7 value. + ## Drift Configuration `HlcOptions` controls how the coordinator handles clock drift: diff --git a/docs/guide/index.md b/docs/guide/index.md index 93b9adc..ad211a6 100644 --- a/docs/guide/index.md +++ b/docs/guide/index.md @@ -71,6 +71,7 @@ var services = new ServiceCollection(); services.AddLockFreeGuidFactory(); // Or: HLC-based factory (also registers IUuidV7Factory) +// nodeId is encoded into HLC UUIDv7 values and must be in 0..HlcGuidFactory.MaxNodeId (0..16383) services.AddHlcGuidFactory(nodeId: 1, options: HlcOptions.Default); ``` diff --git a/src/Distributed/HlcClusterRegistry.cs b/src/Distributed/HlcClusterRegistry.cs index 51fc3a2..8bda05c 100644 --- a/src/Distributed/HlcClusterRegistry.cs +++ b/src/Distributed/HlcClusterRegistry.cs @@ -22,6 +22,10 @@ public HlcClusterRegistry(TimeProvider timeProvider) /// /// Register a node in the cluster. /// + /// + /// Unique 14-bit node identifier (0-16383) encoded into generated HLC UUIDv7 values. + /// + /// HLC drift and overflow behavior options. public HlcGuidFactory RegisterNode(ushort nodeId, HlcOptions? options = null) { return _nodes.GetOrAdd(nodeId, id => new HlcGuidFactory(_sharedTimeProvider, id, options)); diff --git a/src/Extensions.cs b/src/Extensions.cs index eb6158c..9e4f5ab 100644 --- a/src/Extensions.cs +++ b/src/Extensions.cs @@ -49,6 +49,8 @@ public IServiceCollection AddLockFreeGuidFactory( /// Adds the HLC GUID factory with system time. /// Use this for distributed systems requiring causal ordering. /// + /// Unique 14-bit node identifier (0-16383) encoded into generated HLC UUIDv7 values. + /// HLC drift and overflow behavior options. public IServiceCollection AddHlcGuidFactory( ushort nodeId = 0, HlcOptions? options = null) @@ -68,6 +70,10 @@ public IServiceCollection AddHlcGuidFactory( /// Adds the HLC GUID factory with a custom TimeProvider. /// Use this for testing or simulation. /// + /// Time source used by the HLC factory. + /// Unique 14-bit node identifier (0-16383) encoded into generated HLC UUIDv7 values. + /// HLC drift and overflow behavior options. + /// Random number generator used for the UUID random tail. public IServiceCollection AddHlcGuidFactory( TimeProvider timeProvider, ushort nodeId = 0, diff --git a/src/HlcGuidFactory.cs b/src/HlcGuidFactory.cs index 5378504..0403927 100644 --- a/src/HlcGuidFactory.cs +++ b/src/HlcGuidFactory.cs @@ -376,7 +376,7 @@ private Guid CreateGuidFromHlc(HlcTimestamp timestamp, ReadOnlySpan random BinaryPrimitives.WriteUInt16BigEndian(bytes.Slice(6, 2), timestamp.Counter); bytes[6] = (byte)(Version7 | (bytes[6] & VersionMask)); - // Bytes 8-9: variant (2 bits) + node ID high bits (14 bits across bytes 8-9) + // Bytes 8-9: variant (2 bits) + node ID (14 bits across bytes 8-9) // We encode node ID in the "random" portion for correlation BinaryPrimitives.WriteUInt16BigEndian(bytes.Slice(8, 2), timestamp.NodeId); bytes[8] = (byte)(VariantRfc4122 | (bytes[8] & VariantMask)); From 8cc3979ff76c94c56dd77c41dd14701c743e2cb8 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 14 May 2026 21:40:55 +0000 Subject: [PATCH 3/3] Add exception doc to RegisterNode and CHANGELOG breaking-change entry Agent-Logs-Url: https://github.com/dexcompiler/Clockworks/sessions/d7e3a2dd-771f-4d9f-a4ea-dc681577c2e4 Co-authored-by: dexcompiler <115876036+dexcompiler@users.noreply.github.com> --- CHANGELOG.md | 3 +++ src/Distributed/HlcClusterRegistry.cs | 3 +++ 2 files changed, 6 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3dd7b11..201b361 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,9 @@ and this project aims to follow [Semantic Versioning](https://semver.org/spec/v2 ## [Unreleased] +### 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. + ## [1.3.0] - 2026-02-19 ### Fixed diff --git a/src/Distributed/HlcClusterRegistry.cs b/src/Distributed/HlcClusterRegistry.cs index 8bda05c..4f20fe2 100644 --- a/src/Distributed/HlcClusterRegistry.cs +++ b/src/Distributed/HlcClusterRegistry.cs @@ -26,6 +26,9 @@ public HlcClusterRegistry(TimeProvider timeProvider) /// Unique 14-bit node identifier (0-16383) encoded into generated HLC UUIDv7 values. /// /// HLC drift and overflow behavior options. + /// + /// Thrown when exceeds . + /// public HlcGuidFactory RegisterNode(ushort nodeId, HlcOptions? options = null) { return _nodes.GetOrAdd(nodeId, id => new HlcGuidFactory(_sharedTimeProvider, id, options));