This commit is contained in:
@@ -0,0 +1,141 @@
|
|||||||
|
namespace Just.Core.Tests.GuidV8Tests;
|
||||||
|
|
||||||
|
public class ExtractTimestamp
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void RoundTrip_ShouldReturnOriginalTimestamp()
|
||||||
|
{
|
||||||
|
var original = new DateTime(2026, 7, 10, 21, 30, 0, DateTimeKind.Utc);
|
||||||
|
|
||||||
|
var guid = GuidV8.NewGuid(original, RngEntropy.Weak);
|
||||||
|
var extracted = GuidV8.ExtractTimestamp(guid);
|
||||||
|
|
||||||
|
extracted.ShouldBe(original);
|
||||||
|
extracted.Kind.ShouldBe(DateTimeKind.Utc);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void RoundTrip_ShouldPreserve100MicrosecondPrecision()
|
||||||
|
{
|
||||||
|
// 100-microsecond precision: 1234567 * 100µs = 123456.7ms
|
||||||
|
var original = new DateTime(2024, 3, 15, 8, 45, 30, 123, DateTimeKind.Utc)
|
||||||
|
.AddTicks(4567); // sub-millisecond ticks
|
||||||
|
|
||||||
|
var guid = GuidV8.NewGuid(original, RngEntropy.Weak);
|
||||||
|
var extracted = GuidV8.ExtractTimestamp(guid);
|
||||||
|
|
||||||
|
// 123ms + 4567 ticks = 123ms + 456.7µs
|
||||||
|
// timestamp = (ticks since epoch) / 1000 (100µs units)
|
||||||
|
// Ticks: 123ms = 1,230,000 ticks; + 4,567 ticks = 1,234,567 ticks
|
||||||
|
// timestamp = 1,234,567 / 1000 = 1234 (truncates last 3 digits)
|
||||||
|
// recovered = 1234 * 1000 = 1,234,000 ticks = 123.4ms
|
||||||
|
// So the sub-100µs part (67 ticks) is lost
|
||||||
|
extracted.ShouldBe(new DateTime(2024, 3, 15, 8, 45, 30, 123, DateTimeKind.Utc)
|
||||||
|
.AddTicks(4000)); // 123.4ms
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void RoundTrip_UnixEpoch_ShouldReturnExactEpoch()
|
||||||
|
{
|
||||||
|
var original = DateTime.UnixEpoch;
|
||||||
|
|
||||||
|
var guid = GuidV8.NewGuid(original, RngEntropy.Weak);
|
||||||
|
var extracted = GuidV8.ExtractTimestamp(guid);
|
||||||
|
|
||||||
|
extracted.ShouldBe(original);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void RoundTrip_PreUnixEpoch_ShouldThrowArgumentException()
|
||||||
|
{
|
||||||
|
// Negative timestamps cannot be encoded correctly because the uint casts
|
||||||
|
// in NewGuid corrupt the sign. The encoding should reject them.
|
||||||
|
var preEpoch = new DateTime(1969, 7, 20, 20, 17, 40, DateTimeKind.Utc);
|
||||||
|
|
||||||
|
Action act = () => GuidV8.NewGuid(preEpoch);
|
||||||
|
|
||||||
|
act.ShouldThrow<ArgumentException>();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void RoundTrip_FarFuture_ShouldPreserveTimestamp()
|
||||||
|
{
|
||||||
|
// Year 2200 — well within 48-bit range
|
||||||
|
var original = new DateTime(2200, 1, 1, 0, 0, 0, DateTimeKind.Utc);
|
||||||
|
|
||||||
|
var guid = GuidV8.NewGuid(original, RngEntropy.Weak);
|
||||||
|
var extracted = GuidV8.ExtractTimestamp(guid);
|
||||||
|
|
||||||
|
extracted.ShouldBe(original);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ExtractTimestamp_WithStrongEntropy_ShouldStillRecoverTimestamp()
|
||||||
|
{
|
||||||
|
var original = new DateTime(2025, 12, 25, 10, 0, 0, DateTimeKind.Utc);
|
||||||
|
|
||||||
|
var guid = GuidV8.NewGuid(original, RngEntropy.Strong);
|
||||||
|
var extracted = GuidV8.ExtractTimestamp(guid);
|
||||||
|
|
||||||
|
extracted.ShouldBe(original);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void NonV8Guid_ShouldThrowArgumentException()
|
||||||
|
{
|
||||||
|
var guid = Guid.NewGuid(); // UUID v4
|
||||||
|
|
||||||
|
Action act = () => GuidV8.ExtractTimestamp(guid);
|
||||||
|
|
||||||
|
act.ShouldThrow<ArgumentException>()
|
||||||
|
.WithMessage("The provided GUID is not a UUID v8 (version=4). (Parameter 'guid')");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void GuidEmpty_ShouldThrowArgumentException()
|
||||||
|
{
|
||||||
|
Action act = () => GuidV8.ExtractTimestamp(Guid.Empty);
|
||||||
|
|
||||||
|
act.ShouldThrow<ArgumentException>()
|
||||||
|
.WithMessage("The provided GUID is not a UUID v8 (version=0). (Parameter 'guid')");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void MultipleRoundTrips_ShouldAllMatch()
|
||||||
|
{
|
||||||
|
var baseTime = new DateTime(2020, 1, 1, 0, 0, 0, DateTimeKind.Utc);
|
||||||
|
|
||||||
|
for (int i = 0; i < 100; i++)
|
||||||
|
{
|
||||||
|
var original = baseTime.AddMinutes(i * 37 + 13); // varied intervals
|
||||||
|
var guid = GuidV8.NewGuid(original, RngEntropy.Weak);
|
||||||
|
var extracted = GuidV8.ExtractTimestamp(guid);
|
||||||
|
|
||||||
|
extracted.ShouldBe(original);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ExtractTimestamp_ThenNewGuid_ShouldProduceSortableSequence()
|
||||||
|
{
|
||||||
|
var t1 = new DateTime(2026, 5, 1, 0, 0, 0, DateTimeKind.Utc);
|
||||||
|
var t2 = t1.AddSeconds(1);
|
||||||
|
var t3 = t2.AddSeconds(1);
|
||||||
|
|
||||||
|
var g1 = GuidV8.NewGuid(t1, RngEntropy.Weak);
|
||||||
|
var g2 = GuidV8.NewGuid(t2, RngEntropy.Weak);
|
||||||
|
var g3 = GuidV8.NewGuid(t3, RngEntropy.Weak);
|
||||||
|
|
||||||
|
var extracted1 = GuidV8.ExtractTimestamp(g1);
|
||||||
|
var extracted2 = GuidV8.ExtractTimestamp(g2);
|
||||||
|
var extracted3 = GuidV8.ExtractTimestamp(g3);
|
||||||
|
|
||||||
|
extracted1.ShouldBe(t1);
|
||||||
|
extracted2.ShouldBe(t2);
|
||||||
|
extracted3.ShouldBe(t3);
|
||||||
|
|
||||||
|
// Verify they sort in order
|
||||||
|
new[] { g3, g1, g2 }.Order().ShouldBe([g1, g2, g3]);
|
||||||
|
new[] { extracted3, extracted1, extracted2 }.Order().ShouldBe([t1, t2, t3]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -86,11 +86,11 @@ public class NextId
|
|||||||
var time = TestEpoch.AddMilliseconds(200);
|
var time = TestEpoch.AddMilliseconds(200);
|
||||||
|
|
||||||
// Act & Assert
|
// Act & Assert
|
||||||
for (int i = 0; i < 255; i++)
|
for (int i = 0; i < 256; i++)
|
||||||
{
|
{
|
||||||
_seqId.Next(time); // Exhauste sequence
|
_seqId.Next(time); // Use all 256 sequence values (0-255)
|
||||||
}
|
}
|
||||||
Action act = () => _seqId.Next(time);
|
Action act = () => _seqId.Next(time); // 257th call should throw
|
||||||
act.ShouldThrow<IndexOutOfRangeException>()
|
act.ShouldThrow<IndexOutOfRangeException>()
|
||||||
.WithMessage("Refused to create new SeqId. Sequence exhausted.");
|
.WithMessage("Refused to create new SeqId. Sequence exhausted.");
|
||||||
}
|
}
|
||||||
|
|||||||
+41
-1
@@ -1,10 +1,11 @@
|
|||||||
|
using System.Runtime.InteropServices;
|
||||||
using System.Security.Cryptography;
|
using System.Security.Cryptography;
|
||||||
|
|
||||||
namespace Just.Core;
|
namespace Just.Core;
|
||||||
|
|
||||||
public static class GuidV8
|
public static class GuidV8
|
||||||
{
|
{
|
||||||
private const long TicksPrecision = TimeSpan.TicksPerMillisecond / 10;
|
private const long TicksPrecision = TimeSpan.TicksPerMillisecond / 10; // 100-microsecond units
|
||||||
|
|
||||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||||
[ExcludeFromCodeCoverage]
|
[ExcludeFromCodeCoverage]
|
||||||
@@ -15,6 +16,12 @@ public static class GuidV8
|
|||||||
var epoch = dateTime.Subtract(DateTime.UnixEpoch);
|
var epoch = dateTime.Subtract(DateTime.UnixEpoch);
|
||||||
var timestamp = epoch.Ticks / TicksPrecision;
|
var timestamp = epoch.Ticks / TicksPrecision;
|
||||||
|
|
||||||
|
// Negative timestamps can't be encoded correctly due to unsigned bit operations
|
||||||
|
if (timestamp < 0)
|
||||||
|
{
|
||||||
|
throw new ArgumentException("Timestamp must be on or after UnixEpoch (1970-01-01 UTC).", nameof(dateTime));
|
||||||
|
}
|
||||||
|
|
||||||
uint tsHigh = (uint)((timestamp >> 16) & 0xFFFFFFFF);
|
uint tsHigh = (uint)((timestamp >> 16) & 0xFFFFFFFF);
|
||||||
ushort tsLow = (ushort)(timestamp & 0x0000FFFF);
|
ushort tsLow = (ushort)(timestamp & 0x0000FFFF);
|
||||||
|
|
||||||
@@ -40,4 +47,37 @@ public static class GuidV8
|
|||||||
version,
|
version,
|
||||||
bytes[2], bytes[3], bytes[4], bytes[5], bytes[6], bytes[7], bytes[8], bytes[9]);
|
bytes[2], bytes[3], bytes[4], bytes[5], bytes[6], bytes[7], bytes[8], bytes[9]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Extracts the timestamp from a UUID v8 generated by <see cref="NewGuid(DateTime, RngEntropy)"/>.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="guid">The UUID v8 to extract the timestamp from.</param>
|
||||||
|
/// <returns>The <see cref="DateTime"/> (UTC) encoded in the GUID's timestamp fields.</returns>
|
||||||
|
/// <exception cref="ArgumentException">
|
||||||
|
/// Thrown if <paramref name="guid"/> is not a UUID v8.
|
||||||
|
/// </exception>
|
||||||
|
/// <remarks>
|
||||||
|
/// The returned timestamp has 100-microsecond precision (the resolution used by
|
||||||
|
/// <see cref="NewGuid(DateTime, RngEntropy)"/>). Sub-100µs components of the original
|
||||||
|
/// <see cref="DateTime"/> are not recoverable.
|
||||||
|
/// </remarks>
|
||||||
|
[Pure]
|
||||||
|
public static DateTime ExtractTimestamp(Guid guid)
|
||||||
|
{
|
||||||
|
Span<byte> bytes = stackalloc byte[16];
|
||||||
|
guid.TryWriteBytes(bytes);
|
||||||
|
|
||||||
|
// Version is the high nibble of byte 7 (parameter 'c' high byte, little-endian byte 7)
|
||||||
|
var version = bytes[7] >> 4;
|
||||||
|
if (version != 8)
|
||||||
|
throw new ArgumentException($"The provided GUID is not a UUID v8 (version={version}).", nameof(guid));
|
||||||
|
|
||||||
|
// tsHigh is stored as Int32 at bytes 0-3
|
||||||
|
uint tsHigh = MemoryMarshal.Read<uint>(bytes);
|
||||||
|
// tsLow is stored as Int16 at bytes 4-5
|
||||||
|
ushort tsLow = MemoryMarshal.Read<ushort>(bytes[4..]);
|
||||||
|
|
||||||
|
long timestamp = ((long)tsHigh << 16) | tsLow;
|
||||||
|
return DateTime.UnixEpoch.AddTicks(timestamp * TicksPrecision);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+3
-3
@@ -48,7 +48,7 @@ public sealed class SeqId(DateTime epoch)
|
|||||||
/// <param name="entropy">Entropy quality (default: Strong)</param>
|
/// <param name="entropy">Entropy quality (default: Strong)</param>
|
||||||
/// <returns>64-bit sequential ID with random component</returns>
|
/// <returns>64-bit sequential ID with random component</returns>
|
||||||
/// <exception cref="IndexOutOfRangeException">
|
/// <exception cref="IndexOutOfRangeException">
|
||||||
/// Thrown if more than 255 IDs generated in 1ms
|
/// Thrown if more than 256 IDs generated in 1ms
|
||||||
/// </exception>
|
/// </exception>
|
||||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||||
public static long NextId(RngEntropy entropy = RngEntropy.Strong) => Default.Next(entropy);
|
public static long NextId(RngEntropy entropy = RngEntropy.Strong) => Default.Next(entropy);
|
||||||
@@ -70,7 +70,7 @@ public sealed class SeqId(DateTime epoch)
|
|||||||
/// <param name="entropy">Entropy quality (default: Strong)</param>
|
/// <param name="entropy">Entropy quality (default: Strong)</param>
|
||||||
/// <returns>64-bit sequential ID with random component</returns>
|
/// <returns>64-bit sequential ID with random component</returns>
|
||||||
/// <exception cref="IndexOutOfRangeException">
|
/// <exception cref="IndexOutOfRangeException">
|
||||||
/// Thrown if more than 255 IDs generated in 1ms
|
/// Thrown if more than 256 IDs generated in 1ms
|
||||||
/// </exception>
|
/// </exception>
|
||||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||||
public long Next(RngEntropy entropy = RngEntropy.Strong) => Next(_defaultTimeFactory(), entropy);
|
public long Next(RngEntropy entropy = RngEntropy.Strong) => Next(_defaultTimeFactory(), entropy);
|
||||||
@@ -85,7 +85,7 @@ public sealed class SeqId(DateTime epoch)
|
|||||||
/// Thrown if <paramref name="dateTime"/> is earlier than last used timestamp
|
/// Thrown if <paramref name="dateTime"/> is earlier than last used timestamp
|
||||||
/// </exception>
|
/// </exception>
|
||||||
/// <exception cref="IndexOutOfRangeException">
|
/// <exception cref="IndexOutOfRangeException">
|
||||||
/// Thrown if more than 255 IDs generated in 1ms
|
/// Thrown if more than 256 IDs generated in 1ms
|
||||||
/// </exception>
|
/// </exception>
|
||||||
public long Next(DateTime dateTime, RngEntropy entropy = RngEntropy.Strong)
|
public long Next(DateTime dateTime, RngEntropy entropy = RngEntropy.Strong)
|
||||||
{
|
{
|
||||||
|
|||||||
Reference in New Issue
Block a user