From 2142e9d1ef6e37b14d956b98cfa6c4e5e5a1709c Mon Sep 17 00:00:00 2001 From: Dexter Ajoku Date: Fri, 15 May 2026 13:07:43 +0200 Subject: [PATCH 1/4] Add UUIDv7 restart frontier state (#69) --- src/UuidV7Factory.cs | 104 +++++++++++++++++++++++++++++++---- src/UuidV7FactoryState.cs | 112 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 204 insertions(+), 12 deletions(-) create mode 100644 src/UuidV7FactoryState.cs diff --git a/src/UuidV7Factory.cs b/src/UuidV7Factory.cs index 1b013d4..28e0840 100644 --- a/src/UuidV7Factory.cs +++ b/src/UuidV7Factory.cs @@ -65,9 +65,6 @@ public sealed class UuidV7Factory : IUuidV7Factory, IDisposable private const int MaxCounterValue = 0xFFF; // 12 bits = 4095 private const int CounterRandomStart = 0x7FF; // Start in lower half (11 bits max) - private const long TimestampMask = unchecked((long)0xFFFF_FFFF_FFFF_0000L); - private const long CounterMask = 0x0000_0000_0000_FFFFL; - // UUID constants private const byte Version7 = 0x70; // 0111 xxxx private const byte VersionMask = 0x0F; @@ -89,7 +86,7 @@ public UuidV7Factory( TimeProvider timeProvider, RandomNumberGenerator? rng = null, CounterOverflowBehavior overflowBehavior = CounterOverflowBehavior.SpinWait) - : this(timeProvider, rng, overflowBehavior, statistics: null, nodePartition: default) + : this(timeProvider, rng, overflowBehavior, statistics: null, nodePartition: default, restoredState: null) { } @@ -110,7 +107,28 @@ public UuidV7Factory( RandomNumberGenerator? rng, CounterOverflowBehavior overflowBehavior, UuidV7FactoryStatistics? statistics) - : this(timeProvider, rng, overflowBehavior, statistics, nodePartition: default) + : this(timeProvider, rng, overflowBehavior, statistics, nodePartition: default, restoredState: null) + { + } + + /// + /// Creates a new UUIDv7 generator with an opt-in restored logical frontier. + /// + /// Time source (use for production). + /// Persisted factory frontier to restore. + /// + /// 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. + /// + /// Behavior to apply when the per-millisecond counter overflows. + /// Statistics instance to update from this factory, or to disable statistics. + public UuidV7Factory( + TimeProvider timeProvider, + UuidV7FactoryState restoredState, + RandomNumberGenerator? rng = null, + CounterOverflowBehavior overflowBehavior = CounterOverflowBehavior.SpinWait, + UuidV7FactoryStatistics? statistics = null) + : this(timeProvider, rng, overflowBehavior, statistics, nodePartition: default, restoredState) { } @@ -132,7 +150,30 @@ public UuidV7Factory( RandomNumberGenerator? rng = null, CounterOverflowBehavior overflowBehavior = CounterOverflowBehavior.SpinWait, UuidV7FactoryStatistics? statistics = null) - : this(timeProvider, rng, overflowBehavior, statistics, nodePartition) + : this(timeProvider, rng, overflowBehavior, statistics, nodePartition, restoredState: null) + { + } + + /// + /// Creates a new UUIDv7 generator with an opt-in node partition and restored logical frontier. + /// + /// Time source (use for production). + /// Node, shard, process, or deployment discriminator to embed in generated UUIDs. + /// Persisted factory frontier to restore. + /// + /// 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. + /// + /// 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, + UuidV7FactoryState restoredState, + RandomNumberGenerator? rng = null, + CounterOverflowBehavior overflowBehavior = CounterOverflowBehavior.SpinWait, + UuidV7FactoryStatistics? statistics = null) + : this(timeProvider, rng, overflowBehavior, statistics, nodePartition, restoredState) { } @@ -141,7 +182,8 @@ private UuidV7Factory( RandomNumberGenerator? rng, CounterOverflowBehavior overflowBehavior, UuidV7FactoryStatistics? statistics, - UuidV7NodePartition nodePartition) + UuidV7NodePartition nodePartition, + UuidV7FactoryState? restoredState) { _timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider)); _rng = rng ?? RandomNumberGenerator.Create(); @@ -156,10 +198,11 @@ private UuidV7Factory( _randomBuffer = new ThreadLocal(() => new RandomBuffer(_rng, _statistics), trackAllValues: false); - // Initialize state with current time and random counter + // Initialize state with current time and random counter, unless an equal-or-future restored frontier exists. var initialTimestamp = _timeProvider.GetUtcNow().ToUnixTimeMilliseconds(); - var initialCounter = GetRandomCounterStart(); - _packedState = PackState(initialTimestamp, initialCounter); + _packedState = restoredState is { } state && state.TimestampMs >= initialTimestamp + ? PackState(state.TimestampMs, state.Counter) + : PackState(initialTimestamp, GetRandomCounterStart()); } /// @@ -172,6 +215,36 @@ private UuidV7Factory( /// public UuidV7NodePartition? NodePartition => _nodePartition.IsConfigured ? _nodePartition : null; + /// + /// Captures the current logical frontier for checkpointing and later restoration. + /// + public UuidV7FactoryState GetState() + { + var (timestampMs, counter) = UnpackState(Volatile.Read(ref _packedState)); + return new UuidV7FactoryState(timestampMs, counter); + } + + /// + /// Restores a persisted logical frontier, only advancing the current factory state. + /// + /// + /// If the current factory has already advanced beyond , this method does nothing. + /// + public void RestoreState(UuidV7FactoryState state) + { + var restoredPacked = PackState(state.TimestampMs, state.Counter); + var currentPacked = Volatile.Read(ref _packedState); + + while (IsPackedAfter(restoredPacked, currentPacked)) + { + var previous = Interlocked.CompareExchange(ref _packedState, restoredPacked, currentPacked); + if (previous == currentPacked) + return; + + currentPacked = previous; + } + } + /// [MethodImpl(MethodImplOptions.AggressiveInlining)] public Guid NewGuid() @@ -414,13 +487,20 @@ private Guid CreateGuidFromState(long timestampMs, ushort counter) [MethodImpl(MethodImplOptions.AggressiveInlining)] private static long PackState(long timestamp, ushort counter) { - return (timestamp << 16) | counter; + return unchecked((long)(((ulong)timestamp << 16) | counter)); } [MethodImpl(MethodImplOptions.AggressiveInlining)] private static (long Timestamp, ushort Counter) UnpackState(long packed) { - return (packed >> 16, (ushort)(packed & 0xFFFF)); + var value = unchecked((ulong)packed); + return ((long)(value >> 16), (ushort)value); + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static bool IsPackedAfter(long left, long right) + { + return unchecked((ulong)left) > unchecked((ulong)right); } [MethodImpl(MethodImplOptions.AggressiveInlining)] diff --git a/src/UuidV7FactoryState.cs b/src/UuidV7FactoryState.cs new file mode 100644 index 0000000..4781469 --- /dev/null +++ b/src/UuidV7FactoryState.cs @@ -0,0 +1,112 @@ +using System.Buffers.Binary; + +namespace Clockworks; + +/// +/// Serializable UUIDv7 factory frontier for restart-aware monotonicity. +/// +/// +/// The state represents the last logical (timestamp, counter) frontier observed by a . +/// Persisting and restoring this value lets a later factory avoid allocating below that frontier. It does not coordinate +/// multiple live factories; use node partitioning, external coordination, or storage uniqueness constraints when +/// multiple writers share a namespace. +/// +public readonly record struct UuidV7FactoryState +{ + /// + /// Number of bytes in the canonical big-endian state encoding. + /// + public const int EncodedLength = 8; + + /// + /// Maximum timestamp representable by UUIDv7's 48-bit Unix millisecond field. + /// + public const long MaxTimestampMs = 0xFFFF_FFFF_FFFFL; + + /// + /// Maximum 12-bit UUIDv7 monotonic counter value. + /// + public const ushort MaxCounter = 0x0FFF; + + /// + /// Creates a UUIDv7 factory state snapshot. + /// + /// Logical Unix timestamp in milliseconds. + /// 12-bit monotonic counter at . + public UuidV7FactoryState(long timestampMs, ushort counter) + { + if (timestampMs is < 0 or > MaxTimestampMs) + { + throw new ArgumentOutOfRangeException( + nameof(timestampMs), + timestampMs, + $"Timestamp must be between 0 and {MaxTimestampMs}."); + } + + if (counter > MaxCounter) + { + throw new ArgumentOutOfRangeException( + nameof(counter), + counter, + $"Counter must be less than or equal to {MaxCounter}."); + } + + TimestampMs = timestampMs; + Counter = counter; + } + + /// + /// Logical Unix timestamp in milliseconds. + /// + public long TimestampMs { get; } + + /// + /// 12-bit monotonic counter at . + /// + public ushort Counter { get; } + + /// + /// Serializes the state to a compact canonical big-endian byte array. + /// + public byte[] ToBytes() + { + var bytes = new byte[EncodedLength]; + WriteTo(bytes); + return bytes; + } + + /// + /// Writes the state to a compact canonical big-endian encoding. + /// + public void WriteTo(Span destination) + { + if (destination.Length < EncodedLength) + throw new ArgumentException($"Destination must be at least {EncodedLength} bytes.", nameof(destination)); + + destination[0] = (byte)(TimestampMs >> 40); + destination[1] = (byte)(TimestampMs >> 32); + destination[2] = (byte)(TimestampMs >> 24); + destination[3] = (byte)(TimestampMs >> 16); + destination[4] = (byte)(TimestampMs >> 8); + destination[5] = (byte)TimestampMs; + BinaryPrimitives.WriteUInt16BigEndian(destination.Slice(6, 2), Counter); + } + + /// + /// Reads state from the compact canonical big-endian encoding produced by . + /// + public static UuidV7FactoryState ReadFrom(ReadOnlySpan source) + { + if (source.Length < EncodedLength) + throw new ArgumentException($"Source must be at least {EncodedLength} bytes.", nameof(source)); + + var timestampMs = ((long)source[0] << 40) | + ((long)source[1] << 32) | + ((long)source[2] << 24) | + ((long)source[3] << 16) | + ((long)source[4] << 8) | + source[5]; + var counter = BinaryPrimitives.ReadUInt16BigEndian(source.Slice(6, 2)); + return new UuidV7FactoryState(timestampMs, counter); + } +} From 163ccfd1ddb8120a3e577f42d292bddf86a39760 Mon Sep 17 00:00:00 2001 From: Dexter Ajoku Date: Fri, 15 May 2026 13:08:03 +0200 Subject: [PATCH 2/4] Test UUIDv7 frontier restoration (#69) --- property-tests/UuidV7FactoryProperties.fs | 30 ++++ tests/UuidV7FactoryStateTests.cs | 185 ++++++++++++++++++++++ 2 files changed, 215 insertions(+) create mode 100644 tests/UuidV7FactoryStateTests.cs diff --git a/property-tests/UuidV7FactoryProperties.fs b/property-tests/UuidV7FactoryProperties.fs index 81ac8cc..6ef93db 100644 --- a/property-tests/UuidV7FactoryProperties.fs +++ b/property-tests/UuidV7FactoryProperties.fs @@ -237,6 +237,36 @@ 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. +[] +let ``Restored state is a monotonic lower bound`` (counter: uint16) (rollbackMs: uint16) = + let safeCounter = counter % UuidV7FactoryState.MaxCounter + 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() + uuid.GetTimestampMs().Value = restoredState.TimestampMs + && uuid.GetCounter().Value = safeCounter + 1us + +/// Property: restoring an older UUIDv7 factory state never lowers the current frontier. +[] +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 [] let ``UUIDs change with time`` (advanceMs: uint16) = diff --git a/tests/UuidV7FactoryStateTests.cs b/tests/UuidV7FactoryStateTests.cs new file mode 100644 index 0000000..72945ab --- /dev/null +++ b/tests/UuidV7FactoryStateTests.cs @@ -0,0 +1,185 @@ +using Xunit; + +namespace Clockworks.Tests; + +public sealed class UuidV7FactoryStateTests +{ + [Fact] + public void Constructor_ValidatesTimestampAndCounter() + { + Assert.Throws(() => new UuidV7FactoryState(-1, 0)); + Assert.Throws(() => new UuidV7FactoryState(UuidV7FactoryState.MaxTimestampMs + 1, 0)); + Assert.Throws(() => new UuidV7FactoryState(1, UuidV7FactoryState.MaxCounter + 1)); + } + + [Fact] + public void WriteToAndReadFrom_RoundTripState() + { + var state = new UuidV7FactoryState(1_700_000_000_000, 1234); + Span bytes = stackalloc byte[UuidV7FactoryState.EncodedLength]; + + state.WriteTo(bytes); + + Assert.Equal(state, UuidV7FactoryState.ReadFrom(bytes)); + Assert.Equal(state, UuidV7FactoryState.ReadFrom(state.ToBytes())); + } + + [Fact] + public void NewGuid_RestartsAboveState_WhenWallClockMovesBackwards() + { + const long startMs = 1_700_000_000_000; + var firstTime = SimulatedTimeProvider.FromUnixMs(startMs); + using var firstRng = new DeterministicRandomNumberGenerator(seed: 1); + using var first = new UuidV7Factory(firstTime, firstRng); + + var beforeRestart = first.NewGuid(); + var state = first.GetState(); + + var restartedTime = SimulatedTimeProvider.FromUnixMs(startMs - 10_000); + using var restartedRng = new DeterministicRandomNumberGenerator(seed: 2); + using var restarted = new UuidV7Factory(restartedTime, state, restartedRng); + + var afterRestart = restarted.NewGuid(); + + Assert.True(afterRestart > beforeRestart); + Assert.Equal(state.TimestampMs, afterRestart.GetTimestampMs()); + Assert.Equal((ushort)(state.Counter + 1), afterRestart.GetCounter()); + } + + [Fact] + public void Constructor_UsesPhysicalTime_WhenRestoredStateIsLowerThanPhysicalTime() + { + const long physicalMs = 1_700_000_000_000; + var restored = new UuidV7FactoryState(physicalMs - 1000, 4095); + var time = SimulatedTimeProvider.FromUnixMs(physicalMs); + using var rng = new DeterministicRandomNumberGenerator(seed: 1); + using var factory = new UuidV7Factory(time, restored, rng); + + var id = factory.NewGuid(); + + Assert.Equal(physicalMs, id.GetTimestampMs()); + Assert.True(id.GetCounter() <= UuidV7FactoryState.MaxCounter); + } + + [Fact] + public void Constructor_ContinuesFromRestoredCounter_WhenRestoredStateEqualsPhysicalTime() + { + const long physicalMs = 1_700_000_000_000; + var restored = new UuidV7FactoryState(physicalMs, 1000); + var time = SimulatedTimeProvider.FromUnixMs(physicalMs); + using var rng = new DeterministicRandomNumberGenerator(seed: 1); + using var factory = new UuidV7Factory(time, restored, rng); + + var id = factory.NewGuid(); + + Assert.Equal(physicalMs, id.GetTimestampMs()); + Assert.Equal((ushort?)1001, id.GetCounter()); + } + + [Fact] + public void Constructor_ContinuesFromRestoredTime_WhenRestoredStateIsGreaterThanPhysicalTime() + { + const long physicalMs = 1_700_000_000_000; + var restored = new UuidV7FactoryState(physicalMs + 250, 2000); + var time = SimulatedTimeProvider.FromUnixMs(physicalMs); + using var rng = new DeterministicRandomNumberGenerator(seed: 1); + using var factory = new UuidV7Factory(time, restored, rng); + + var id = factory.NewGuid(); + + Assert.Equal(restored.TimestampMs, id.GetTimestampMs()); + Assert.Equal((ushort?)2001, id.GetCounter()); + } + + [Fact] + public void RestoreState_DoesNotMoveFactoryBackwards() + { + const long startMs = 1_700_000_000_000; + var time = SimulatedTimeProvider.FromUnixMs(startMs); + using var rng = new DeterministicRandomNumberGenerator(seed: 1); + using var factory = new UuidV7Factory(time, rng); + + _ = factory.NewGuid(); + var advanced = factory.GetState(); + + factory.RestoreState(new UuidV7FactoryState(startMs - 100, 4095)); + var afterRestore = factory.GetState(); + + Assert.Equal(advanced, afterRestore); + } + + [Fact] + public void RestoreState_AdvancesFactoryToFutureFrontier() + { + const long startMs = 1_700_000_000_000; + var time = SimulatedTimeProvider.FromUnixMs(startMs); + using var rng = new DeterministicRandomNumberGenerator(seed: 1); + using var factory = new UuidV7Factory(time, rng); + var restored = new UuidV7FactoryState(startMs + 10, 100); + + factory.RestoreState(restored); + var id = factory.NewGuid(); + + Assert.Equal(restored.TimestampMs, id.GetTimestampMs()); + Assert.Equal((ushort?)101, id.GetCounter()); + } + + [Fact] + public void RestoreState_HandlesPackedTimestampSignBitBoundary() + { + const long lowerTimestampMs = (1L << 47) - 1; + const long higherTimestampMs = 1L << 47; + var time = SimulatedTimeProvider.FromUnixMs(lowerTimestampMs); + var initial = new UuidV7FactoryState(lowerTimestampMs, UuidV7FactoryState.MaxCounter); + var restored = new UuidV7FactoryState(higherTimestampMs, 100); + using var rng = new DeterministicRandomNumberGenerator(seed: 1); + using var factory = new UuidV7Factory( + time, + initial, + rng, + overflowBehavior: CounterOverflowBehavior.IncrementTimestamp); + + factory.RestoreState(restored); + var state = factory.GetState(); + var id = factory.NewGuid(); + + Assert.Equal(restored, state); + Assert.Equal(higherTimestampMs, id.GetTimestampMs()); + Assert.Equal((ushort?)101, id.GetCounter()); + } + + [Fact] + public void Constructor_PreservesNodePartition_WhenRestoredStateIsUsed() + { + const long startMs = 1_700_000_000_000; + var partition = new UuidV7NodePartition(nodeId: 42, nodeIdBitWidth: 10); + var restored = new UuidV7FactoryState(startMs + 10, 100); + var time = SimulatedTimeProvider.FromUnixMs(startMs); + using var rng = new DeterministicRandomNumberGenerator(seed: 1); + using var factory = new UuidV7Factory(time, partition, restored, rng); + + var id = factory.NewGuid(); + + Assert.Equal(restored.TimestampMs, id.GetTimestampMs()); + Assert.Equal((ushort?)101, id.GetCounter()); + Assert.Equal((ushort?)42, id.GetNodePartitionId(10)); + } + + [Fact] + public void Constructor_IncrementTimestampHandlesRestoredMaxCounterAheadOfPhysicalTime() + { + const long startMs = 1_700_000_000_000; + var restored = new UuidV7FactoryState(startMs + 10, UuidV7FactoryState.MaxCounter); + var time = SimulatedTimeProvider.FromUnixMs(startMs); + using var rng = new DeterministicRandomNumberGenerator(seed: 1); + using var factory = new UuidV7Factory( + time, + restored, + rng, + overflowBehavior: CounterOverflowBehavior.IncrementTimestamp); + + var id = factory.NewGuid(); + + Assert.Equal(restored.TimestampMs + 1, id.GetTimestampMs()); + } +} From 2de1eb3ee1af742bd52f539978e4b517c9a4e832 Mon Sep 17 00:00:00 2001 From: Dexter Ajoku Date: Fri, 15 May 2026 13:08:27 +0200 Subject: [PATCH 3/4] Document UUIDv7 restart state (#69) --- CHANGELOG.md | 2 + README.md | 3 +- docs/.vitepress/config.ts | 1 + docs/changelog.md | 2 + docs/concepts/uuidv7-restart-state.md | 57 +++++++++++++++++++++++++++ docs/guide/uuidv7.md | 43 +++++++++++++++++++- 6 files changed, 106 insertions(+), 2 deletions(-) create mode 100644 docs/concepts/uuidv7-restart-state.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 7da7765..d14e0ad 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/README.md b/README.md index e395ca7..9165a90 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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. diff --git a/docs/.vitepress/config.ts b/docs/.vitepress/config.ts index 4b54332..b67175e 100644 --- a/docs/.vitepress/config.ts +++ b/docs/.vitepress/config.ts @@ -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' }, ], diff --git a/docs/changelog.md b/docs/changelog.md index e8f7ffd..a4fe7dd 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -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. diff --git a/docs/concepts/uuidv7-restart-state.md b/docs/concepts/uuidv7-restart-state.md new file mode 100644 index 0000000..5e68e42 --- /dev/null +++ b/docs/concepts/uuidv7-restart-state.md @@ -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. diff --git a/docs/guide/uuidv7.md b/docs/guide/uuidv7.md index e6d6b8b..df231a8 100644 --- a/docs/guide/uuidv7.md +++ b/docs/guide/uuidv7.md @@ -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. @@ -91,6 +92,46 @@ 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 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); +``` + +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: From 6a9b030a0d049220406158ae0c8fbc85b055a430 Mon Sep 17 00:00:00 2001 From: Dexter Ajoku Date: Fri, 15 May 2026 17:58:35 +0200 Subject: [PATCH 4/4] Address UUIDv7 restart state review notes (#69) --- docs/guide/uuidv7.md | 2 ++ property-tests/UuidV7FactoryProperties.fs | 9 ++++++--- src/UuidV7Factory.cs | 1 + src/UuidV7FactoryState.cs | 1 + 4 files changed, 10 insertions(+), 3 deletions(-) diff --git a/docs/guide/uuidv7.md b/docs/guide/uuidv7.md index df231a8..c205003 100644 --- a/docs/guide/uuidv7.md +++ b/docs/guide/uuidv7.md @@ -115,6 +115,8 @@ 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 diff --git a/property-tests/UuidV7FactoryProperties.fs b/property-tests/UuidV7FactoryProperties.fs index 6ef93db..4343f9e 100644 --- a/property-tests/UuidV7FactoryProperties.fs +++ b/property-tests/UuidV7FactoryProperties.fs @@ -240,7 +240,7 @@ let ``Node partitions separate identical deterministic UUID streams`` (rawWidth: /// Property: restored UUIDv7 factory state is a monotonic lower bound for future generation. [] let ``Restored state is a monotonic lower bound`` (counter: uint16) (rollbackMs: uint16) = - let safeCounter = counter % UuidV7FactoryState.MaxCounter + let safeCounter = counter % (UuidV7FactoryState.MaxCounter + 1us) let safeRollbackMs = int64 (rollbackMs % 1000us) + 1L let stateMs = 1_700_000_000_000L let restoredState = UuidV7FactoryState(stateMs, safeCounter) @@ -248,8 +248,11 @@ let ``Restored state is a monotonic lower bound`` (counter: uint16) (rollbackMs: use factory = new UuidV7Factory(timeProvider, restoredState) let uuid = factory.NewGuid() - uuid.GetTimestampMs().Value = restoredState.TimestampMs - && uuid.GetCounter().Value = safeCounter + 1us + 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. [] diff --git a/src/UuidV7Factory.cs b/src/UuidV7Factory.cs index 28e0840..8783129 100644 --- a/src/UuidV7Factory.cs +++ b/src/UuidV7Factory.cs @@ -218,6 +218,7 @@ private UuidV7Factory( /// /// Captures the current logical frontier for checkpointing and later restoration. /// + /// The current UUIDv7 factory frontier as a restart checkpoint. public UuidV7FactoryState GetState() { var (timestampMs, counter) = UnpackState(Volatile.Read(ref _packedState)); diff --git a/src/UuidV7FactoryState.cs b/src/UuidV7FactoryState.cs index 4781469..09575bf 100644 --- a/src/UuidV7FactoryState.cs +++ b/src/UuidV7FactoryState.cs @@ -83,6 +83,7 @@ public void WriteTo(Span destination) if (destination.Length < EncodedLength) throw new ArgumentException($"Destination must be at least {EncodedLength} bytes.", nameof(destination)); + // UUIDv7 uses a 48-bit timestamp; write the six bytes directly to avoid an 8-byte temporary. destination[0] = (byte)(TimestampMs >> 40); destination[1] = (byte)(TimestampMs >> 32); destination[2] = (byte)(TimestampMs >> 24);