From 8e961352d220b799c35c3e235c7d0260f26ceb76 Mon Sep 17 00:00:00 2001 From: just Date: Fri, 10 Jul 2026 22:06:37 +0400 Subject: [PATCH] fixed seqid test, added timestamp extraction --- Core.Tests/GuidV8Tests/ExtractTimestamp.cs | 141 +++++++++++++++++++++ Core.Tests/SeqIdTests/NextId.cs | 6 +- Core/GuidV8.cs | 42 +++++- Core/SeqId.cs | 6 +- 4 files changed, 188 insertions(+), 7 deletions(-) create mode 100644 Core.Tests/GuidV8Tests/ExtractTimestamp.cs diff --git a/Core.Tests/GuidV8Tests/ExtractTimestamp.cs b/Core.Tests/GuidV8Tests/ExtractTimestamp.cs new file mode 100644 index 0000000..85ea5b9 --- /dev/null +++ b/Core.Tests/GuidV8Tests/ExtractTimestamp.cs @@ -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(); + } + + [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() + .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() + .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]); + } +} diff --git a/Core.Tests/SeqIdTests/NextId.cs b/Core.Tests/SeqIdTests/NextId.cs index b99f17e..0ca9353 100644 --- a/Core.Tests/SeqIdTests/NextId.cs +++ b/Core.Tests/SeqIdTests/NextId.cs @@ -86,11 +86,11 @@ public class NextId var time = TestEpoch.AddMilliseconds(200); // 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() .WithMessage("Refused to create new SeqId. Sequence exhausted."); } diff --git a/Core/GuidV8.cs b/Core/GuidV8.cs index e31f7dc..c50e2c7 100644 --- a/Core/GuidV8.cs +++ b/Core/GuidV8.cs @@ -1,10 +1,11 @@ +using System.Runtime.InteropServices; using System.Security.Cryptography; namespace Just.Core; public static class GuidV8 { - private const long TicksPrecision = TimeSpan.TicksPerMillisecond / 10; + private const long TicksPrecision = TimeSpan.TicksPerMillisecond / 10; // 100-microsecond units [MethodImpl(MethodImplOptions.AggressiveInlining)] [ExcludeFromCodeCoverage] @@ -15,6 +16,12 @@ public static class GuidV8 var epoch = dateTime.Subtract(DateTime.UnixEpoch); 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); ushort tsLow = (ushort)(timestamp & 0x0000FFFF); @@ -40,4 +47,37 @@ public static class GuidV8 version, bytes[2], bytes[3], bytes[4], bytes[5], bytes[6], bytes[7], bytes[8], bytes[9]); } + + /// + /// Extracts the timestamp from a UUID v8 generated by . + /// + /// The UUID v8 to extract the timestamp from. + /// The (UTC) encoded in the GUID's timestamp fields. + /// + /// Thrown if is not a UUID v8. + /// + /// + /// The returned timestamp has 100-microsecond precision (the resolution used by + /// ). Sub-100µs components of the original + /// are not recoverable. + /// + [Pure] + public static DateTime ExtractTimestamp(Guid guid) + { + Span 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(bytes); + // tsLow is stored as Int16 at bytes 4-5 + ushort tsLow = MemoryMarshal.Read(bytes[4..]); + + long timestamp = ((long)tsHigh << 16) | tsLow; + return DateTime.UnixEpoch.AddTicks(timestamp * TicksPrecision); + } } diff --git a/Core/SeqId.cs b/Core/SeqId.cs index a85b30f..80d235e 100644 --- a/Core/SeqId.cs +++ b/Core/SeqId.cs @@ -48,7 +48,7 @@ public sealed class SeqId(DateTime epoch) /// Entropy quality (default: Strong) /// 64-bit sequential ID with random component /// - /// Thrown if more than 255 IDs generated in 1ms + /// Thrown if more than 256 IDs generated in 1ms /// [MethodImpl(MethodImplOptions.AggressiveInlining)] public static long NextId(RngEntropy entropy = RngEntropy.Strong) => Default.Next(entropy); @@ -70,7 +70,7 @@ public sealed class SeqId(DateTime epoch) /// Entropy quality (default: Strong) /// 64-bit sequential ID with random component /// - /// Thrown if more than 255 IDs generated in 1ms + /// Thrown if more than 256 IDs generated in 1ms /// [MethodImpl(MethodImplOptions.AggressiveInlining)] public long Next(RngEntropy entropy = RngEntropy.Strong) => Next(_defaultTimeFactory(), entropy); @@ -85,7 +85,7 @@ public sealed class SeqId(DateTime epoch) /// Thrown if is earlier than last used timestamp /// /// - /// Thrown if more than 255 IDs generated in 1ms + /// Thrown if more than 256 IDs generated in 1ms /// public long Next(DateTime dateTime, RngEntropy entropy = RngEntropy.Strong) {