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
21 changes: 15 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ A high-performance, fully compliant .NET implementation of ULIDs (Universally Un

[<img align="right" width="100px" src="assets/logo_ulid.png" />](https://www.nuget.org/packages/ByteAether.Ulid/)

ULIDs (Universally Unique Lexicographically Sortable Identifiers) offer a modern, human-readable alternative to traditional GUIDs, optimized specifically for distributed systems and time-ordered data. **ByteAether.Ulid** delivers a high-performance, specification-compliant .NET implementation engineered to resolve critical concurrency and persistence edge cases left unaddressed by alternative libraries.
ULIDs (Universally Unique Lexicographically Sortable Identifiers) offer a modern, human-readable alternative to traditional GUIDs, optimized specifically for distributed systems and time-ordered data. **ByteAether.Ulid** delivers a high-performance, specification-compliant .NET implementation engineered to resolve critical concurrency and persistence edge cases unaddressed by alternative libraries.

### Resilient Concurrency & Monotonic Overflow Handling

Expand Down Expand Up @@ -150,7 +150,11 @@ You can customize ULID generation by providing `GenerationOptions`. This allows

#### Example: Monotonic ULID with Random Increments

To generate ULIDs that are monotonically increasing with a random increment, you can specify the `Monotonicity` option.
The monotonicity state (last generated timestamp and 80-bit random payload) is bound directly to the lifecycle of the `GenerationOptions` instance.

* **Instance Reuse (Recommended for Sequences):** Reusing a single `GenerationOptions` instance across calls guarantees strict, cross-thread monotonic ordering via lock-free atomic compare-and-exchange (CAS) operations.
* **Instance Isolation:** Passing a new `GenerationOptions` instance on each call isolates state, disabling monotonic sequence tracking between calls and eliminating CAS contention.

```csharp
using System;
using ByteAether.Ulid;
Expand Down Expand Up @@ -241,13 +245,13 @@ The `Ulid` implementation provides the following properties and methods:
### Properties

- `Ulid.MinValue`\
Represents an empty ULID, equivalent to `default(Ulid)` and `Ulid.New(new byte[16])`.
Represents an empty ULID, equivalent to `default(Ulid)` or `Ulid.New(new byte[16])`.
- `Ulid.MaxValue`\
Represents the maximum possible value for a ULID (all bytes set to `0xFF`).
- `Ulid.Empty`\
Alias for `Ulid.MinValue`.
- `Ulid.DefaultGenerationOptions`\
Default configuration for ULID generation when no options are provided by the `Ulid.New(...)` call.
Gets or sets the global default `GenerationOptions` configuration for ULID generation when no options are provided by the `Ulid.New(...)` call.
- `.Time`\
Gets the timestamp component of the ULID as a `DateTimeOffset`.
- `.TimeBytes`\
Expand Down Expand Up @@ -278,10 +282,15 @@ The `Ulid` implementation provides the following properties and methods:

### GenerationOptions

The `GenerationOptions` class provides detailed configuration for ULID generation, with the following key properties:
The `GenerationOptions` class encapsulates generation strategy, state retention, and lock-free thread synchronization for monotonic ULID generation.

Configurable properties:

- `Monotonicity`\
Controls the behavior of ULID generation when multiple identifiers are created within the same millisecond. It determines whether ULIDs are strictly increasing or allow for random ordering within that millisecond. Available options include: `NonMonotonic`, `MonotonicIncrement` (default), `MonotonicRandom1Byte`, `MonotonicRandom2Byte`, `MonotonicRandom3Byte`, `MonotonicRandom4Byte`.
Defines the monotonic strategy when generating multiple ULIDs within the same millisecond. Each instance maintains an atomic state machine using lock-free Compare-And-Swap (CAS) primitives to guarantee strict sequential ordering without mutex locking. Options include:
- `NonMonotonic`: Generates fully random 80-bit payloads without state tracking.
- `MonotonicIncrement` (Default): Increments the least significant bit of the random payload upon sub-millisecond collisions.
- `MonotonicRandom1Byte`, `MonotonicRandom2Byte`, `MonotonicRandom3Byte`, `MonotonicRandom4Byte`: Adds a random integer increment within the specified byte range to the payload, strengthening entropy against enumeration attacks while maintaining monotonicity.

- `InitialRandomSource`\
An `IRandomProvider` for generating the random bytes of a ULID. The default `CryptographicallySecureRandomProvider` ensures robust, unpredictable ULIDs using a cryptographically secure generator.
Expand Down
281 changes: 127 additions & 154 deletions src/Ulid.Tests/Ulid.New.Tests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,18 +2,6 @@

public class UlidNewTests
{
// A lock object to synchronize tests that rely on shared static state in the Ulid class,
// preventing race conditions and ensuring test isolation.

// ReSharper disable once ChangeFieldTypeToSystemThreadingLock for older .net compatibility
private static readonly object _staticStateLock = new();

// We need safe DateTimeOffset values for monotonicity
private static readonly DateTimeOffset _lastTimestamp = DateTimeOffset.UtcNow.AddMinutes(1);
private static int _timestampOffsetCounter;

private static DateTimeOffset GetDateTimeOffset() => _lastTimestamp.AddSeconds(++_timestampOffsetCounter);

/// <summary>
/// A controllable random provider for testing purposes. It returns pre-configured byte sequences.
/// </summary>
Expand Down Expand Up @@ -93,67 +81,61 @@ public void New_WithUnixTimestampMillisecondsAndRandom_ShouldGenerateCorrectTime
[Fact]
public void New_NonMonotonic_CanProduceSmallerUlids()
{
lock (_staticStateLock)
// Arrange
var timestamp = DateTimeOffset.UtcNow.AddDays(1).ToUnixTimeMilliseconds();
var random1 = new byte[] { 2, 2, 2, 2, 2, 2, 2, 2, 2, 2 };
var random2 = new byte[] { 1, 1, 1, 1, 1, 1, 1, 1, 1, 1 };

var initialRandomProvider = new ControllableRandomProvider(random1, random2);
var options = new Ulid.GenerationOptions
{
// Arrange
var timestamp = GetDateTimeOffset().ToUnixTimeMilliseconds();
var random1 = new byte[] { 2, 2, 2, 2, 2, 2, 2, 2, 2, 2 };
var random2 = new byte[] { 1, 1, 1, 1, 1, 1, 1, 1, 1, 1 };

var initialRandomProvider = new ControllableRandomProvider(random1, random2);
var options = new Ulid.GenerationOptions
{
Monotonicity = Ulid.GenerationOptions.MonotonicityOptions.NonMonotonic,
InitialRandomSource = initialRandomProvider
};

// Act
var ulid1 = Ulid.New(timestamp, options);
var ulid2 = Ulid.New(timestamp, options);

// Assert
Assert.Equal(timestamp, ulid1.Time.ToUnixTimeMilliseconds());
Assert.Equal(random1, ulid1.Random.ToArray());

Assert.Equal(timestamp, ulid2.Time.ToUnixTimeMilliseconds());
Assert.Equal(random2, ulid2.Random.ToArray());
}
Monotonicity = Ulid.GenerationOptions.MonotonicityOptions.NonMonotonic,
InitialRandomSource = initialRandomProvider
};

// Act
var ulid1 = Ulid.New(timestamp, options);
var ulid2 = Ulid.New(timestamp, options);

// Assert
Assert.Equal(timestamp, ulid1.Time.ToUnixTimeMilliseconds());
Assert.Equal(random1, ulid1.Random.ToArray());

Assert.Equal(timestamp, ulid2.Time.ToUnixTimeMilliseconds());
Assert.Equal(random2, ulid2.Random.ToArray());
}

[Fact]
public void New_MonotonicIncrement_ShouldOverflowToTimestamp()
{
lock (_staticStateLock)
// Arrange
var timestamp = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
var initialRandom = new byte[] { 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFD };

var initialRandomProvider = new ControllableRandomProvider(initialRandom);

var options = new Ulid.GenerationOptions
{
// Arrange
var timestamp = GetDateTimeOffset().ToUnixTimeMilliseconds();
var initialRandom = new byte[] { 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFD };

var initialRandomProvider = new ControllableRandomProvider(initialRandom);

var options = new Ulid.GenerationOptions
{
Monotonicity = Ulid.GenerationOptions.MonotonicityOptions.MonotonicIncrement,
InitialRandomSource = initialRandomProvider
};

// Act
var ulid1 = Ulid.New(timestamp, options); // Random ...FD
var ulid2 = Ulid.New(timestamp, options); // Random ...FE (incremented)
var ulid3 = Ulid.New(timestamp, options); // Random ...FF (incremented)
var ulid4 = Ulid.New(timestamp, options); // Overflow

// Assert
Assert.Equal(timestamp, ulid1.Time.ToUnixTimeMilliseconds());
Assert.Equal(timestamp, ulid2.Time.ToUnixTimeMilliseconds());
Assert.Equal(timestamp, ulid3.Time.ToUnixTimeMilliseconds());
Assert.Equal(timestamp + 1, ulid4.Time.ToUnixTimeMilliseconds());

Assert.Equal(new byte[] { 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFD }, ulid1.Random.ToArray());
Assert.Equal(new byte[] { 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFE }, ulid2.Random.ToArray());
Assert.Equal(new byte[] { 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF }, ulid3.Random.ToArray());
Assert.Equal(new byte[10], ulid4.Random.ToArray());
}
Monotonicity = Ulid.GenerationOptions.MonotonicityOptions.MonotonicIncrement,
InitialRandomSource = initialRandomProvider
};

// Act
var ulid1 = Ulid.New(timestamp, options); // Random ...FD
var ulid2 = Ulid.New(timestamp, options); // Random ...FE (incremented)
var ulid3 = Ulid.New(timestamp, options); // Random ...FF (incremented)
var ulid4 = Ulid.New(timestamp, options); // Overflow

// Assert
Assert.Equal(timestamp, ulid1.Time.ToUnixTimeMilliseconds());
Assert.Equal(timestamp, ulid2.Time.ToUnixTimeMilliseconds());
Assert.Equal(timestamp, ulid3.Time.ToUnixTimeMilliseconds());
Assert.Equal(timestamp + 1, ulid4.Time.ToUnixTimeMilliseconds());

Assert.Equal(new byte[] { 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFD }, ulid1.Random.ToArray());
Assert.Equal(new byte[] { 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFE }, ulid2.Random.ToArray());
Assert.Equal(new byte[] { 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF }, ulid3.Random.ToArray());
Assert.Equal(new byte[10], ulid4.Random.ToArray());
}

[Theory]
Expand All @@ -166,41 +148,38 @@ public void New_MonotonicRandom_ShouldIncrementCorrectly(
int incrementSize
)
{
lock (_staticStateLock)
{
// Arrange
var timestamp = GetDateTimeOffset().ToUnixTimeMilliseconds();
// Arrange
var timestamp = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();

var initialRandom = new byte[10];
initialRandom[9] = 0xA; // 10
var initialRandom = new byte[10];
initialRandom[9] = 0xA; // 10

var increment = new byte[incrementSize];
increment[0] = 4; // 10 + 4
var increment = new byte[incrementSize];
increment[0] = 4; // 10 + 4

var incrementedRandom = new byte[10];
incrementedRandom[9] = 15; // 10 + 4 + 1 : +1 comes from base implementation of Ulid
var incrementedRandom = new byte[10];
incrementedRandom[9] = 15; // 10 + 4 + 1 : +1 comes from base implementation of Ulid

var initialRandomProvider = new ControllableRandomProvider(initialRandom);
var incrementRandomProvider = new ControllableRandomProvider(increment);
var initialRandomProvider = new ControllableRandomProvider(initialRandom);
var incrementRandomProvider = new ControllableRandomProvider(increment);

var options = new Ulid.GenerationOptions
{
Monotonicity = monotonicity,
InitialRandomSource = initialRandomProvider,
IncrementRandomSource = incrementRandomProvider
};
var options = new Ulid.GenerationOptions
{
Monotonicity = monotonicity,
InitialRandomSource = initialRandomProvider,
IncrementRandomSource = incrementRandomProvider
};

// Act
var ulid1 = Ulid.New(timestamp, options);
var ulid2 = Ulid.New(timestamp, options);
// Act
var ulid1 = Ulid.New(timestamp, options);
var ulid2 = Ulid.New(timestamp, options);

// Assert
Assert.Equal(timestamp, ulid1.Time.ToUnixTimeMilliseconds());
Assert.Equal(timestamp, ulid2.Time.ToUnixTimeMilliseconds());
// Assert
Assert.Equal(timestamp, ulid1.Time.ToUnixTimeMilliseconds());
Assert.Equal(timestamp, ulid2.Time.ToUnixTimeMilliseconds());

Assert.Equal(initialRandom, ulid1.Random.ToArray());
Assert.Equal(incrementedRandom, ulid2.Random.ToArray());
}
Assert.Equal(initialRandom, ulid1.Random.ToArray());
Assert.Equal(incrementedRandom, ulid2.Random.ToArray());
}

[Theory]
Expand All @@ -212,42 +191,39 @@ public void New_MonotonicRandom_ShouldCarryOverIncrement(
Ulid.GenerationOptions.MonotonicityOptions monotonicity, int incrementSize
)
{
lock (_staticStateLock)
{
// Arrange
var timestamp = GetDateTimeOffset().ToUnixTimeMilliseconds();
// Arrange
var timestamp = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();

var initialRandom = new byte[10];
initialRandom[9] = 0xFE; // Max - 2
var initialRandom = new byte[10];
initialRandom[9] = 0xFE; // Max - 2

var increment = new byte[incrementSize];
increment[0] = 0x01; // 1 as the other +1 comes from base implementation
var increment = new byte[incrementSize];
increment[0] = 0x01; // 1 as the other +1 comes from base implementation

var initialRandomProvider = new ControllableRandomProvider(initialRandom);
var incrementRandomProvider = new ControllableRandomProvider(increment);
var initialRandomProvider = new ControllableRandomProvider(initialRandom);
var incrementRandomProvider = new ControllableRandomProvider(increment);

var options = new Ulid.GenerationOptions
{
Monotonicity = monotonicity,
InitialRandomSource = initialRandomProvider,
IncrementRandomSource = incrementRandomProvider
};
var options = new Ulid.GenerationOptions
{
Monotonicity = monotonicity,
InitialRandomSource = initialRandomProvider,
IncrementRandomSource = incrementRandomProvider
};

// Act
var ulid1 = Ulid.New(timestamp, options);
var ulid2 = Ulid.New(timestamp, options);
// Act
var ulid1 = Ulid.New(timestamp, options);
var ulid2 = Ulid.New(timestamp, options);

// Assert
Assert.Equal(timestamp, ulid1.Time.ToUnixTimeMilliseconds());
Assert.Equal(timestamp, ulid2.Time.ToUnixTimeMilliseconds());
Assert.Equal(initialRandom, ulid1.Random.ToArray());
// Assert
Assert.Equal(timestamp, ulid1.Time.ToUnixTimeMilliseconds());
Assert.Equal(timestamp, ulid2.Time.ToUnixTimeMilliseconds());
Assert.Equal(initialRandom, ulid1.Random.ToArray());

var expectedRandom = new byte[10];
expectedRandom[8] = 0x01; // ...0100
expectedRandom[9] = 0x00;
var expectedRandom = new byte[10];
expectedRandom[8] = 0x01; // ...0100
expectedRandom[9] = 0x00;

Assert.Equal(expectedRandom, ulid2.Random.ToArray());
}
Assert.Equal(expectedRandom, ulid2.Random.ToArray());
}


Expand All @@ -260,40 +236,37 @@ public void New_MonotonicRandom_ShouldOverflowToTimestamp(
Ulid.GenerationOptions.MonotonicityOptions monotonicity, int incrementSize
)
{
lock (_staticStateLock)
// Arrange
var timestamp = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
var initialRandom = new byte[] { 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF };
//Set all the last bytes to 0x00 that should be incremented later
for(var i = initialRandom.Length - incrementSize; i < initialRandom.Length; i++)
{
// Arrange
var timestamp = GetDateTimeOffset().ToUnixTimeMilliseconds();
var initialRandom = new byte[] { 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF };
//Set all the last bytes to 0x00 that should be incremented later
for(var i = initialRandom.Length - incrementSize; i < initialRandom.Length; i++)
{
initialRandom[i] = 0x00;
}

var increment = Enumerable.Repeat<byte>(0xFF, incrementSize).ToArray();
// overflow +1 comes from the base implementation of Ulid

var initialRandomProvider = new ControllableRandomProvider(initialRandom);
var incrementRandomProvider = new ControllableRandomProvider(increment);

var options = new Ulid.GenerationOptions
{
Monotonicity = monotonicity,
InitialRandomSource = initialRandomProvider,
IncrementRandomSource = incrementRandomProvider
};

// Act
var ulid1 = Ulid.New(timestamp, options); // Random is all 0xFF
var ulid2 = Ulid.New(timestamp, options); // This should overflow

// Assert
Assert.Equal(timestamp, ulid1.Time.ToUnixTimeMilliseconds());
Assert.Equal(initialRandom, ulid1.Random.ToArray());

Assert.Equal(timestamp + 1, ulid2.Time.ToUnixTimeMilliseconds());
Assert.Equal(new byte[10], ulid2.Random.ToArray());
initialRandom[i] = 0x00;
}

var increment = Enumerable.Repeat<byte>(0xFF, incrementSize).ToArray();
// overflow +1 comes from the base implementation of Ulid

var initialRandomProvider = new ControllableRandomProvider(initialRandom);
var incrementRandomProvider = new ControllableRandomProvider(increment);

var options = new Ulid.GenerationOptions
{
Monotonicity = monotonicity,
InitialRandomSource = initialRandomProvider,
IncrementRandomSource = incrementRandomProvider
};

// Act
var ulid1 = Ulid.New(timestamp, options); // Random is all 0xFF
var ulid2 = Ulid.New(timestamp, options); // This should overflow

// Assert
Assert.Equal(timestamp, ulid1.Time.ToUnixTimeMilliseconds());
Assert.Equal(initialRandom, ulid1.Random.ToArray());

Assert.Equal(timestamp + 1, ulid2.Time.ToUnixTimeMilliseconds());
Assert.Equal(new byte[10], ulid2.Random.ToArray());
}
}
Loading