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
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.1] - 2026-04-02

### Build
Expand Down
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,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)
Expand Down
4 changes: 3 additions & 1 deletion docs/guide/hlc.md
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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:
Expand Down
1 change: 1 addition & 0 deletions docs/guide/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -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);
```

Expand Down
14 changes: 14 additions & 0 deletions property-tests/HlcCoordinatorProperties.fs
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,20 @@ open FsCheck.Xunit
open Clockworks
open Clockworks.Distributed

/// Property: HLC UUIDv7 encoding preserves every supported 14-bit node ID.
[<Property(MaxTest = 100)>]
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.
[<Property(MaxTest = 100)>]
let ``BeforeSend timestamps are strictly increasing`` (advances: uint16 list) =
Expand Down
7 changes: 7 additions & 0 deletions src/Distributed/HlcClusterRegistry.cs
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,13 @@ public HlcClusterRegistry(TimeProvider timeProvider)
/// <summary>
/// Register a node in the cluster.
/// </summary>
/// <param name="nodeId">
/// Unique 14-bit node identifier (0-16383) encoded into generated HLC UUIDv7 values.
/// </param>
/// <param name="options">HLC drift and overflow behavior options.</param>
/// <exception cref="ArgumentOutOfRangeException">
/// Thrown when <paramref name="nodeId"/> exceeds <see cref="HlcGuidFactory.MaxNodeId"/>.
/// </exception>
public HlcGuidFactory RegisterNode(ushort nodeId, HlcOptions? options = null)
{
return _nodes.GetOrAdd(nodeId, id => new HlcGuidFactory(_sharedTimeProvider, id, options));
Expand Down
10 changes: 8 additions & 2 deletions src/Extensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,8 @@ public IServiceCollection AddLockFreeGuidFactory(
/// Adds the HLC GUID factory with system time.
/// Use this for distributed systems requiring causal ordering.
/// </summary>
/// <param name="nodeId">Unique 14-bit node identifier (0-16383) encoded into generated HLC UUIDv7 values.</param>
/// <param name="options">HLC drift and overflow behavior options.</param>
public IServiceCollection AddHlcGuidFactory(
ushort nodeId = 0,
HlcOptions? options = null)
Expand All @@ -68,6 +70,10 @@ public IServiceCollection AddHlcGuidFactory(
/// Adds the HLC GUID factory with a custom TimeProvider.
/// Use this for testing or simulation.
/// </summary>
/// <param name="timeProvider">Time source used by the HLC factory.</param>
/// <param name="nodeId">Unique 14-bit node identifier (0-16383) encoded into generated HLC UUIDv7 values.</param>
/// <param name="options">HLC drift and overflow behavior options.</param>
/// <param name="rng">Random number generator used for the UUID random tail.</param>
public IServiceCollection AddHlcGuidFactory(
TimeProvider timeProvider,
ushort nodeId = 0,
Expand Down Expand Up @@ -141,7 +147,7 @@ public static class GuidExtensions
}

/// <summary>
/// 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.
/// </summary>
public ushort? GetNodeId()
Expand All @@ -152,7 +158,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]);
}

Expand Down
21 changes: 19 additions & 2 deletions src/HlcGuidFactory.cs
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,11 @@ namespace Clockworks;
/// </summary>
public sealed class HlcGuidFactory : IHlcGuidFactory, IDisposable
{
/// <summary>
/// Maximum node identifier that can be encoded in an HLC UUIDv7 value.
/// </summary>
public const ushort MaxNodeId = 0x3FFF;

private readonly TimeProvider _timeProvider;
private readonly RandomNumberGenerator _rng;
private readonly bool _ownsRng;
Expand Down Expand Up @@ -82,16 +87,28 @@ public sealed class HlcGuidFactory : IHlcGuidFactory, IDisposable
/// Creates a new HLC-based GUID factory.
/// </summary>
/// <param name="timeProvider">Time source</param>
/// <param name="nodeId">Unique identifier for this node (0-65535)</param>
/// <param name="nodeId">Unique 14-bit identifier for this node (0-16383).</param>
/// <param name="options">HLC configuration options</param>
/// <param name="rng">Random number generator (null = create new CSPRNG)</param>
/// <exception cref="ArgumentOutOfRangeException">
/// Thrown when <paramref name="nodeId"/> exceeds <see cref="MaxNodeId"/> and cannot be encoded in the
/// HLC UUIDv7 node field.
/// </exception>
public HlcGuidFactory(
TimeProvider timeProvider,
ushort nodeId = 0,
HlcOptions? options = null,
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();
Expand Down Expand Up @@ -359,7 +376,7 @@ private Guid CreateGuidFromHlc(HlcTimestamp timestamp, ReadOnlySpan<byte> 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));
Expand Down
35 changes: 35 additions & 0 deletions tests/HlcGuidFactoryNodeIdTests.cs
Original file line number Diff line number Diff line change
@@ -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<ArgumentOutOfRangeException>(
() => new HlcGuidFactory(time, unsupportedNodeId));

Assert.Equal("nodeId", ex.ParamName);
Assert.Equal(unsupportedNodeId, ex.ActualValue);
}
}
Loading