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 @@ -10,11 +10,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.
- Add opt-in `UuidV7FactoryState` restart frontier snapshots for services that persist and restore UUIDv7 logical cursors.

### 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 restart-state invariants, durability boundaries, and multi-writer failure modes.
- 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.
Expand Down
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ It is built around `TimeProvider` so that *time becomes an injectable dependency
- Works with real or simulated time
- Configurable counter overflow behavior
- Optional `rand_b` node partitioning for distributed fleets with assigned node/shard IDs
- Optional restart frontier state for services that persist and restore the UUIDv7 logical cursor
- 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 Expand Up @@ -77,7 +78,7 @@ var factory = new UuidV7Factory(TimeProvider.System);
var id = factory.NewGuid();
```

For production services, prefer one shared `UuidV7Factory` instance per process. Its monotonic `(timestamp, counter)` allocation is deterministic within that live factory, including when wall time moves backwards. Independent factories, restarts, and multi-node fleets do not share that logical frontier; global uniqueness remains probabilistic and should be backed by storage uniqueness constraints where collisions are unacceptable.
For production services, prefer one shared `UuidV7Factory` instance per process. Its monotonic `(timestamp, counter)` allocation is deterministic within that live factory, including when wall time moves backwards. Independent factories and multi-node fleets do not share that logical frontier; global uniqueness remains probabilistic and should be backed by storage uniqueness constraints where collisions are unacceptable. Services that need restart-aware monotonicity can persist `UuidV7FactoryState` from `GetState()` and restore it into a later factory.

`UuidV7Factory` owns a cryptographically secure RNG by default. Custom deterministic RNGs are useful for replayable tests and simulations, but identical deterministic RNG state plus identical time and call patterns can intentionally reproduce the same UUID sequence. Do not use seeded or deterministic RNGs for production UUID issuance.

Expand Down
1 change: 1 addition & 0 deletions docs/.vitepress/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@ export default defineConfig({
{ 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: 'UUIDv7 Restart State', link: '/concepts/uuidv7-restart-state' },
{ 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 @@ -13,11 +13,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.
- Add opt-in `UuidV7FactoryState` restart frontier snapshots for services that persist and restore UUIDv7 logical cursors.

### 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 restart-state invariants, durability boundaries, and multi-writer failure modes.
- 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.
Expand Down
57 changes: 57 additions & 0 deletions docs/concepts/uuidv7-restart-state.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
---
title: UUIDv7 Restart State
---

# UUIDv7 Restart State

`UuidV7Factory` is monotonic within a live factory instance. `UuidV7FactoryState` extends that guarantee across a controlled restart by letting an application persist the logical frontier and restore it into the replacement factory.

## Model

The factory frontier is ordered lexicographically:

```text
(timestampMs, counter)
```

where `timestampMs` is the 48-bit UUIDv7 Unix millisecond field and `counter` is the 12-bit `rand_a` monotonic counter. Internally, the factory stores this pair in one atomic 64-bit word, so restoring state is a compare-and-swap max operation rather than a lock.

```mermaid
flowchart LR
A["Generate UUID"] --> B["GetState()"]
B --> C["Persist 8-byte frontier"]
C --> D["Restart process"]
D --> E["ReadFrom(bytes)"]
E --> F["new UuidV7Factory(timeProvider, restoredState)"]
F --> G["Next UUID is above restored frontier"]
```

## Correctness Invariant

For a restored frontier `R`, the next successfully generated UUID observes:

```text
generated_frontier > R
```

If physical time is ahead of `R.timestampMs`, the factory starts from physical time and a random counter start. If physical time is equal to or behind `R.timestampMs`, the factory starts from `R` and advances the counter or logical timestamp on the next allocation.

This preserves monotonicity relative to the persisted cursor without adding work to the normal `NewGuid()` hot path. `GetState()` reads one atomic word, `RestoreState()` only advances the current state, and `UuidV7FactoryState.WriteTo(...)` writes the canonical 8-byte encoding into caller-provided memory.

## Failure Modes

Restart state is a local recovery mechanism, not a distributed allocator.

| Scenario | Behavior |
|---|---|
| Crash after durable UUID write but before state persistence | The latest frontier may be lost. Store state in the same durability boundary as the data protected by the UUID. |
| Multiple live factories restore the same state | They can overlap in logical `(timestamp, counter)` space. Use node partitioning, HLC UUIDs, external coordination, or storage uniqueness constraints. |
| Restored timestamp is far ahead of physical time | The factory continues from the restored logical time and reports logical drift through statistics when enabled. |
| Restored same-millisecond counter is already maxed | `SpinWait` waits for physical time to advance; `IncrementTimestamp` advances logical time. |

For high-assurance systems, treat restart state as one layer in a deterministic issuance protocol:

1. Generate UUIDs from a singleton factory.
2. Commit application data and the latest factory state in the same durable transaction or checkpoint.
3. Keep a storage uniqueness constraint on the identifier column.
4. Use node partitioning or `HlcGuidFactory` when several writers intentionally share a namespace.
45 changes: 44 additions & 1 deletion docs/guide/uuidv7.md
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,8 @@ This guarantees strict per-instance monotonicity without locks. The guarantee is
| Multiple threads sharing one factory | Same per-instance guarantee; CAS retries may occur under contention, but successful allocations do not reuse a pair. |
| Multiple factories in one process | No shared logical frontier. Full UUID collisions are still extremely unlikely with independent CSPRNG state, but uniqueness is probabilistic. |
| Multiple processes or machines | No built-in global coordination or node discriminator in `UuidV7Factory`. Clock skew can increase timestamp overlap, while uniqueness depends on randomized counter starts and the 62-bit random tail. |
| Process restart | The new factory starts from current wall time and a random counter start. It does not inherit the previous logical frontier. |
| Process restart without restored state | The new factory starts from current wall time and a random counter start. It does not inherit the previous logical frontier. |
| Process restart with restored state | The new factory starts at or above the restored logical frontier. This prevents rollback relative to the persisted cursor, but it does not coordinate multiple live writers. |

For production services, prefer a single `UuidV7Factory` singleton per process or service instance. The built-in DI helpers register it this way.

Expand Down Expand Up @@ -91,6 +92,48 @@ services.AddNodePartitionedGuidFactory(

See [UUIDv7 Node Partitioning](/concepts/uuidv7-node-partitioning) for the design trade-offs.

## Restart State

`UuidV7FactoryState` captures the factory's current logical frontier as a compact `(timestampMs, counter)` cursor. Persisting this state lets a later process restore the frontier after a restart:

```csharp
using var factory = new UuidV7Factory(TimeProvider.System);

var id = factory.NewGuid();
var state = factory.GetState();

Span<byte> buffer = stackalloc byte[UuidV7FactoryState.EncodedLength];
state.WriteTo(buffer);

// Persist the eight bytes durably with your application's checkpoint.
```

To restore:

```csharp
var restored = UuidV7FactoryState.ReadFrom(persistedBytes);
using var factory = new UuidV7Factory(TimeProvider.System, restored);
```

Checkpointing is exposed on the concrete `UuidV7Factory` type. The narrower `IUuidV7Factory` interface remains generation-only so alternative implementations are not forced into the same persistence model.

Correctness invariant:

```text
next_generated_frontier > restored_frontier
```

More precisely, if the restored timestamp is equal to or ahead of physical time, the factory initializes from the restored cursor and the next UUID advances it. If physical time is already ahead of the restored timestamp, the factory uses physical time and a fresh random counter start. Either way, future allocations do not move below the restored frontier.

Important operational limits:

- Persist state after the UUIDs it covers are durably committed. A crash after issuing a UUID but before persisting the new state can still lose the last frontier.
- Restored state coordinates one replacement factory. It does not coordinate multiple live factories restoring the same cursor.
- Use node partitioning, `HlcGuidFactory`, external allocation, or storage uniqueness constraints when multiple writers share a namespace.
- If you restore a same-millisecond max-counter state with `SpinWait`, the next allocation may wait for physical time to advance. For deterministic simulations, prefer `CounterOverflowBehavior.Auto` or `IncrementTimestamp`.

See [UUIDv7 Restart State](/concepts/uuidv7-restart-state) for the design notes and failure modes.

## 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
33 changes: 33 additions & 0 deletions property-tests/UuidV7FactoryProperties.fs
Original file line number Diff line number Diff line change
Expand Up @@ -237,6 +237,39 @@ let ``Node partitions separate identical deterministic UUID streams`` (rawWidth:
&& leftId.GetNodePartitionId(width).Value = 0us
&& rightId.GetNodePartitionId(width).Value = maxNodeId)

/// Property: restored UUIDv7 factory state is a monotonic lower bound for future generation.
[<Property(MaxTest = 50)>]
let ``Restored state is a monotonic lower bound`` (counter: uint16) (rollbackMs: uint16) =
let safeCounter = counter % (UuidV7FactoryState.MaxCounter + 1us)
let safeRollbackMs = int64 (rollbackMs % 1000us) + 1L
let stateMs = 1_700_000_000_000L
let restoredState = UuidV7FactoryState(stateMs, safeCounter)
let timeProvider = SimulatedTimeProvider.FromUnixMs(stateMs - safeRollbackMs)
use factory = new UuidV7Factory(timeProvider, restoredState)

let uuid = factory.NewGuid()
let timestamp = uuid.GetTimestampMs().Value
let generatedCounter = uuid.GetCounter().Value

timestamp > restoredState.TimestampMs
|| (timestamp = restoredState.TimestampMs && generatedCounter > safeCounter)

/// Property: restoring an older UUIDv7 factory state never lowers the current frontier.
[<Property(MaxTest = 50)>]
let ``RestoreState never lowers frontier`` (count: byte) =
let safeCount = int (count % 64uy) + 1
let timeProvider = new SimulatedTimeProvider()
use factory = new UuidV7Factory(timeProvider)

for _ in 1..safeCount do
factory.NewGuid() |> ignore

let current = factory.GetState()
factory.RestoreState(UuidV7FactoryState(0L, 0us))
let afterRestore = factory.GetState()

afterRestore = current

/// Property: UUIDs generated at different times are different
[<Property(MaxTest = 50)>]
let ``UUIDs change with time`` (advanceMs: uint16) =
Expand Down
Loading
Loading