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
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ It is built around `TimeProvider` so that *time becomes an injectable dependency
- `UuidV7Factory` produces RFC 9562 UUIDv7 values as `Guid`
- Works with real or simulated time
- Configurable counter overflow behavior
- Optional `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

Expand Down
69 changes: 69 additions & 0 deletions demo/Clockworks.Demo/Demos/UuidV7Showcase.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand All @@ -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))
Expand Down
1 change: 1 addition & 0 deletions docs/.vitepress/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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' },
],
Expand Down
2 changes: 2 additions & 0 deletions docs/changelog.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
57 changes: 57 additions & 0 deletions docs/concepts/uuidv7-node-partitioning.md
Original file line number Diff line number Diff line change
@@ -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.
24 changes: 24 additions & 0 deletions docs/guide/uuidv7.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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<Guid>)`.
38 changes: 38 additions & 0 deletions property-tests/UuidV7FactoryProperties.fs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
[<Property(MaxTest = 50)>]
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.
[<Property(MaxTest = 50)>]
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
[<Property(MaxTest = 50)>]
let ``UUIDs change with time`` (advanceMs: uint16) =
Expand Down
79 changes: 79 additions & 0 deletions src/Extensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,63 @@ public IServiceCollection AddLockFreeGuidFactory(
return services;
}

/// <summary>
/// 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.
/// </summary>
/// <param name="nodePartition">Node partition to embed into the most-significant bits of UUIDv7 <c>rand_b</c>.</param>
/// <param name="overflowBehavior">Behavior to apply when the per-millisecond counter overflows.</param>
public IServiceCollection AddNodePartitionedGuidFactory(
UuidV7NodePartition nodePartition,
CounterOverflowBehavior overflowBehavior = CounterOverflowBehavior.SpinWait)
{
services.TryAddSingleton(TimeProvider.System);
services.AddSingleton<IUuidV7Factory>(sp => new UuidV7Factory(
sp.GetRequiredService<TimeProvider>(),
nodePartition,
overflowBehavior: overflowBehavior));
services.AddSingleton(sp => (UuidV7Factory)sp.GetRequiredService<IUuidV7Factory>());

return services;
}

/// <summary>
/// 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
/// <paramref name="timeProvider"/> and <paramref name="rng"/> instances remain caller-owned.
/// </summary>
/// <param name="timeProvider">Time source used by the UUIDv7 factory.</param>
/// <param name="nodePartition">Node partition to embed into the most-significant bits of UUIDv7 <c>rand_b</c>.</param>
/// <param name="rng">
/// Random number generator used for the UUID random tail. Leave null for a per-factory CSPRNG. Seeded or
/// deterministic RNGs are intended only for reproducible tests and simulations.
/// </param>
/// <param name="overflowBehavior">Behavior to apply when the per-millisecond counter overflows.</param>
/// <param name="statistics">Optional statistics instance updated by the registered singleton factory.</param>
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<IUuidV7Factory>(sp => new UuidV7Factory(
sp.GetRequiredService<TimeProvider>(),
nodePartition,
rng,
overflowBehavior,
statistics is null ? null : sp.GetRequiredService<UuidV7FactoryStatistics>()));
services.AddSingleton(sp => (UuidV7Factory)sp.GetRequiredService<IUuidV7Factory>());

return services;
}

/// <summary>
/// Adds the HLC GUID factory with system time.
/// Use this for distributed systems requiring causal ordering.
Expand Down Expand Up @@ -234,6 +291,28 @@ public static class GuidExtensions
return (ushort)(((bytes[8] & 0x3F) << 8) | bytes[9]);
}

/// <summary>
/// For node-partitioned UUIDv7s, extracts the configured node partition from the most-significant
/// <c>rand_b</c> bits.
/// </summary>
/// <param name="nodeIdBitWidth">Number of <c>rand_b</c> bits reserved for the node partition.</param>
/// <returns>
/// The node partition ID, or <see langword="null"/> if this GUID is not UUIDv7 or the bit width is invalid.
/// </returns>
public ushort? GetNodePartitionId(byte nodeIdBitWidth)
{
if (nodeIdBitWidth is < UuidV7NodePartition.MinNodeIdBitWidth or > UuidV7NodePartition.MaxNodeIdBitWidth)
return null;

Span<byte> 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);
}

/// <summary>
/// Checks if this GUID is a valid UUIDv7.
/// </summary>
Expand Down
Loading
Loading