From e47ebee033d8b38fbd3e365039d54e89aae8c18a Mon Sep 17 00:00:00 2001 From: Dexter Ajoku Date: Fri, 15 May 2026 18:21:21 +0200 Subject: [PATCH 1/2] Apply UUIDv7 overflow behavior under logical drift (#80) --- property-tests/UuidV7FactoryProperties.fs | 6 +++- src/UuidV7Factory.cs | 28 +++++++++++++--- tests/UuidV7FactoryTests.cs | 41 +++++++++++++++++++++++ 3 files changed, 70 insertions(+), 5 deletions(-) diff --git a/property-tests/UuidV7FactoryProperties.fs b/property-tests/UuidV7FactoryProperties.fs index 4343f9e..fce1a4f 100644 --- a/property-tests/UuidV7FactoryProperties.fs +++ b/property-tests/UuidV7FactoryProperties.fs @@ -245,7 +245,11 @@ let ``Restored state is a monotonic lower bound`` (counter: uint16) (rollbackMs: let stateMs = 1_700_000_000_000L let restoredState = UuidV7FactoryState(stateMs, safeCounter) let timeProvider = SimulatedTimeProvider.FromUnixMs(stateMs - safeRollbackMs) - use factory = new UuidV7Factory(timeProvider, restoredState) + use factory = + new UuidV7Factory( + timeProvider, + restoredState, + overflowBehavior = CounterOverflowBehavior.IncrementTimestamp) let uuid = factory.NewGuid() let timestamp = uuid.GetTimestampMs().Value diff --git a/src/UuidV7Factory.cs b/src/UuidV7Factory.cs index 8783129..e2cd9c1 100644 --- a/src/UuidV7Factory.cs +++ b/src/UuidV7Factory.cs @@ -416,8 +416,25 @@ private void FillGuids(Span destination) if (currentCounter >= MaxCounterValue) { _statistics?.RecordCounterOverflow(); - newTimestamp = currentTimestamp + 1; - newCounter = GetRandomCounterStart(); + switch (_effectiveOverflowBehavior) + { + case CounterOverflowBehavior.SpinWait: + _statistics?.RecordSpinWait(); + SpinWaitForNextMillisecond(currentTimestamp); + continue; + + case CounterOverflowBehavior.IncrementTimestamp: + newTimestamp = currentTimestamp + 1; + newCounter = GetRandomCounterStart(); + break; + + case CounterOverflowBehavior.ThrowException: + throw new InvalidOperationException( + $"Counter overflow: generated {MaxCounterValue + 1} UUIDs within millisecond {currentTimestamp}"); + + default: + throw new UnreachableException(); + } } else { @@ -584,12 +601,15 @@ private void Refill() } /// -/// Behavior when the UUIDv7 counter overflows (> 4095 UUIDs in the same millisecond). +/// Behavior when the UUIDv7 counter overflows (> 4095 UUIDs in the same logical millisecond). /// +/// +/// The logical millisecond can be ahead of physical time after wall-clock rollback or restart-state restoration. +/// public enum CounterOverflowBehavior { /// - /// Spin-wait until the next millisecond. + /// Spin-wait until physical time passes the exhausted logical millisecond. /// Maintains strict time accuracy but may block. /// Best for: Testing, simulation, low-throughput production. /// diff --git a/tests/UuidV7FactoryTests.cs b/tests/UuidV7FactoryTests.cs index 432069d..eec22b7 100644 --- a/tests/UuidV7FactoryTests.cs +++ b/tests/UuidV7FactoryTests.cs @@ -173,6 +173,47 @@ public void OverflowBehavior_IncrementTimestamp_StaysMonotonic_EvenWhenTimeDoesN } } + [Fact] + public void OverflowBehavior_ThrowException_ThrowsWhenLogicalTimeIsAheadAndCounterOverflows() + { + const long startMs = 1_700_000_000_000; + var time = SimulatedTimeProvider.FromUnixMs(startMs - 10); + var restoredState = new UuidV7FactoryState(startMs, UuidV7FactoryState.MaxCounter); + using var rng = new DeterministicRandomNumberGenerator(seed: 1); + using var factory = new UuidV7Factory( + time, + restoredState, + rng, + overflowBehavior: CounterOverflowBehavior.ThrowException); + + Assert.Throws(() => factory.NewGuid()); + } + + [Fact] + public async Task OverflowBehavior_SpinWait_WaitsUntilPhysicalTimePassesLogicalFrontier() + { + const long startMs = 1_700_000_000_000; + var time = SimulatedTimeProvider.FromUnixMs(startMs - 10); + var restoredState = new UuidV7FactoryState(startMs, UuidV7FactoryState.MaxCounter); + using var rng = new DeterministicRandomNumberGenerator(seed: 1); + using var factory = new UuidV7Factory( + time, + restoredState, + rng, + overflowBehavior: CounterOverflowBehavior.SpinWait); + + var pending = Task.Run(factory.NewGuid); + var earlyWinner = await Task.WhenAny(pending, Task.Delay(TimeSpan.FromMilliseconds(25))); + Assert.NotSame(pending, earlyWinner); + + time.SetUnixMs(startMs + 1); + + var winner = await Task.WhenAny(pending, Task.Delay(TimeSpan.FromSeconds(5))); + Assert.Same(pending, winner); + var id = await pending; + Assert.Equal(startMs + 1, id.GetTimestampMs()); + } + [Fact] public void OverflowBehavior_Auto_UsesIncrementTimestamp_ForSimulatedTimeProvider() { From 6b7d99b8e7df5b8f06e6d6ebad87eecbd5997ddb Mon Sep 17 00:00:00 2001 From: Dexter Ajoku Date: Fri, 15 May 2026 18:21:43 +0200 Subject: [PATCH 2/2] Document UUIDv7 drift overflow semantics (#80) --- CHANGELOG.md | 1 + docs/changelog.md | 1 + docs/guide/uuidv7.md | 6 ++++-- 3 files changed, 6 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d14e0ad..4421af5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ and this project aims to follow [Semantic Versioning](https://semver.org/spec/v2 - Add opt-in `UuidV7FactoryState` restart frontier snapshots for services that persist and restore UUIDv7 logical cursors. ### Changed +- `UuidV7Factory` now applies `CounterOverflowBehavior` consistently when logical time is ahead of physical time and the 12-bit counter is exhausted. - `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 diff --git a/docs/changelog.md b/docs/changelog.md index a4fe7dd..f7533f7 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -16,6 +16,7 @@ This page mirrors the repository root `CHANGELOG.md`. - Add opt-in `UuidV7FactoryState` restart frontier snapshots for services that persist and restore UUIDv7 logical cursors. ### Changed +- `UuidV7Factory` now applies `CounterOverflowBehavior` consistently when logical time is ahead of physical time and the 12-bit counter is exhausted. - `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 diff --git a/docs/guide/uuidv7.md b/docs/guide/uuidv7.md index c205003..e36de61 100644 --- a/docs/guide/uuidv7.md +++ b/docs/guide/uuidv7.md @@ -130,7 +130,7 @@ 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`. +- If you restore a max-counter state while physical time is behind the restored logical timestamp, `SpinWait` waits until physical time passes that logical timestamp. For deterministic simulations and large restored drift windows, prefer `CounterOverflowBehavior.Auto` or `IncrementTimestamp`. See [UUIDv7 Restart State](/concepts/uuidv7-restart-state) for the design notes and failure modes. @@ -203,11 +203,13 @@ var factory = new UuidV7Factory( | Behavior | Description | |---|---| -| `SpinWait` | Busy-waits until the next millisecond (default) | +| `SpinWait` | Busy-waits until physical time passes the exhausted logical millisecond (default) | | `IncrementTimestamp` | Artificially increments the timestamp to maintain throughput (timestamp may drift ahead) | | `ThrowException` | Throws if more than 4096 UUIDs are allocated in a single millisecond | | `Auto` | Chooses `IncrementTimestamp` for `SimulatedTimeProvider` (avoids deadlocks), otherwise `SpinWait` | +The same policy applies when logical time is ahead of physical time because of clock rollback or restored restart state. For example, if a restored frontier has `counter = 4095` and physical time is still behind that frontier, `SpinWait` waits for physical catch-up, `IncrementTimestamp` advances logical time, and `ThrowException` fails immediately. + ::: warning Simulated time + overflow If you use `SimulatedTimeProvider` and generate more than 4096 UUIDs without advancing time, `SpinWait` can deadlock (simulated time won't move forward on its own). For simulations, prefer `Auto` or `IncrementTimestamp`. :::