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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions docs/changelog.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 4 additions & 2 deletions docs/guide/uuidv7.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down Expand Up @@ -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`.
:::
Expand Down
6 changes: 5 additions & 1 deletion property-tests/UuidV7FactoryProperties.fs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
28 changes: 24 additions & 4 deletions src/UuidV7Factory.cs
Original file line number Diff line number Diff line change
Expand Up @@ -416,8 +416,25 @@ private void FillGuids(Span<Guid> 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
{
Expand Down Expand Up @@ -584,12 +601,15 @@ private void Refill()
}

/// <summary>
/// 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).
/// </summary>
/// <remarks>
/// The logical millisecond can be ahead of physical time after wall-clock rollback or restart-state restoration.
/// </remarks>
public enum CounterOverflowBehavior
{
/// <summary>
/// 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.
/// </summary>
Expand Down
41 changes: 41 additions & 0 deletions tests/UuidV7FactoryTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<InvalidOperationException>(() => 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()
{
Expand Down
Loading