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 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.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.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..ceaedf2 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 } @@ -292,21 +292,22 @@ 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, 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; } /// @@ -324,33 +325,29 @@ public readonly bool TryFormat( #else [MethodImpl(MethodImplOptions.AggressiveInlining)] #endif - public readonly bool TryFormat( + public bool TryFormat( Span destination, out int bytesWritten, ReadOnlySpan format, 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; } ///