diff --git a/Doc/UUIDNext.xml b/Doc/UUIDNext.xml index a42a372..d637cdd 100644 --- a/Doc/UUIDNext.xml +++ b/Doc/UUIDNext.xml @@ -39,19 +39,9 @@ This class implement the "Fixed Bit-Length Dedicated Counter" described in the section 6.2 of the RFC - + - Generate a UUID version 4 based on RFC 9562 - - - - - Generate a UUID version 5 based on RFC 9562 - - - - - Generate a UUID version 7 given an arbitrary date + Generate a UUID with a timestamp given an arbitrary date To give the best possible UUID given an arbitrary date we can't rely on UuidV7Generator because it has some @@ -63,13 +53,13 @@ The first point implies that there shouldn't be overflow preventing mechanism like in UuidV7Generator. The second point implies that we should keep track of the monotonicity of multiple timestamps in parallel. The third point implies that the number of timestamps we keep track of should be limited. - After some benchmarks, I chose a cache size of 256 entries. The cache has a memory footprint of only a few KB and - has a reasonable worst case performance + After some benchmarks, I chose a cache size of 1024 entries. The cache has a memory footprint of only a few + dozen KB and has a reasonable worst case performance - + - Generate a UUID version 7 given an arbitrary date + Generate a UUID with a timestamp given an arbitrary date To give the best possible UUID given an arbitrary date we can't rely on UuidV7Generator because it has some @@ -81,22 +71,52 @@ The first point implies that there shouldn't be overflow preventing mechanism like in UuidV7Generator. The second point implies that we should keep track of the monotonicity of multiple timestamps in parallel. The third point implies that the number of timestamps we keep track of should be limited. - After some benchmarks, I chose a cache size of 256 entries. The cache has a memory footprint of only a few KB and - has a reasonable worst case performance + After some benchmarks, I chose a cache size of 1024 entries. The cache has a memory footprint of only a few + dozen KB and has a reasonable worst case performance - + Create a UUID version 7 where the timestamp part represent the given date - The date that will provide the timestamp par of the UUID + The date that will provide the timestamp part of the UUID A UUID version 7 + + + Generate a UUID version 4 based on RFC 9562 + + + + + Generate a UUID version 5 based on RFC 9562 + + + + + Generate a UUID version 7 given an arbitrary date + + + + + Generate a UUID version 7 given an arbitrary date + + Generate a UUID version 7 based on RFC 9562 + + + Generate a UUID version 8 optimised for SQL Server given an arbitrary date + + + + + Generate a UUID version 8 optimised for SQL Server given an arbitrary date + + Generate a UUID version 8 based on RFC 9562 @@ -107,24 +127,14 @@ when used in a uniqueidentifier typed column in SQL Sever - - - Provite a set of static and extensions methods that brings .NET8+ features to .NET Standard 2.1 and .NET framework - - - - - Creates a new guid from a span of bytes. - - - + - Returns an unsigned byte array containing the GUID. + A better cache - + - Returns whether bytes are successfully written to given span. + A better cache @@ -137,15 +147,19 @@ Compares two Guids and returns an indication of their relative sort order. - + - A quick and dirty cache + Call to .NET's RandomNumberGenerator has a significant overhead when filling small spans/arrays of a few bytes + The goal of this class is to generate random numbers in larger batch to reduce the number of calls to RandomNumberGenerator + - + - A quick and dirty cache + Call to .NET's RandomNumberGenerator has a significant overhead when filling small spans/arrays of a few bytes + The goal of this class is to generate random numbers in larger batch to reduce the number of calls to RandomNumberGenerator + @@ -243,6 +257,16 @@ Create a new UUID version 7 with the given date as timestamp + + + Create a new sequential UUID Optimised for SQL Server with the given date as timestamp + + + + + returns true if the date is close to DateTimeOffset.UtcNow, false otherwise + + Provite a set of static methods for generating UUIDs diff --git a/Doc/uuidnext.tools.uuidtoolkit.md b/Doc/uuidnext.tools.uuidtoolkit.md index 050c025..594edc4 100644 --- a/Doc/uuidnext.tools.uuidtoolkit.md +++ b/Doc/uuidnext.tools.uuidtoolkit.md @@ -71,6 +71,22 @@ Here is the bit layout of the UUID Version 7 created +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ ``` +### **CreateSequentialUuidForSqlServerFromSpecificDate(DateTimeOffset)** + +Create a new sequential UUID Optimised for SQL Server with the given date as timestamp + +```csharp +public static Guid CreateSequentialUuidForSqlServerFromSpecificDate(DateTimeOffset date) +``` + +#### Parameters + +`date` [DateTimeOffset](https://docs.microsoft.com/en-us/dotnet/api/system.datetimeoffset)
+ +#### Returns + +[Guid](https://docs.microsoft.com/en-us/dotnet/api/system.guid)
+ ### **CreateGuidFromBigEndianBytes(Span<Byte>)** Create new UUID version 8 with the provided bytes with the variant and version bits set diff --git a/Src/UUIDNext.Test/Generator/UuidFromSpecificDateGeneratorTestBase.cs b/Src/UUIDNext.Test/Generator/UuidFromSpecificDateGeneratorTestBase.cs new file mode 100644 index 0000000..ab6f8e5 --- /dev/null +++ b/Src/UUIDNext.Test/Generator/UuidFromSpecificDateGeneratorTestBase.cs @@ -0,0 +1,95 @@ +using System; +using NFluent; +using UUIDNext.Tools; +using Xunit; + +namespace UUIDNext.Test.Generator; + +public abstract class UuidFromSpecificDateGeneratorTestBase : UuidTimestampGeneratorBaseTest +{ + [Theory] + [MemberData(nameof(InvalidDates))] + public void CheckThatDateBeforeUnixEpochAreRejected(DateTimeOffset date) + { + var generator = NewGenerator(); + Check.ThatCode(() => NewUuid(generator, date)).Throws(); + } + + public static object[][] InvalidDates + => [[new DateTimeOffset(new(1900, 1, 1))], [DateTimeOffset.FromUnixTimeMilliseconds(-1)], [DateTimeOffset.MinValue]]; + + [Fact] + public void EnsureThatUuidsFromTheSameTimestampAreIncreasing() + { + UuidWithTimestampComparer comparer = new(); + DateTimeOffset date = new(new(2020, 2, 1)); + var generator = NewGenerator(); + Guid previousUuid = NewUuid(generator, date.AddMilliseconds(-1)); + for (int i = 0; i < 100; i++) + { + var uuid = NewUuid(generator, date); + Check.That(comparer.Compare(previousUuid, uuid)).IsStrictlyLessThan(0); + previousUuid = uuid; + } + } + + [Fact] + public void EnsureThatUuidsFromMultipleTimestampAreIncreasing() + { + UuidWithTimestampComparer comparer = new(); + + DateTimeOffset dateT = new(new(2028, 10, 12)); + DateTimeOffset dateA = new(new(2030, 5, 1)); + DateTimeOffset dateS = new(new(2037, 11, 24)); + + var generator = NewGenerator(); + + Guid previousUuidT = NewUuid(generator, dateT.AddMilliseconds(-1)); + Guid previousUuidA = NewUuid(generator, dateA.AddMilliseconds(-1)); + Guid previousUuidS = NewUuid(generator, dateS.AddMilliseconds(-1)); + + for (int i = 0; i < 100; i++) + { + var uuidT = NewUuid(generator, dateT); + var uuidA = NewUuid(generator, dateA); + var uuidS = NewUuid(generator, dateS); + + Check.That(comparer.Compare(previousUuidT, uuidT)).IsStrictlyLessThan(0); + Check.That(comparer.Compare(previousUuidA, uuidA)).IsStrictlyLessThan(0); + Check.That(comparer.Compare(previousUuidS, uuidS)).IsStrictlyLessThan(0); + + previousUuidT = uuidT; + previousUuidA = uuidA; + previousUuidS = uuidS; + } + } + + [Fact] + public void EnsureTimestampAndVersionAreAlwaysCorrect() + { + UuidWithTimestampComparer comparer = new(); + DateTime date = new(2020, 2, 1, 0, 0, 0, DateTimeKind.Utc); + var generator = NewGenerator(); + + int overflowCount = 0; + Guid previousUuid = NewUuid(generator, date.AddMilliseconds(-1)); + + // we loop 10_000 times to make sure that the sequence of the UUID v7 will overflow at + // least twice since the sequence is stored on 12 bits and its maximum value is thus 4095 + int iterationCount = (int)(GetSequenceMaxValue() * 2.5); + for (int i = 0; i < iterationCount; i++) + { + var uuid = NewUuid(generator, date); + Check.That(UuidDecoder.GetVersion(uuid)).Is(Version); + var uuidDate = UuidTestHelper.DecodeDate(uuid); + Check.That(uuidDate).Is(date); + + if (comparer.Compare(previousUuid, uuid) > 0) + overflowCount++; + + previousUuid = uuid; + } + + Check.That(overflowCount).IsGreaterOrEqualThan(2).And.IsLessOrEqualThan(5); + } +} diff --git a/Src/UUIDNext.Test/Generator/UuidTimestampGeneratorBaseTest.cs b/Src/UUIDNext.Test/Generator/UuidTimestampGeneratorBaseTest.cs index 1dbf67c..435e253 100644 --- a/Src/UUIDNext.Test/Generator/UuidTimestampGeneratorBaseTest.cs +++ b/Src/UUIDNext.Test/Generator/UuidTimestampGeneratorBaseTest.cs @@ -4,80 +4,65 @@ using NFluent; using Xunit; -namespace UUIDNext.Test.Generator +namespace UUIDNext.Test.Generator; + +public abstract class UuidTimestampGeneratorBaseTest { - public abstract class UuidTimestampGeneratorBaseTest - { - protected abstract byte Version { get; } + protected abstract byte Version { get; } - protected abstract int SequenceBitSize { get; } + protected abstract int SequenceBitSize { get; } - protected abstract Guid NewUuid(object generator); + protected abstract Guid NewUuid(object generator); - protected abstract object NewGenerator(); + protected abstract Guid NewUuid(object generator, DateTimeOffset date); - private int GetSequenceMaxValue() => (1 << SequenceBitSize) - 1; + protected abstract object NewGenerator(); - private short GetSeedMaxValue() => (short)((GetSequenceMaxValue() + 1) / 2 - 1); + protected int GetSequenceMaxValue() => (1 << SequenceBitSize) - 1; - [Fact] - public void DumbTest() - { - var generator = NewGenerator(); - ConcurrentBag generatedUuids = new(); - Parallel.For(0, 100, _ => generatedUuids.Add(NewUuid(generator))); + protected short GetSeedMaxValue() => (short)((GetSequenceMaxValue() + 1) / 2 - 1); - Check.That(generatedUuids).ContainsNoDuplicateItem(); + [Fact] + public void DumbTest() + { + var generator = NewGenerator(); + ConcurrentBag generatedUuids = new(); + Parallel.For(0, 100, _ => generatedUuids.Add(NewUuid(generator))); - foreach (var uuid in generatedUuids) - { - UuidTestHelper.CheckVersionAndVariant(uuid, Version); - } - } + Check.That(generatedUuids).ContainsNoDuplicateItem(); - [Fact] - public void TestSequenceOverflow() + foreach (var uuid in generatedUuids) { - var generator = NewGenerator(); - var date = DateTime.UtcNow.Date; - - var firstUuid = UuidTestHelper.New(generator, date); - var firstDate = UuidTestHelper.DecodeDate(firstUuid); - var firstSequence = UuidTestHelper.DecodeSequence(firstUuid); - - Check.That(firstSequence).IsLessOrEqualThan(GetSeedMaxValue()); + UuidTestHelper.CheckVersionAndVariant(uuid, Version); + } + } - Parallel.For(0, GetSequenceMaxValue() - firstSequence, _ => UuidTestHelper.New(generator, date)); + [Fact] - var lastUuid = UuidTestHelper.New(generator, date); - var lastDate = UuidTestHelper.DecodeDate(lastUuid); - var lastSequence = UuidTestHelper.DecodeSequence(lastUuid); - Check.That(lastSequence).IsLessOrEqualThan(GetSeedMaxValue()); - Check.That(lastDate).IsEqualTo(firstDate.AddMilliseconds(1)); - } + public void CheckSequenceSeeding() + { + const int iterationCOunt = 1_000_000; - [Fact] - public void TestSequenceOverflowWithOffset() - { - var generator = NewGenerator(); - var date = DateTime.UtcNow.Date; + short maxSequence = (short)GetSequenceMaxValue(); - //setup an offset by generating an uuid into the future - UuidTestHelper.New(generator, date.AddSeconds(1)); + var generator = NewGenerator(); - var firstUuid = UuidTestHelper.New(generator, date); - var firstDate = UuidTestHelper.DecodeDate(firstUuid); - var firstSequence = UuidTestHelper.DecodeSequence(firstUuid); + short maxGeneratedSequence = 0; + DateTimeOffset baseDate = new(new(2020, 1, 2)); - Check.That(firstSequence).IsLessOrEqualThan(GetSeedMaxValue()); + for (int i = 0; i < iterationCOunt; i++) + { + var guid = NewUuid(generator, baseDate.AddMilliseconds(i)); + bool decodedSequence = UUIDNext.Tools.UuidDecoder.TryDecodeSequence(guid, out short sequence); + Check.That(decodedSequence) + .IsTrue(); - Parallel.For(0, GetSequenceMaxValue() - firstSequence, _ => UuidTestHelper.New(generator, date)); + Check.That(sequence).IsGreaterOrEqualThan(0); + Check.That(sequence).IsLessOrEqualThan(maxSequence); - var lastUuid = UuidTestHelper.New(generator, date); - var lastDate = UuidTestHelper.DecodeDate(lastUuid); - var lastSequence = UuidTestHelper.DecodeSequence(lastUuid); - Check.That(lastSequence).IsLessOrEqualThan(GetSeedMaxValue()); - Check.That(lastDate).IsEqualTo(firstDate.AddMilliseconds(1)); + maxGeneratedSequence = Math.Max(maxGeneratedSequence, sequence); } + + Check.That(maxGeneratedSequence).IsStrictlyGreaterThan((short)(1 << (SequenceBitSize - 2))); } -} +} \ No newline at end of file diff --git a/Src/UUIDNext.Test/Generator/UuidTimestampWithOverflowGeneratorBaseTest.cs b/Src/UUIDNext.Test/Generator/UuidTimestampWithOverflowGeneratorBaseTest.cs new file mode 100644 index 0000000..ecf4a3a --- /dev/null +++ b/Src/UUIDNext.Test/Generator/UuidTimestampWithOverflowGeneratorBaseTest.cs @@ -0,0 +1,57 @@ +using System; +using System.Threading.Tasks; +using NFluent; +using Xunit; + +namespace UUIDNext.Test.Generator; + +public abstract class UuidTimestampWithOverflowGeneratorBaseTest : UuidTimestampGeneratorBaseTest +{ + protected override Guid NewUuid(object generator, DateTimeOffset date) + => UuidTestHelper.New(generator, date); + + [Fact] + public void TestSequenceOverflow() + { + var generator = NewGenerator(); + var date = DateTime.UtcNow.Date; + + var firstUuid = UuidTestHelper.New(generator, date); + var firstDate = UuidTestHelper.DecodeDate(firstUuid); + var firstSequence = UuidTestHelper.DecodeSequence(firstUuid); + + Check.That(firstSequence).IsLessOrEqualThan(GetSeedMaxValue()); + + Parallel.For(0, GetSequenceMaxValue() - firstSequence, _ => UuidTestHelper.New(generator, date)); + + var lastUuid = UuidTestHelper.New(generator, date); + var lastDate = UuidTestHelper.DecodeDate(lastUuid); + var lastSequence = UuidTestHelper.DecodeSequence(lastUuid); + Check.That(lastSequence).IsLessOrEqualThan(GetSeedMaxValue()); + Check.That(lastDate).IsEqualTo(firstDate.AddMilliseconds(1)); + } + + [Fact] + public void TestSequenceOverflowWithOffset() + { + var generator = NewGenerator(); + var date = DateTime.UtcNow.Date; + + //setup an offset by generating an uuid into the future + UuidTestHelper.New(generator, date.AddSeconds(1)); + + var firstUuid = UuidTestHelper.New(generator, date); + var firstDate = UuidTestHelper.DecodeDate(firstUuid); + var firstSequence = UuidTestHelper.DecodeSequence(firstUuid); + + Check.That(firstSequence).IsLessOrEqualThan(GetSeedMaxValue()); + + Parallel.For(0, GetSequenceMaxValue() - firstSequence, _ => UuidTestHelper.New(generator, date)); + + var lastUuid = UuidTestHelper.New(generator, date); + var lastDate = UuidTestHelper.DecodeDate(lastUuid); + var lastSequence = UuidTestHelper.DecodeSequence(lastUuid); + Check.That(lastSequence).IsLessOrEqualThan(GetSeedMaxValue()); + Check.That(lastDate).IsEqualTo(firstDate.AddMilliseconds(1)); + } +} diff --git a/Src/UUIDNext.Test/Generator/UuidV7FromSpecificDateGeneratorTest.cs b/Src/UUIDNext.Test/Generator/UuidV7FromSpecificDateGeneratorTest.cs index 367e3ad..d76130f 100644 --- a/Src/UUIDNext.Test/Generator/UuidV7FromSpecificDateGeneratorTest.cs +++ b/Src/UUIDNext.Test/Generator/UuidV7FromSpecificDateGeneratorTest.cs @@ -1,93 +1,49 @@ using System; +using System.Collections.Generic; +using System.Linq; using NFluent; using UUIDNext.Generator; -using UUIDNext.Tools; using Xunit; namespace UUIDNext.Test.Generator; -public class UuidV7FromSpecificDateGeneratorTest +public class UuidV7FromSpecificDateGeneratorTest : UuidFromSpecificDateGeneratorTestBase { - GuidComparer _comparer = new(); + protected override int SequenceBitSize => 12; - [Theory] - [MemberData(nameof(InvalidDates))] - public void CheckThatDateBeforeUnixEpochAreRejected(DateTimeOffset date) - { - UuidV7FromSpecificDateGenerator generator = new(); - Check.ThatCode(() => generator.New(date)).Throws(); - } - - public static object[][] InvalidDates - => [[new DateTimeOffset(new(1900, 1, 1))], [DateTimeOffset.FromUnixTimeMilliseconds(-1)], [DateTimeOffset.MinValue]]; + protected override byte Version => 7; - [Fact] - public void EnsureTimestampAndVersionAreAlwaysCorrect() - { - DateTime date = new(2020, 1, 1, 0,0,0, DateTimeKind.Utc); - UuidV7FromSpecificDateGenerator generator = new(); + protected override object NewGenerator() => new UuidV7FromSpecificDateGenerator(); - int overflowCount = 0; - Guid previousUuid = Uuid.Nil; + protected override Guid NewUuid(object generator) + => ((UuidV7FromSpecificDateGenerator)generator).New(DateTimeOffset.Now); - // we loop 10_000 times to make sure that the sequence of the UUID v7 will overflow at - // least twice since the sequence is stored on 12 bits and its maximum value is thus 4095 - for (int i = 0; i < 10_000; i++) - { - var uuid = generator.New(date); - Check.That(UuidDecoder.GetVersion(uuid)).Is(7); - var uuidDate = UuidTestHelper.DecodeDate(uuid); - Check.That(uuidDate).Is(date); - - if (_comparer.Compare(previousUuid, uuid) > 0) - overflowCount++; - - previousUuid = uuid; - } - - Check.That(overflowCount).IsGreaterOrEqualThan(2).And.IsLessOrEqualThan(5); - } + protected override Guid NewUuid(object generator, DateTimeOffset date) + => ((UuidV7FromSpecificDateGenerator)generator).New(date); [Fact] - public void EnsureThatUuidsFromTheSameTimestampAreIncreasing() + public void TestSequenceSeed() { - DateTimeOffset date = new(new(2020, 1, 1)); - UuidV7FromSpecificDateGenerator generator = new(); - Guid previousUuid = Uuid.Nil; - for (int i = 0; i < 100; i++) - { - var uuid = generator.New(date); - Check.That(_comparer.Compare(previousUuid, uuid)).IsStrictlyLessThan(0); - previousUuid = uuid; - } - } - - [Fact] - public void EnsureThatUuidsFromMultipleTimestampAreIncreasing() - { - DateTimeOffset dateT = new(new(2008, 10, 12)); - DateTimeOffset dateA = new(new(2010, 5, 1)); - DateTimeOffset dateS = new(new(2017, 11, 24)); - - UuidV7FromSpecificDateGenerator generator = new(); - - Guid previousUuidT = Uuid.Nil; - Guid previousUuidA = Uuid.Nil; - Guid previousUuidS = Uuid.Nil; + const int runCount = 1000; + var date = DateTimeOffset.UtcNow; - for (int i = 0; i < 100; i++) + HashSet generatedSequences = []; + for (int i = 0; i < runCount; i++) { - var uuidT = generator.New(dateT); - var uuidA = generator.New(dateA); - var uuidS = generator.New(dateS); + UuidV7FromSpecificDateGenerator generator = new(); + var guid = generator.New(date); - Check.That(_comparer.Compare(previousUuidT, uuidT)).IsStrictlyLessThan(0); - Check.That(_comparer.Compare(previousUuidA, uuidA)).IsStrictlyLessThan(0); - Check.That(_comparer.Compare(previousUuidS, uuidS)).IsStrictlyLessThan(0); + //check that sequence seed leaves room for the next genertions + var sequence = UuidTestHelper.DecodeSequence(guid); + Check.That(sequence).IsLessOrEqualThan(0b_0111_1111_1111); - previousUuidT = uuidT; - previousUuidA = uuidA; - previousUuidS = uuidS; + generatedSequences.Add(sequence); } + + //check that sequence seed is randomized + Check.That(generatedSequences.Count).IsStrictlyGreaterThan(runCount / 2); + + //check that only the highest bit of the sequence seed is always 0 + Check.That(generatedSequences.Any(s => s > 0b_0100_0000_0000)).IsTrue(); } } diff --git a/Src/UUIDNext.Test/Generator/UuidV7GeneratorTest.cs b/Src/UUIDNext.Test/Generator/UuidV7GeneratorTest.cs index 890bc83..1fb8830 100644 --- a/Src/UUIDNext.Test/Generator/UuidV7GeneratorTest.cs +++ b/Src/UUIDNext.Test/Generator/UuidV7GeneratorTest.cs @@ -9,7 +9,7 @@ namespace UUIDNext.Test.Generator { - public class UuidV7GeneratorTest : UuidTimestampGeneratorBaseTest + public class UuidV7GeneratorTest : UuidTimestampWithOverflowGeneratorBaseTest { protected override byte Version => 7; diff --git a/Src/UUIDNext.Test/Generator/UuidV8SqlServerFromSpecificDateGeneratorTest.cs b/Src/UUIDNext.Test/Generator/UuidV8SqlServerFromSpecificDateGeneratorTest.cs new file mode 100644 index 0000000..0880416 --- /dev/null +++ b/Src/UUIDNext.Test/Generator/UuidV8SqlServerFromSpecificDateGeneratorTest.cs @@ -0,0 +1,21 @@ +using System; +using NFluent; +using UUIDNext.Generator; +using Xunit; + +namespace UUIDNext.Test.Generator; + +public class UuidV8SqlServerFromSpecificDateGeneratorTest : UuidFromSpecificDateGeneratorTestBase +{ + protected override byte Version => 8; + + protected override int SequenceBitSize => 14; + + protected override object NewGenerator() => new UuidV8SqlServerFromSpecificDateGenerator(); + + protected override Guid NewUuid(object generator) + => ((UuidV8SqlServerFromSpecificDateGenerator)generator).New(DateTimeOffset.Now); + + protected override Guid NewUuid(object generator, DateTimeOffset date) + => ((UuidV8SqlServerFromSpecificDateGenerator)generator).New(date); +} diff --git a/Src/UUIDNext.Test/Generator/UuidV8SqlServerGeneratorTest.cs b/Src/UUIDNext.Test/Generator/UuidV8SqlServerGeneratorTest.cs index 4e4d98b..4290683 100644 --- a/Src/UUIDNext.Test/Generator/UuidV8SqlServerGeneratorTest.cs +++ b/Src/UUIDNext.Test/Generator/UuidV8SqlServerGeneratorTest.cs @@ -8,7 +8,7 @@ namespace UUIDNext.Test.Generator { - public class UuidV8SqlServerGeneratorTest : UuidTimestampGeneratorBaseTest + public class UuidV8SqlServerGeneratorTest : UuidTimestampWithOverflowGeneratorBaseTest { protected override byte Version => 8; diff --git a/Src/UUIDNext.Test/Tools/UUIDToolkitTest.cs b/Src/UUIDNext.Test/Tools/UUIDToolkitTest.cs index 334fb7a..35510e3 100644 --- a/Src/UUIDNext.Test/Tools/UUIDToolkitTest.cs +++ b/Src/UUIDNext.Test/Tools/UUIDToolkitTest.cs @@ -1,5 +1,4 @@ using System; -using System.Linq; using System.Security.Cryptography; using NFluent; using UUIDNext.Tools; @@ -9,19 +8,20 @@ namespace UUIDNext.Test.Tools; public class UUIDToolkitTest { - GuidComparer _comparer = new(); - [Fact] public void CheckThatUuidV7FromCurrentDateTimeIsCoherentWithNewSequential() { const int testCount = 10_000; + + GuidComparer comparer = new(); + int errorCount = 0; for (int i = 0; i < testCount; i++) { var uuidFromSequential = Uuid.NewSequential(); var uuidFromToolkit = UuidToolkit.CreateUuidV7FromSpecificDate(DateTimeOffset.Now); - if (0 <= _comparer.Compare(uuidFromSequential, uuidFromToolkit)) + if (0 <= comparer.Compare(uuidFromSequential, uuidFromToolkit)) errorCount += 1; } @@ -32,19 +32,55 @@ public void CheckThatUuidV7FromCurrentDateTimeIsCoherentWithNewSequential() [Fact] public void CheckThatUuidV7DateOrderIsRespected() { + GuidComparer comparer = new(); + var uuidBeforeNow = UuidToolkit.CreateUuidV7FromSpecificDate(DateTimeOffset.UtcNow.AddMilliseconds(-1)); var uuidNow = Uuid.NewSequential(); var uuidAfterNow = UuidToolkit.CreateUuidV7FromSpecificDate(DateTimeOffset.UtcNow.AddMilliseconds(1)); - Check.That(_comparer.Compare(uuidBeforeNow, uuidNow)).IsStrictlyNegative(); - Check.That(_comparer.Compare(uuidNow, uuidAfterNow)).IsStrictlyNegative(); + Check.That(comparer.Compare(uuidBeforeNow, uuidNow)).IsStrictlyNegative(); + Check.That(comparer.Compare(uuidNow, uuidAfterNow)).IsStrictlyNegative(); + } + + [Fact] + public void CheckThatUuidV8FromCurrentDateTimeIsCoherentWithNewDatabaseFriendly() + { + const int testCount = 10_000; + + UuidWithTimestampComparer comparer = new(); + + int errorCount = 0; + for (int i = 0; i < testCount; i++) + { + var uuidFromSequential = Uuid.NewDatabaseFriendly(Database.SqlServer); + var uuidFromToolkit = UuidToolkit.CreateSequentialUuidForSqlServerFromSpecificDate(DateTimeOffset.Now); + + if (0 <= comparer.Compare(uuidFromSequential, uuidFromToolkit)) + errorCount += 1; + } + + // We check that 99.5% of the call get forwarded to Uuid.NewDatabaseFriendly(Database.SqlServer) as we can never reach 100% acuracy + Check.That(errorCount * 100.0 / testCount).IsStrictlyLessThan(0.5); + } + + [Fact] + public void CheckThatUuidV8DateOrderIsRespected() + { + UuidWithTimestampComparer comparer = new(); + + var uuidBeforeNow = UuidToolkit.CreateSequentialUuidForSqlServerFromSpecificDate(DateTimeOffset.UtcNow.AddMilliseconds(-1)); + var uuidNow = Uuid.NewSequential(); + var uuidAfterNow = UuidToolkit.CreateSequentialUuidForSqlServerFromSpecificDate(DateTimeOffset.UtcNow.AddMilliseconds(1)); + + Check.That(comparer.Compare(uuidBeforeNow, uuidNow)).IsStrictlyNegative(); + Check.That(comparer.Compare(uuidNow, uuidAfterNow)).IsStrictlyNegative(); } [Fact] public void TestCreateUuidFromBigEndianBytes() { Span bytes = stackalloc byte[16]; - + for (int i = 0; i < 16; i++) bytes[i] = 0; var nilV8Uuid = UuidToolkit.CreateGuidFromBigEndianBytes(bytes); diff --git a/Src/UUIDNext.Test/UuidWithTimestampComparer.cs b/Src/UUIDNext.Test/UuidWithTimestampComparer.cs new file mode 100644 index 0000000..d0c6436 --- /dev/null +++ b/Src/UUIDNext.Test/UuidWithTimestampComparer.cs @@ -0,0 +1,24 @@ +using System; +using System.Collections.Generic; +using UUIDNext.Tools; + +namespace UUIDNext.Test; + +internal class UuidWithTimestampComparer : IComparer +{ + public int Compare(Guid x, Guid y) + { + if (!UuidDecoder.TryDecodeTimestamp(x, out var dateX) || !UuidDecoder.TryDecodeSequence(x, out var sequenceX)) + throw new ArgumentException($"Argument {nameof(x)} is not a timestamped GUID", nameof(x)); + + if (!UuidDecoder.TryDecodeTimestamp(y, out var dateY) || !UuidDecoder.TryDecodeSequence(y, out var sequenceY)) + throw new ArgumentException($"Argument {nameof(y)} is not a timestamped GUID", nameof(y)); + + if (dateX < dateY) return -1; + if (dateX > dateY) return 1; + if (sequenceX < sequenceY) return -1; + if (sequenceX > sequenceY) return 1; + + return 0; + } +} diff --git a/Src/UUIDNext/Generator/SequenceManager.cs b/Src/UUIDNext/Generator/SequenceManager.cs new file mode 100644 index 0000000..f06509f --- /dev/null +++ b/Src/UUIDNext/Generator/SequenceManager.cs @@ -0,0 +1,37 @@ +using System.Buffers.Binary; +using UUIDNext.Tools; + +namespace UUIDNext.Generator; + +internal class SequenceManager(ushort sequenceMaxValue, int cacheSize) +{ + private readonly ushort SequenceMaxValue = sequenceMaxValue; + // Setting the highest bit to 0 mitigate the risk of a sequence overflow (see section 6.2) + private readonly byte HighestByteMask = (byte)(sequenceMaxValue >> 9); + + private readonly BetterCache _sequenceByTimestamp = new(cacheSize); + + public ushort ComputeSequence(long timestamp) + { + if (timestamp < 0) + throw new ArgumentOutOfRangeException(nameof(timestamp), "Dates before 1970-01-01 are not supported"); + + return _sequenceByTimestamp.AddOrUpdate(timestamp, + _ => GetSequenceSeed(), + (_, s) => UpdateSequence(s)); + } + + private ushort UpdateSequence(ushort sequence) + => sequence < SequenceMaxValue ? (ushort)(sequence + 1) + : GetSequenceSeed(); + + private ushort GetSequenceSeed() + { + // following section 6.2 on "Fixed-Length Dedicated Counter Seeding", the initial value of the sequence is randomized + Span buffer = stackalloc byte[2]; + RandomNumberGeneratorPolyfill.Fill(buffer); + // Setting the highest bit to 0 mitigate the risk of a sequence overflow (see section 6.2) + buffer[0] &= HighestByteMask; + return BinaryPrimitives.ReadUInt16BigEndian(buffer); + } +} diff --git a/Src/UUIDNext/Generator/UuidFromSpecificDateGeneratorBase.cs b/Src/UUIDNext/Generator/UuidFromSpecificDateGeneratorBase.cs new file mode 100644 index 0000000..c4b1bad --- /dev/null +++ b/Src/UUIDNext/Generator/UuidFromSpecificDateGeneratorBase.cs @@ -0,0 +1,43 @@ +using System.Buffers.Binary; + +namespace UUIDNext.Generator; + +/// +/// Generate a UUID with a timestamp given an arbitrary date +/// +/// +/// To give the best possible UUID given an arbitrary date we can't rely on UuidV7Generator because it has some +/// mechanism to ensure that every UUID generated is greater then the previous one. +/// This generator try to find the best compromise between these three pillars: +/// * The timestamp part should always represent the date parameter. period. +/// * We should stay as close as possible to the "spirit" of UUID V7 and provide incresing value for a given date +/// * This library should be as lightweight as possible +/// The first point implies that there shouldn't be overflow preventing mechanism like in UuidV7Generator. The second +/// point implies that we should keep track of the monotonicity of multiple timestamps in parallel. The third point +/// implies that the number of timestamps we keep track of should be limited. +/// After some benchmarks, I chose a cache size of 1024 entries. The cache has a memory footprint of only a few +/// dozen KB and has a reasonable worst case performance +/// +internal abstract class UuidFromSpecificDateGeneratorBase(ushort sequenceMaxValue, int cacheSize) +{ + + private readonly SequenceManager _sequenceManager = new(sequenceMaxValue, cacheSize); + + protected abstract Guid CreateUuid(long timestamp, Span sequenceBytes); + + /// + /// Create a UUID version 7 where the timestamp part represent the given date + /// + /// The date that will provide the timestamp part of the UUID + /// A UUID version 7 + public Guid New(DateTimeOffset date) + { + long timestamp = date.ToUnixTimeMilliseconds(); + ushort sequence = _sequenceManager.ComputeSequence(timestamp); + + Span sequenceBytes = stackalloc byte[2]; + BinaryPrimitives.TryWriteUInt16BigEndian(sequenceBytes, sequence); + + return CreateUuid(timestamp, sequenceBytes); + } +} \ No newline at end of file diff --git a/Src/UUIDNext/Generator/UuidV7FromSpecificDateGenerator.cs b/Src/UUIDNext/Generator/UuidV7FromSpecificDateGenerator.cs index c85e4bd..9b1684e 100644 --- a/Src/UUIDNext/Generator/UuidV7FromSpecificDateGenerator.cs +++ b/Src/UUIDNext/Generator/UuidV7FromSpecificDateGenerator.cs @@ -1,65 +1,13 @@ -using System.Buffers.Binary; -using UUIDNext.Tools; +using UUIDNext.Tools; namespace UUIDNext.Generator; /// /// Generate a UUID version 7 given an arbitrary date /// -/// -/// To give the best possible UUID given an arbitrary date we can't rely on UuidV7Generator because it has some -/// mechanism to ensure that every UUID generated is greater then the previous one. -/// This generator try to find the best compromise between these three pillars: -/// * The timestamp part should always represent the date parameter. period. -/// * We should stay as close as possible to the "spirit" of UUID V7 and provide incresing value for a given date -/// * This library should be as lightweight as possible -/// The first point implies that there shouldn't be overflow preventing mechanism like in UuidV7Generator. The second -/// point implies that we should keep track of the monotonicity of multiple timestamps in parallel. The third point -/// implies that the number of timestamps we keep track of should be limited. -/// After some benchmarks, I chose a cache size of 256 entries. The cache has a memory footprint of only a few -/// dozen KB and has a reasonable worst case performance -/// internal class UuidV7FromSpecificDateGenerator(int cacheSize = 1024) + : UuidFromSpecificDateGeneratorBase(sequenceMaxValue: 0b1111_1111_1111, cacheSize: cacheSize) { - private const ushort SequenceMaxValue = 0b1111_1111_1111; - - private readonly BetterCache _sequenceByTimestamp = new(cacheSize); - - /// - /// Create a UUID version 7 where the timestamp part represent the given date - /// - /// The date that will provide the timestamp par of the UUID - /// A UUID version 7 - public Guid New(DateTimeOffset date) - { - long timestamp = date.ToUnixTimeMilliseconds(); - if (timestamp < 0) - throw new ArgumentOutOfRangeException(nameof(date), "Dates before 1970-01-01 are not supported"); - - ushort sequence = ComputeSequence(timestamp); - - Span sequenceBytes = stackalloc byte[2]; - BinaryPrimitives.TryWriteUInt16BigEndian(sequenceBytes, sequence); - - return UuidToolkit.CreateUuidV7(timestamp, sequenceBytes); - } - - private ushort ComputeSequence(long timestamp) - { - return _sequenceByTimestamp.AddOrUpdate(timestamp, GetSequenceSeed, UpdateSequence); - - static ushort UpdateSequence(long _, ushort sequence) - => sequence < SequenceMaxValue ? (ushort)(sequence + 1) - : GetSequenceSeed(default); - - static ushort GetSequenceSeed(long _) - { - // following section 6.2 on "Fixed-Length Dedicated Counter Seeding", the initial value of the sequence is randomized - Span buffer = stackalloc byte[2]; - RandomNumberGeneratorPolyfill.Fill(buffer); - // Setting the highest bit to 0 mitigate the risk of a sequence overflow (see section 6.2) - buffer[0] &= 0b0000_0111; - return BinaryPrimitives.ReadUInt16BigEndian(buffer); - } - } + protected override Guid CreateUuid(long timestamp, Span sequenceBytes) + => UuidToolkit.CreateUuidV7(timestamp, sequenceBytes); } diff --git a/Src/UUIDNext/Generator/UuidV8SqlServerFromSpecificDateGenerator.cs b/Src/UUIDNext/Generator/UuidV8SqlServerFromSpecificDateGenerator.cs new file mode 100644 index 0000000..f730109 --- /dev/null +++ b/Src/UUIDNext/Generator/UuidV8SqlServerFromSpecificDateGenerator.cs @@ -0,0 +1,14 @@ +using UUIDNext.Tools; + +namespace UUIDNext.Generator; + +/// +/// Generate a UUID version 8 optimised for SQL Server given an arbitrary date +/// +internal class UuidV8SqlServerFromSpecificDateGenerator(int cacheSize = 1024) + : UuidFromSpecificDateGeneratorBase(sequenceMaxValue: 0b11_1111_1111_1111, cacheSize: cacheSize) + +{ + protected override Guid CreateUuid(long timestamp, Span sequenceBytes) + => UuidToolkit.CreateSequentialUuidForSqlServer(timestamp, sequenceBytes); +} diff --git a/Src/UUIDNext/Generator/UuidV8SqlServerGenerator.cs b/Src/UUIDNext/Generator/UuidV8SqlServerGenerator.cs index cb74d92..f4d10a0 100644 --- a/Src/UUIDNext/Generator/UuidV8SqlServerGenerator.cs +++ b/Src/UUIDNext/Generator/UuidV8SqlServerGenerator.cs @@ -34,15 +34,10 @@ 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 */ var (timestamp, sequence) = _monotonicityHandler.GetTimestampAndSequence(date); + + Span sequenceBytes = stackalloc byte[2]; + BinaryPrimitives.TryWriteUInt16BigEndian(sequenceBytes, sequence); - Span bytes = stackalloc byte[16]; - // We only use 48 bits of the timestamp so we can write it on 64 bits and then - // erase the 16 most significant bits with the sequence to save some buffer allocation and copy - BinaryPrimitives.TryWriteInt64BigEndian(bytes.Slice(8, 8), timestamp); - BinaryPrimitives.TryWriteUInt16BigEndian(bytes.Slice(8, 2), sequence); - - RandomNumberGeneratorPolyfill.Fill(bytes.Slice(0, 8)); - - return UuidToolkit.CreateGuidFromBigEndianBytes(bytes, 8); + return UuidToolkit.CreateSequentialUuidForSqlServer(timestamp, sequenceBytes); } } diff --git a/Src/UUIDNext/Tools/UuidToolkit.cs b/Src/UUIDNext/Tools/UuidToolkit.cs index 6a64df7..7108e4c 100644 --- a/Src/UUIDNext/Tools/UuidToolkit.cs +++ b/Src/UUIDNext/Tools/UuidToolkit.cs @@ -13,7 +13,10 @@ public static class UuidToolkit // UuidV7FromSpecificDateGenerator has a footprint of ~50KB so we decalre it as Lazy so that it // only impacts the consumers of the feature private static readonly Lazy _lazyV7Generator = new(() => new()); - private static UuidV7FromSpecificDateGenerator V7Generator => _lazyV7Generator.Value; + + // UuidV8SqlServerFromSpecificDateGenerator has a footprint of ~50KB so we decalre it as Lazy + // so that it only impacts the consumers of the feature + private static readonly Lazy _lazyV8Generator = new(() => new()); /// /// Create new UUID version 8 with the provided bytes with the variant and version bits set @@ -184,15 +187,79 @@ public static Guid CreateUuidV7FromSpecificDate(DateTimeOffset date) // wrong method so we're forwarding the call to Uuid.NewSequential to keep consistency accross // different calls + if (IsCloseToNow(date)) + return Uuid.NewSequential(); + + return _lazyV7Generator.Value.New(date); + } + + internal static Guid CreateSequentialUuidForSqlServer(long timestamp, Span followingBytes) + { + if (followingBytes.Length > 10) + throw new ArgumentException($"argument {nameof(followingBytes)} should have a size of 10 bytes or less", nameof(followingBytes)); + + Span buffer = stackalloc byte[16]; + + // We only use 48 bits of the timestamp so we can write it on 64 bits and then + // erase the 16 most significant bits with the sequence to save some buffer allocation and copy + BinaryPrimitives.TryWriteInt64BigEndian(buffer.Slice(8, 8), timestamp); + + // write the data provided by the caller + int randomOffset = 0; + Span sequenceBytes = buffer.Slice(8, 2); + if (followingBytes.Length == 0) + RandomNumberGeneratorPolyfill.Fill(buffer.Slice(8, 2)); + if (followingBytes.Length == 1) + { + sequenceBytes[0] = followingBytes[0]; + RandomNumberGeneratorPolyfill.Fill(sequenceBytes.Slice(1)); + } + else + { + randomOffset = followingBytes.Length - 2; + + //write the sequence + followingBytes.Slice(0, 2).CopyTo(sequenceBytes); + + Span randBytes = buffer.Slice(0, randomOffset); + followingBytes.Slice(2).CopyTo(randBytes); + } + + RandomNumberGeneratorPolyfill.Fill(buffer.Slice(randomOffset, 8 - randomOffset)); + + return CreateGuidFromBigEndianBytes(buffer, 8); + } + + /// + /// Create a new sequential UUID Optimised for SQL Server with the given date as timestamp + /// + public static Guid CreateSequentialUuidForSqlServerFromSpecificDate(DateTimeOffset date) + { + // if the date argument is equal or close enough to DateTimeOffset.UtcNow we consider that + // the API consumer just wanted a sequential UUID Optimised for SQL Server without specifying + // the date and called the wrong method so we're forwarding the call to Uuid.NewSequential + // to keep consistency accross different calls + + if (IsCloseToNow(date)) + return Uuid.NewDatabaseFriendly(Database.SqlServer); + + return _lazyV8Generator.Value.New(date); + } + + /// + /// returns true if the date is close to DateTimeOffset.UtcNow, false otherwise + /// + private static bool IsCloseToNow(DateTimeOffset date) + { const long tickThreshold = 10; // 1 µs var now = DateTimeOffset.UtcNow; if (date.ToUnixTimeMilliseconds() == now.ToUnixTimeMilliseconds()) - return Uuid.NewSequential(); + return true; if (Math.Abs(date.UtcTicks - now.UtcTicks) < tickThreshold) - return Uuid.NewSequential(); + return true; - return V7Generator.New(date); + return false; } }