From c6217274d7d158eac58357f1054cf81d7a1aa402 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joonatan=20Uusv=C3=A4li?= Date: Sat, 10 Jan 2026 14:56:50 +0200 Subject: [PATCH 1/3] Refactored ULID string formatting logic to remove redundant checks and improve overall performance. Added additional test for Unix timestamp and random input validation. --- src/ByteAether.Ulid.Tests/Ulid.New.Tests.cs | 15 +++++++ src/ByteAether.Ulid/Ulid.New.cs | 48 ++++++++++----------- src/ByteAether.Ulid/Ulid.String.cs | 39 ++++++++--------- 3 files changed, 56 insertions(+), 46 deletions(-) diff --git a/src/ByteAether.Ulid.Tests/Ulid.New.Tests.cs b/src/ByteAether.Ulid.Tests/Ulid.New.Tests.cs index e4f8f05..96c8417 100644 --- a/src/ByteAether.Ulid.Tests/Ulid.New.Tests.cs +++ b/src/ByteAether.Ulid.Tests/Ulid.New.Tests.cs @@ -75,6 +75,21 @@ public void New_WithTimestampAndRandom_ShouldGenerateCorrectTimestampAndRandom() Assert.Equal(random, ulid.Random.ToArray()); } + [Fact] + public void New_WithUnixTimestampMillisecondsAndRandom_ShouldGenerateCorrectTimestampAndRandom() + { + // Arrange + var timestamp = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); + var random = new byte[] { 1, 2, 3, 4, 5, 6, 7, 8, 9, 10 }; + + // Act + var ulid = Ulid.New(timestamp, random); + + // Assert + Assert.Equal(timestamp, ulid.Time.ToUnixTimeMilliseconds()); + Assert.Equal(random, ulid.Random.ToArray()); + } + [Fact] public void New_NonMonotonic_CanProduceSmallerUlids() { diff --git a/src/ByteAether.Ulid/Ulid.New.cs b/src/ByteAether.Ulid/Ulid.New.cs index 3daad1d..88d6ca4 100644 --- a/src/ByteAether.Ulid/Ulid.New.cs +++ b/src/ByteAether.Ulid/Ulid.New.cs @@ -174,6 +174,30 @@ ref random.GetPinnableReference(), private static int _lastUlidLock; +#if NET5_0_OR_GREATER + [SkipLocalsInit] +#endif +#if NETCOREAPP3_0_OR_GREATER + [MethodImpl(MethodImplOptions.AggressiveInlining | MethodImplOptions.AggressiveOptimization)] +#else + [MethodImpl(MethodImplOptions.AggressiveInlining)] +#endif + private static void AcquireSpinLock() + { + // Hot-path + if (Interlocked.CompareExchange(ref _lastUlidLock, 1, 0) == 0) + { + return; + } + + // Spin until the lock is acquired + var spinner = new SpinWait(); + while (Interlocked.CompareExchange(ref _lastUlidLock, 1, 0) == 1) + { + spinner.SpinOnce(); + } + } + #if NET5_0_OR_GREATER [SkipLocalsInit] #endif @@ -348,28 +372,4 @@ private static void IncrementByByteSpan(ref byte targetRef, ReadOnlySpan s throw new OverflowException("Addition resulted in a value larger than the target span's capacity."); } - -#if NET5_0_OR_GREATER - [SkipLocalsInit] -#endif -#if NETCOREAPP3_0_OR_GREATER - [MethodImpl(MethodImplOptions.AggressiveInlining | MethodImplOptions.AggressiveOptimization)] -#else - [MethodImpl(MethodImplOptions.AggressiveInlining)] -#endif - private static void AcquireSpinLock() - { - // Hot-path - if (Interlocked.CompareExchange(ref _lastUlidLock, 1, 0) == 0) - { - return; - } - - // Spin until the lock is acquired - var spinner = new SpinWait(); - while (Interlocked.CompareExchange(ref _lastUlidLock, 1, 0) == 1) - { - spinner.SpinOnce(); - } - } } \ No newline at end of file diff --git a/src/ByteAether.Ulid/Ulid.String.cs b/src/ByteAether.Ulid/Ulid.String.cs index a9d49c9..098aa97 100644 --- a/src/ByteAether.Ulid/Ulid.String.cs +++ b/src/ByteAether.Ulid/Ulid.String.cs @@ -79,7 +79,7 @@ public readonly partial struct Ulid #else [MethodImpl(MethodImplOptions.AggressiveInlining)] #endif - public readonly string ToString(string? format, IFormatProvider? formatProvider) => ToString(); + public string ToString(string? format, IFormatProvider? formatProvider) => ToString(); /// /// Returns a string representation of the current instance of in its canonical Crockford's Base32 format.' @@ -93,13 +93,13 @@ public readonly partial struct Ulid #else [MethodImpl(MethodImplOptions.AggressiveInlining)] #endif - public override readonly string ToString() + public override string ToString() { #if NETSTANDARD2_1_OR_GREATER || NETCOREAPP - return string.Create(UlidStringLength, this, (span, ulid) => ulid.TryFill(span, _base32Chars)); + return string.Create(UlidStringLength, this, static (span, ulid) => ulid.Fill(span, _base32Chars)); #else Span span = stackalloc char[UlidStringLength]; - TryFill(span, _base32Chars); + Fill(span, _base32Chars); return span.ToString(); #endif } @@ -299,14 +299,15 @@ public readonly bool TryFormat( IFormatProvider? provider = null ) { - if (TryFill(destination, _base32Chars)) + if (destination.Length < UlidStringLength) { - charsWritten = UlidStringLength; - return true; + charsWritten = 0; + return false; } - charsWritten = 0; - return false; + Fill(destination, _base32Chars); + charsWritten = UlidStringLength; + return true; } /// @@ -331,26 +332,22 @@ public readonly bool TryFormat( IFormatProvider? provider = null ) { - if (TryFill(destination, _base32Bytes)) + if (destination.Length < UlidStringLength) { - bytesWritten = UlidStringLength; - return true; + bytesWritten = 0; + return false; } - bytesWritten = 0; - return false; + Fill(destination, _base32Bytes); + bytesWritten = UlidStringLength; + return true; } #if NETCOREAPP3_0_OR_GREATER [MethodImpl(MethodImplOptions.AggressiveOptimization)] #endif - private bool TryFill(Span span, T[] map) + private void Fill(Span span, T[] map) { - if (span.Length < UlidStringLength) - { - return false; - } - // Encode randomness span[25] = map[_r9 & 0x1F]; // [11111111][11111111][11111111][11111111][11111111][11111111][11111111][11111111][11111111][111|11111|] span[24] = map[((_r8 & 0x3) << 3) | (_r9 >> 5)]; // [11111111][11111111][11111111][11111111][11111111][11111111][11111111][11111111][111111|11][111|11111] @@ -380,8 +377,6 @@ private bool TryFill(Span span, T[] map) span[2] = map[_t1 >> 3]; // 00[11111111][|11111|111][11111111][11111111][11111111][11111111] span[1] = map[_t0 & 0x1F]; // 00[111|11111|][11111111][11111111][11111111][11111111][11111111] span[0] = map[_t0 >> 5]; // |00[111|11111][11111111][11111111][11111111][11111111][11111111] - - return true; } /// From f9414a91254db3382fe7ea53dbd4113b8d893a13 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joonatan=20Uusv=C3=A4li?= Date: Thu, 15 Jan 2026 16:22:12 +0200 Subject: [PATCH 2/3] Removed "readonly" marker from methods where it is not necessary. --- src/ByteAether.Ulid/Ulid.Boundaries.cs | 2 +- src/ByteAether.Ulid/Ulid.Comparable.cs | 4 ++-- src/ByteAether.Ulid/Ulid.Equatable.cs | 4 ++-- src/ByteAether.Ulid/Ulid.Guid.cs | 2 +- src/ByteAether.Ulid/Ulid.String.cs | 4 ++-- 5 files changed, 8 insertions(+), 8 deletions(-) diff --git a/src/ByteAether.Ulid/Ulid.Boundaries.cs b/src/ByteAether.Ulid/Ulid.Boundaries.cs index 98c0cdf..a2eefac 100644 --- a/src/ByteAether.Ulid/Ulid.Boundaries.cs +++ b/src/ByteAether.Ulid/Ulid.Boundaries.cs @@ -1,6 +1,6 @@ namespace ByteAether.Ulid; -public partial struct Ulid +public readonly partial struct Ulid { private static readonly byte[] _randomMin = Enumerable.Repeat((byte)0x00, _ulidSizeRandom).ToArray(); private static readonly byte[] _randomMax = Enumerable.Repeat((byte)0xFF, _ulidSizeRandom).ToArray(); diff --git a/src/ByteAether.Ulid/Ulid.Comparable.cs b/src/ByteAether.Ulid/Ulid.Comparable.cs index ff7575d..7319872 100644 --- a/src/ByteAether.Ulid/Ulid.Comparable.cs +++ b/src/ByteAether.Ulid/Ulid.Comparable.cs @@ -55,7 +55,7 @@ namespace ByteAether.Ulid; #if NETCOREAPP3_0_OR_GREATER [MethodImpl(MethodImplOptions.AggressiveOptimization)] #endif - public readonly int CompareTo(object? obj) + public int CompareTo(object? obj) { if (obj == null) { @@ -74,7 +74,7 @@ public readonly int CompareTo(object? obj) #if NETCOREAPP3_0_OR_GREATER [MethodImpl(MethodImplOptions.AggressiveOptimization)] #endif - public readonly int CompareTo(Ulid other) + public int CompareTo(Ulid other) => CompareToCore(this, other); #if NETCOREAPP3_0_OR_GREATER diff --git a/src/ByteAether.Ulid/Ulid.Equatable.cs b/src/ByteAether.Ulid/Ulid.Equatable.cs index a243836..b8c7048 100644 --- a/src/ByteAether.Ulid/Ulid.Equatable.cs +++ b/src/ByteAether.Ulid/Ulid.Equatable.cs @@ -18,7 +18,7 @@ namespace ByteAether.Ulid; #else [MethodImpl(MethodImplOptions.AggressiveInlining)] #endif - public override readonly int GetHashCode() + public override int GetHashCode() { ref var rA = ref Unsafe.As(ref Unsafe.AsRef(in this)); return rA ^ Unsafe.Add(ref rA, 1) ^ Unsafe.Add(ref rA, 2) ^ Unsafe.Add(ref rA, 3); @@ -28,7 +28,7 @@ public override readonly int GetHashCode() #if NETCOREAPP3_0_OR_GREATER [MethodImpl(MethodImplOptions.AggressiveOptimization)] #endif - public readonly bool Equals(Ulid other) + public bool Equals(Ulid other) => EqualsCore(this, other); /// diff --git a/src/ByteAether.Ulid/Ulid.Guid.cs b/src/ByteAether.Ulid/Ulid.Guid.cs index e242fe5..d91a880 100644 --- a/src/ByteAether.Ulid/Ulid.Guid.cs +++ b/src/ByteAether.Ulid/Ulid.Guid.cs @@ -78,7 +78,7 @@ public static Ulid New(Guid guid) #if NETCOREAPP3_0_OR_GREATER [MethodImpl(MethodImplOptions.AggressiveOptimization)] #endif - public readonly Guid ToGuid() + public Guid ToGuid() { #if NETCOREAPP if (BitConverter.IsLittleEndian && _isVector128Supported) diff --git a/src/ByteAether.Ulid/Ulid.String.cs b/src/ByteAether.Ulid/Ulid.String.cs index 098aa97..ceaedf2 100644 --- a/src/ByteAether.Ulid/Ulid.String.cs +++ b/src/ByteAether.Ulid/Ulid.String.cs @@ -292,7 +292,7 @@ public static bool TryParse(ReadOnlySpan s, IFormatProvider? provider, out #else [MethodImpl(MethodImplOptions.AggressiveInlining)] #endif - public readonly bool TryFormat( + public bool TryFormat( Span destination, out int charsWritten, ReadOnlySpan format, @@ -325,7 +325,7 @@ public readonly bool TryFormat( #else [MethodImpl(MethodImplOptions.AggressiveInlining)] #endif - public readonly bool TryFormat( + public bool TryFormat( Span destination, out int bytesWritten, ReadOnlySpan format, From ed1f7e0117e0b9b64908eb15a89b73b20f2b3c5f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joonatan=20Uusv=C3=A4li?= Date: Thu, 15 Jan 2026 16:42:05 +0200 Subject: [PATCH 3/3] Updated README to clarify ULID advantages over GUIDs, highlight strict monotonicity, and address limitations in UUIDv7 implementations. --- README.md | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index ed319c1..a5c22ad 100644 --- a/README.md +++ b/README.md @@ -31,7 +31,11 @@ This implementation addresses a potential `OverflowException` that can occur whe This library uniquely addresses the predictability of monotonic ULIDs generated within the same millisecond by allowing random increments to the random component. This mitigates enumeration attack vulnerabilities, as discussed in [ULID specification issue #105](https://github.com/ulid/spec/issues/105). You can configure the random increment with a random value ranging from 1-byte (1–256) to 4-bytes (1–4,294,967,296), enhancing randomness while preserving lexicographical sortability. -For most modern systems, ULIDs offer a superior alternative to both GUIDs and integer IDs. While GUIDs provide uniqueness, they lack sortability and readability, impacting indexing and querying efficiency. Integer IDs are sortable but not universally unique, leading to potential conflicts in distributed environments. ULIDs combine universal uniqueness with lexicographical sortability, making them the optimal choice for scalable and efficient identifier generation in modern applications. This library provides a robust, reliable, and compliant ULID implementation, enabling your application to leverage these benefits without compromising performance or adherence to the official specification. +--- + +In the evolution of distributed identifiers, ULIDs represent the definitive successor to both legacy GUIDs and auto-incrementing integers. While modern standards like UUIDv7 attempt to address sortability, the [RFC 9562](https://www.rfc-editor.org/rfc/rfc9562#name-monotonicity-and-counters) makes monotonicity optional, allowing implementations ([such as the native .NET provider](https://github.com/dotnet/runtime/blob/571b044582ceb7fe426b7f143c703064aa9ea4db/src/libraries/System.Private.CoreLib/src/System/Guid.cs#L306)) to sacrifice strict ordering during sub-millisecond bursts. This _"lazy"_ approach reintroduces the very index fragmentation and out-of-order writes that sortable IDs were meant to solve. + +ULID addresses this by design, mandating strict lexicographical sortability and monotonic increments. By enforcing these requirements at the specification level rather than leaving them to the implementor's discretion, ULID ensures consistent, high-performance behavior across all environments. This library provides a robust, compliant implementation that guarantees this order, enabling your application to scale without the performance trade-offs of non-deterministic identifiers. ## Features