From 3665abaab8738ac8dc5df4aaaaf746ae017bc0b1 Mon Sep 17 00:00:00 2001 From: just Date: Fri, 1 Aug 2025 22:21:42 +0400 Subject: [PATCH] dotnet 9 and sequential id --- .gitea/workflows/publish-nuget.yaml | 2 +- .gitea/workflows/test-dotnet.yaml | 8 +- Core.Tests/Base32Conversions/Decode.cs | 4 +- Core.Tests/Base32Conversions/Encode.cs | 9 +- Core.Tests/Core.Tests.csproj | 10 +- Core.Tests/GuidV8Tests/NewGuid.cs | 42 +++--- Core.Tests/SeqIdTests/NextId.cs | 140 ++++++++++++++++++++ Core/Base32.cs | 34 +++-- Core/Core.csproj | 4 +- Core/Extensions/SystemIOStreamExtensions.cs | 96 ++++++++++++-- Core/GuidV8.cs | 8 +- Core/RngEntropy.cs | 16 +++ Core/SeqId.cs | 121 +++++++++++++++++ LICENSE | 2 +- 14 files changed, 428 insertions(+), 68 deletions(-) create mode 100644 Core.Tests/SeqIdTests/NextId.cs create mode 100644 Core/RngEntropy.cs create mode 100644 Core/SeqId.cs diff --git a/.gitea/workflows/publish-nuget.yaml b/.gitea/workflows/publish-nuget.yaml index c41d4f2..3373ad2 100644 --- a/.gitea/workflows/publish-nuget.yaml +++ b/.gitea/workflows/publish-nuget.yaml @@ -16,7 +16,7 @@ jobs: - name: Setup .NET uses: https://github.com/actions/setup-dotnet@v3 with: - dotnet-version: 8.x + dotnet-version: 9.x - name: Restore dependencies run: dotnet restore Core/Core.csproj diff --git a/.gitea/workflows/test-dotnet.yaml b/.gitea/workflows/test-dotnet.yaml index 3fab52d..3dfb5d8 100644 --- a/.gitea/workflows/test-dotnet.yaml +++ b/.gitea/workflows/test-dotnet.yaml @@ -21,7 +21,7 @@ jobs: - name: Setup .NET uses: https://github.com/actions/setup-dotnet@v3 with: - dotnet-version: 8.x + dotnet-version: 9.x - name: Restore dependencies run: dotnet restore @@ -30,12 +30,12 @@ jobs: run: dotnet build --no-restore - name: Test - run: dotnet test --no-build --verbosity normal --logger trx --results-directory "TestResults-8.x" + run: dotnet test --no-build --verbosity normal --logger trx --results-directory "TestResults-9.x" - name: Upload dotnet test results uses: actions/upload-artifact@v3 with: - name: dotnet-results-8.x - path: TestResults-8.x + name: dotnet-results-9.x + path: TestResults-9.x if: ${{ always() }} retention-days: 30 diff --git a/Core.Tests/Base32Conversions/Decode.cs b/Core.Tests/Base32Conversions/Decode.cs index f629f55..21ce83a 100644 --- a/Core.Tests/Base32Conversions/Decode.cs +++ b/Core.Tests/Base32Conversions/Decode.cs @@ -36,7 +36,7 @@ public class Decode var actualBytesArray = Base32.Decode(str); actualBytesArray.Should().Equal(expected); } - + [Theory] [InlineData("ZFXJMF5N====", new byte[] { 0b11001001, 0b01101110, 0b10010110, 0b00010111, 0b10101101, })] [InlineData("CPIKTMY=====", new byte[] { 0b00010011, 0b11010000, 0b10101001, 0b10110011, })] @@ -72,7 +72,7 @@ public class Decode [Theory] [InlineData(null)] [InlineData("")] - public void WhenCalledWithNullString_ShouldReturnEmptyArray(string testString) + public void WhenCalledWithNullString_ShouldReturnEmptyArray(string? testString) { Base32.Decode(testString).Should().BeEmpty(); } diff --git a/Core.Tests/Base32Conversions/Encode.cs b/Core.Tests/Base32Conversions/Encode.cs index d4158af..e3e1751 100644 --- a/Core.Tests/Base32Conversions/Encode.cs +++ b/Core.Tests/Base32Conversions/Encode.cs @@ -23,6 +23,9 @@ public class Encode [InlineData("GSHXGB5ORKNDSFLSU2YWALI=")] [InlineData("TMFSC64ZZNPQSNGCFIODS7TR")] [InlineData("ZTDUBU4QZFFMDJKBII334EIB")] + [InlineData("2IO2HTALCXZWCBD2AAAAAAAAAAAA====")] + [InlineData("WXYEOQUZULMCY6ZQTDOLTRUZZMKQ====")] + [InlineData("FG4M3ZQM3TVWDMBUP5L7N7V3JS7KBM2E")] public void WhenDecodedFromString_ShouldBeEncodedToTheSameString(string testString) { var resultBytes = Base32.Decode(testString); @@ -34,6 +37,8 @@ public class Encode [Theory] [InlineData("FG4M3ZQM3TVWDMBUP5L7N7V3JS7KBM2E", new byte[] { 0x29, 0xb8, 0xcd, 0xe6, 0x0c, 0xdc, 0xeb, 0x61, 0xb0, 0x34, 0x7f, 0x57, 0xf6, 0xfe, 0xbb, 0x4c, 0xbe, 0xa0, 0xb3, 0x44, })] [InlineData("WXYEOQUZULMCY6ZQTDOLTRUZZMKQ====", new byte[] { 0xb5, 0xf0, 0x47, 0x42, 0x99, 0xa2, 0xd8, 0x2c, 0x7b, 0x30, 0x98, 0xdc, 0xb9, 0xc6, 0x99, 0xcb, 0x15, })] + [InlineData("2IO2HTALCXZWCBD2AAAAAAAAAAAA====", new byte[] { 0xd2, 0x1d, 0xa3, 0xcc, 0x0b, 0x15, 0xf3, 0x61, 0x04, 0x7a, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, })] + [InlineData("2IO2HTALCXZWCBD2AAAAAAAA", new byte[] { 0xd2, 0x1d, 0xa3, 0xcc, 0x0b, 0x15, 0xf3, 0x61, 0x04, 0x7a, 0x00, 0x00, 0x00, 0x00, 0x00, })] [InlineData("2IO2HTALCXZWCBD2", new byte[] { 0xd2, 0x1d, 0xa3, 0xcc, 0x0b, 0x15, 0xf3, 0x61, 0x04, 0x7a, })] [InlineData("ZFXJMF5N", new byte[] { 0b11001001, 0b01101110, 0b10010110, 0b00010111, 0b10101101, })] [InlineData("CPIKTMY=", new byte[] { 0b00010011, 0b11010000, 0b10101001, 0b10110011, })] @@ -48,7 +53,7 @@ public class Encode [Theory] [InlineData(new byte[] { })] [InlineData(null)] - public void WhenCalledWithEmptyByteArray_ShouldReturnEmptyString(byte[] testArray) + public void WhenCalledWithEmptyByteArray_ShouldReturnEmptyString(byte[]? testArray) { var actualBase32 = Base32.Encode(testArray); actualBase32.Should().Be(string.Empty); @@ -57,7 +62,7 @@ public class Encode [Theory] [InlineData(new byte[] { })] [InlineData(null)] - public void WhenCalledWithEmptyByteArray_ShouldReturnZeroAndNotChangeOutput(byte[] testArray) + public void WhenCalledWithEmptyByteArray_ShouldReturnZeroAndNotChangeOutput(byte[]? testArray) { char[] output = ['1', '2', '3', '4']; diff --git a/Core.Tests/Core.Tests.csproj b/Core.Tests/Core.Tests.csproj index c42aacf..dc2a347 100644 --- a/Core.Tests/Core.Tests.csproj +++ b/Core.Tests/Core.Tests.csproj @@ -1,7 +1,7 @@ - net8.0 + net9.0 enable enable @@ -15,13 +15,13 @@ - - - + + + runtime; build; native; contentfiles; analyzers; buildtransitive all - + runtime; build; native; contentfiles; analyzers; buildtransitive all diff --git a/Core.Tests/GuidV8Tests/NewGuid.cs b/Core.Tests/GuidV8Tests/NewGuid.cs index fd4a5c7..62a8976 100644 --- a/Core.Tests/GuidV8Tests/NewGuid.cs +++ b/Core.Tests/GuidV8Tests/NewGuid.cs @@ -3,15 +3,15 @@ namespace Just.Core.Tests.GuidV8Tests; public class NewGuid { [Theory] - [InlineData(GuidV8Entropy.Weak, + [InlineData(RngEntropy.Weak, -25000000, -10000000, -5000000, -2000000, -1000000, -500000, -250000, -100000, -25000, -10000, -5000, -2500, -1000, -500, -499, -497, -450, -300, -200, -50, -1, 0, 1, 2, 5, 10, 20, 50, 100, 200, 350, 500, 1000, 2500, 5000, 10000, 25000, 100000, 250000, 500000, 1000000, 2000000, 5000000, 10000000, 100000000, 250000000)] - [InlineData(GuidV8Entropy.Strong, + [InlineData(RngEntropy.Strong, -25000000, -10000000, -5000000, -2000000, -1000000, -500000, -250000, -100000, -25000, -10000, -5000, -2500, -1000, -500, -499, -497, -450, -300, -200, -50, -1, 0, 1, 2, 5, 10, 20, 50, 100, 200, 350, 500, 1000, 2500, 5000, 10000, 25000, 100000, 250000, 500000, 1000000, 2000000, 5000000, 10000000, 100000000, 250000000)] - [InlineData(GuidV8Entropy.Weak, + [InlineData(RngEntropy.Weak, -9863, -9740, -9214, -8878, -8674, -8652, -8640, -8565, -8518, -8449, -8390, -8193, -8108, -7808, -7501, -7203, -7133, -7020, -6983, -6855, -6576, -6162, -5630, -5505, -5472, -5382, -4706, -4680, -4509, -4454, @@ -22,7 +22,7 @@ public class NewGuid 4163, 4198, 4366, 4662, 4746, 4879, 5467, 5601, 5912, 5979, 6128, 6277, 6323, 6437, 6699, 6853, 7556, 7776, 7795, 8099, 8336, 8592, 8682, 8683, 8818, 8904, 9375, 9466, 9551, 9708)] - [InlineData(GuidV8Entropy.Strong, + [InlineData(RngEntropy.Strong, -9863, -9740, -9214, -8878, -8674, -8652, -8640, -8565, -8518, -8449, -8390, -8193, -8108, -7808, -7501, -7203, -7133, -7020, -6983, -6855, -6576, -6162, -5630, -5505, -5472, -5382, -4706, -4680, -4509, -4454, @@ -33,7 +33,7 @@ public class NewGuid 4163, 4198, 4366, 4662, 4746, 4879, 5467, 5601, 5912, 5979, 6128, 6277, 6323, 6437, 6699, 6853, 7556, 7776, 7795, 8099, 8336, 8592, 8682, 8683, 8818, 8904, 9375, 9466, 9551, 9708)] - [InlineData(GuidV8Entropy.Weak, + [InlineData(RngEntropy.Weak, 5635912, 6673780, 17277183, 17512959, 19098799, 21672621, 30581958, 30824885, 31874213, 35192781, 36337094, 37752116, 38387215, 39154682, 40525427, 52288093, 55218356, 59065156, 65231785, 75430932, 76289058, 79078058, 85770685, 85925884, 94726743, 94864163, 95781967, 96150006, 96482085, 102570414, @@ -44,7 +44,7 @@ public class NewGuid 292563035, 299285016, 303834917, 310357836, 315078337, 316367236, 318311758, 318873972, 319675272, 321784171, 324204294, 327667283, 330287252, 338438172, 349863360, 360777768, 366398711, 368637150, 368776734, 371900343, 379094084, 379818879, 381448333, 381814627, 382393101, 382483709, 385600870, 389455134, 396115960, 399364095)] - [InlineData(GuidV8Entropy.Strong, + [InlineData(RngEntropy.Strong, 5635912, 6673780, 17277183, 17512959, 19098799, 21672621, 30581958, 30824885, 31874213, 35192781, 36337094, 37752116, 38387215, 39154682, 40525427, 52288093, 55218356, 59065156, 65231785, 75430932, 76289058, 79078058, 85770685, 85925884, 94726743, 94864163, 95781967, 96150006, 96482085, 102570414, @@ -55,7 +55,7 @@ public class NewGuid 292563035, 299285016, 303834917, 310357836, 315078337, 316367236, 318311758, 318873972, 319675272, 321784171, 324204294, 327667283, 330287252, 338438172, 349863360, 360777768, 366398711, 368637150, 368776734, 371900343, 379094084, 379818879, 381448333, 381814627, 382393101, 382483709, 385600870, 389455134, 396115960, 399364095)] - public void Guids_Differing_By_Minutes_Should_Be_Sortable(GuidV8Entropy entropy, params int[] seconds) + public void Guids_Differing_By_Minutes_Should_Be_Sortable(RngEntropy entropy, params int[] seconds) { var rng = new Random(25); var referenceTime = new DateTime(2024, 05, 17, 15, 36, 13, 771, DateTimeKind.Utc); @@ -77,15 +77,15 @@ public class NewGuid } [Theory] - [InlineData(GuidV8Entropy.Weak, + [InlineData(RngEntropy.Weak, -250000000, -25000000, -10000000, -5000000, -2000000, -1000000, -500000, -250000, -100000, -25000, -10000, -5000, -2500, -1000, -500, -499, -497, -450, -300, -200, -50, -1, 0, 1, 2, 5, 10, 20, 50, 100, 200, 350, 500, 1000, 2500, 5000, 10000, 25000, 100000, 250000, 500000, 1000000, 2000000, 5000000, 10000000, 100000000, 2100000000)] - [InlineData(GuidV8Entropy.Strong, + [InlineData(RngEntropy.Strong, -250000000, -25000000, -10000000, -5000000, -2000000, -1000000, -500000, -250000, -100000, -25000, -10000, -5000, -2500, -1000, -500, -499, -497, -450, -300, -200, -50, -1, 0, 1, 2, 5, 10, 20, 50, 100, 200, 350, 500, 1000, 2500, 5000, 10000, 25000, 100000, 250000, 500000, 1000000, 2000000, 5000000, 10000000, 100000000, 2100000000)] - [InlineData(GuidV8Entropy.Weak, + [InlineData(RngEntropy.Weak, -9863, -9740, -9214, -8878, -8674, -8652, -8640, -8565, -8518, -8449, -8390, -8193, -8108, -7808, -7501, -7203, -7133, -7020, -6983, -6855, -6576, -6162, -5630, -5505, -5472, -5382, -4706, -4680, -4509, -4454, @@ -96,7 +96,7 @@ public class NewGuid 4163, 4198, 4366, 4662, 4746, 4879, 5467, 5601, 5912, 5979, 6128, 6277, 6323, 6437, 6699, 6853, 7556, 7776, 7795, 8099, 8336, 8592, 8682, 8683, 8818, 8904, 9375, 9466, 9551, 9708)] - [InlineData(GuidV8Entropy.Strong, + [InlineData(RngEntropy.Strong, -9863, -9740, -9214, -8878, -8674, -8652, -8640, -8565, -8518, -8449, -8390, -8193, -8108, -7808, -7501, -7203, -7133, -7020, -6983, -6855, -6576, -6162, -5630, -5505, -5472, -5382, -4706, -4680, -4509, -4454, @@ -107,7 +107,7 @@ public class NewGuid 4163, 4198, 4366, 4662, 4746, 4879, 5467, 5601, 5912, 5979, 6128, 6277, 6323, 6437, 6699, 6853, 7556, 7776, 7795, 8099, 8336, 8592, 8682, 8683, 8818, 8904, 9375, 9466, 9551, 9708)] - [InlineData(GuidV8Entropy.Weak, + [InlineData(RngEntropy.Weak, 6629058, 24114993, 40561510, 46245969, 46876997, 48747281, 80489854, 110237218, 117445694, 118974860, 135132579, 141760591, 149114066, 158322437, 159065333, 164925904, 173848639, 175086337, 175704556, 176335514, 200302773, 207133553, 230088723, 234521706, 239587338, 263755571, 264571928, 290118284, 292346548, 319322378, @@ -118,7 +118,7 @@ public class NewGuid 749379956, 757561933, 758668417, 761735205, 770349479, 797570403, 805896481, 809050934, 821655964, 821980469, 830824227, 840429528, 851772315, 859717719, 859763860, 867675943, 912124563, 914880620, 923914294, 930298008, 932610035, 937468680, 945565998, 949277691, 949397209, 951283050, 953249971, 953953188, 976210158, 982233484, 982233485)] - [InlineData(GuidV8Entropy.Strong, + [InlineData(RngEntropy.Strong, 6629058, 24114993, 40561510, 46245969, 46876997, 48747281, 80489854, 110237218, 117445694, 118974860, 135132579, 141760591, 149114066, 158322437, 159065333, 164925904, 173848639, 175086337, 175704556, 176335514, 200302773, 207133553, 230088723, 234521706, 239587338, 263755571, 264571928, 290118284, 292346548, 319322378, @@ -129,7 +129,7 @@ public class NewGuid 749379956, 757561933, 758668417, 761735205, 770349479, 797570403, 805896481, 809050934, 821655964, 821980469, 830824227, 840429528, 851772315, 859717719, 859763860, 867675943, 912124563, 914880620, 923914294, 930298008, 932610035, 937468680, 945565998, 949277691, 949397209, 951283050, 953249971, 953953188, 976210158, 982233484, 982233485)] - public void Guids_Differing_By_Seconds_Should_Be_Sortable(GuidV8Entropy entropy, params int[] seconds) + public void Guids_Differing_By_Seconds_Should_Be_Sortable(RngEntropy entropy, params int[] seconds) { var rng = new Random(25); var referenceTime = new DateTime(2024, 05, 17, 15, 36, 13, 771, DateTimeKind.Utc); @@ -151,15 +151,15 @@ public class NewGuid } [Theory] - [InlineData(GuidV8Entropy.Weak, + [InlineData(RngEntropy.Weak, -250000000, -25000000, -10000000, -5000000, -2000000, -1000000, -500000, -250000, -100000, -25000, -10000, -5000, -2500, -1000, -500, -499, -497, -450, -300, -200, -50, -1, 0, 1, 2, 5, 10, 20, 50, 100, 200, 350, 500, 1000, 2500, 5000, 10000, 25000, 100000, 250000, 500000, 1000000, 2000000, 5000000, 10000000, 100000000, 2100000000)] - [InlineData(GuidV8Entropy.Strong, + [InlineData(RngEntropy.Strong, -250000000, -25000000, -10000000, -5000000, -2000000, -1000000, -500000, -250000, -100000, -25000, -10000, -5000, -2500, -1000, -500, -499, -497, -450, -300, -200, -50, -1, 0, 1, 2, 5, 10, 20, 50, 100, 200, 350, 500, 1000, 2500, 5000, 10000, 25000, 100000, 250000, 500000, 1000000, 2000000, 5000000, 10000000, 100000000, 2100000000)] - [InlineData(GuidV8Entropy.Weak, + [InlineData(RngEntropy.Weak, -9863, -9740, -9214, -8878, -8674, -8652, -8640, -8565, -8518, -8449, -8390, -8193, -8108, -7808, -7501, -7203, -7133, -7020, -6983, -6855, -6576, -6162, -5630, -5505, -5472, -5382, -4706, -4680, -4509, -4454, @@ -170,7 +170,7 @@ public class NewGuid 4163, 4198, 4366, 4662, 4746, 4879, 5467, 5601, 5912, 5979, 6128, 6277, 6323, 6437, 6699, 6853, 7556, 7776, 7795, 8099, 8336, 8592, 8682, 8683, 8818, 8904, 9375, 9466, 9551, 9708)] - [InlineData(GuidV8Entropy.Strong, + [InlineData(RngEntropy.Strong, -9863, -9740, -9214, -8878, -8674, -8652, -8640, -8565, -8518, -8449, -8390, -8193, -8108, -7808, -7501, -7203, -7133, -7020, -6983, -6855, -6576, -6162, -5630, -5505, -5472, -5382, -4706, -4680, -4509, -4454, @@ -181,7 +181,7 @@ public class NewGuid 4163, 4198, 4366, 4662, 4746, 4879, 5467, 5601, 5912, 5979, 6128, 6277, 6323, 6437, 6699, 6853, 7556, 7776, 7795, 8099, 8336, 8592, 8682, 8683, 8818, 8904, 9375, 9466, 9551, 9708)] - [InlineData(GuidV8Entropy.Weak, + [InlineData(RngEntropy.Weak, 6629058, 24114993, 40561510, 46245969, 46876997, 48747281, 80489854, 110237218, 117445694, 118974860, 135132579, 141760591, 149114066, 158322437, 159065333, 164925904, 173848639, 175086337, 175704556, 176335514, 200302773, 207133553, 230088723, 234521706, 239587338, 263755571, 264571928, 290118284, 292346548, 319322378, @@ -192,7 +192,7 @@ public class NewGuid 749379956, 757561933, 758668417, 761735205, 770349479, 797570403, 805896481, 809050934, 821655964, 821980469, 830824227, 840429528, 851772315, 859717719, 859763860, 867675943, 912124563, 914880620, 923914294, 930298008, 932610035, 937468680, 945565998, 949277691, 949397209, 951283050, 953249971, 953953188, 976210158, 982233484, 982233485)] - [InlineData(GuidV8Entropy.Strong, + [InlineData(RngEntropy.Strong, 6629058, 24114993, 40561510, 46245969, 46876997, 48747281, 80489854, 110237218, 117445694, 118974860, 135132579, 141760591, 149114066, 158322437, 159065333, 164925904, 173848639, 175086337, 175704556, 176335514, 200302773, 207133553, 230088723, 234521706, 239587338, 263755571, 264571928, 290118284, 292346548, 319322378, @@ -203,7 +203,7 @@ public class NewGuid 749379956, 757561933, 758668417, 761735205, 770349479, 797570403, 805896481, 809050934, 821655964, 821980469, 830824227, 840429528, 851772315, 859717719, 859763860, 867675943, 912124563, 914880620, 923914294, 930298008, 932610035, 937468680, 945565998, 949277691, 949397209, 951283050, 953249971, 953953188, 976210158, 982233484, 982233485)] - public void Guids_Differing_By_Milliseconds_Should_Be_Sortable(GuidV8Entropy entropy, params int[] seconds) + public void Guids_Differing_By_Milliseconds_Should_Be_Sortable(RngEntropy entropy, params int[] seconds) { var rng = new Random(26); var referenceTime = new DateTime(2024, 05, 17, 15, 36, 13, 771, DateTimeKind.Utc); diff --git a/Core.Tests/SeqIdTests/NextId.cs b/Core.Tests/SeqIdTests/NextId.cs new file mode 100644 index 0000000..9892abe --- /dev/null +++ b/Core.Tests/SeqIdTests/NextId.cs @@ -0,0 +1,140 @@ +namespace Just.Core.Tests.SeqIdTests; + +public class NextId +{ + private static readonly DateTime TestEpoch = new(2025, 1, 1, 0, 0, 0, DateTimeKind.Utc); + private readonly SeqId _seqId = new(TestEpoch); + + private const int TimestampShift = 22; // 63 - 41 = 22 + private const long TimestampMask = 0x1FFFFFFFFFF; // 41 bits mask + private const int SeqShift = 14; // TimestampShift - 8 = 14 + private const long SeqMask = 0xFF; // 8 bits mask + private const long RandMask = 0x3FFF; // 14 bits mask (since 2^14 = 16384) + + [Fact] + public void NextId_ShouldHaveCorrectBitStructure() + { + // Arrange + var time = TestEpoch.AddMilliseconds(500); + + // Act + long id = _seqId.Next(time); + + // Assert + long timestampPart = (id >> TimestampShift) & TimestampMask; + long sequencePart = (id >> SeqShift) & SeqMask; + long randomPart = id & RandMask; + + timestampPart.Should().Be(500); + sequencePart.Should().Be(0); + randomPart.Should().BeInRange(0, RandMask); + } + + [Fact] + public void NextId_ShouldIncrementSequenceForSameTimestamp() + { + // Arrange + var time = TestEpoch.AddMilliseconds(100); + + // Act + long id1 = _seqId.Next(time); + long id2 = _seqId.Next(time); + + // Assert + long sequence1 = (id1 >> SeqShift) & SeqMask; + long sequence2 = (id2 >> SeqShift) & SeqMask; + + sequence2.Should().Be(sequence1 + 1); + } + + [Fact] + public void NextId_ShouldResetSequenceForNewTimestamp() + { + // Arrange + var time1 = TestEpoch.AddMilliseconds(100); + var time2 = time1.AddMilliseconds(1); + + // Act + _ = _seqId.Next(time1); // Sequence increments to 0 + _ = _seqId.Next(time1); // Sequence increments to 1 + long id = _seqId.Next(time2); // Should reset to 0 + + // Assert + long sequence = (id >> SeqShift) & SeqMask; + sequence.Should().Be(0); + } + + [Fact] + public void NextId_ShouldThrowWhenTimestampDecreases() + { + // Arrange + var time1 = TestEpoch.AddMilliseconds(200); + var time2 = TestEpoch.AddMilliseconds(100); + + // Act & Assert + _seqId.Next(time1); // First call sets last timestamp + Action act = () => _seqId.Next(time2); + act.Should().Throw() + .WithMessage("Refused to create new SeqId. Last timestamp is in the future."); + } + + [Fact] + public void NextId_ShouldThrowWhenSequenceExhausted() + { + // Arrange + var time = TestEpoch.AddMilliseconds(200); + + // Act & Assert + for (int i = 0; i < 255; i++) + { + _seqId.Next(time); // Exhauste sequence + } + Action act = () => _seqId.Next(time); + act.Should().Throw() + .WithMessage("Refused to create new SeqId. Sequence exhausted."); + } + + [Fact] + public void NextId_WithStrongEntropy_ShouldSetLower14Bits() + { + // Arrange + var time = TestEpoch.AddMilliseconds(300); + + // Act + long id = _seqId.Next(time, RngEntropy.Strong); + + // Assert + long randomPart = id & RandMask; + randomPart.Should().BeInRange(0, RandMask); + } + + [Fact] + public void NextId_WithWeakEntropy_ShouldSetLower14Bits() + { + // Arrange + var time = TestEpoch.AddMilliseconds(400); + + // Act + long id = _seqId.Next(time, RngEntropy.Weak); + + // Assert + long randomPart = id & RandMask; + randomPart.Should().BeInRange(0, RandMask); + } + + [Fact] + public void DefaultInstance_NextId_ShouldUseDefaultEpoch() + { + // Arrange + var now = DateTime.UtcNow; + var defaultEpoch = new DateTime(2025, 1, 1, 0, 0, 0, DateTimeKind.Utc); + long expectedTimestamp = (long)(now - defaultEpoch).TotalMilliseconds; + + // Act + long id = SeqId.NextId(); + + // Assert + long timestampPart = (id >> TimestampShift) & TimestampMask; + timestampPart.Should().BeCloseTo(expectedTimestamp & TimestampMask, 1); // Mask handles overflow + } +} \ No newline at end of file diff --git a/Core/Base32.cs b/Core/Base32.cs index d32de7f..2a09384 100644 --- a/Core/Base32.cs +++ b/Core/Base32.cs @@ -4,23 +4,23 @@ public static class Base32 { public const string Alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567"; public const char Padding = '='; + public const int MaxBytesStack = 250; [Pure] public static string Encode(ReadOnlySpan input) { - if (input.Length == 0) - { - return string.Empty; - } + if (input.IsEmpty) return string.Empty; int outLength = 8 * ((input.Length + 4) / 5); - Span output = stackalloc char[outLength]; + Span output = input.Length <= MaxBytesStack + ? stackalloc char[outLength] + : new char[outLength]; _ = Encode(input, output); return new string(output); } - + [Pure] public static int Encode(ReadOnlySpan input, Span output) { @@ -47,10 +47,14 @@ public static class Base32 [Pure] public static byte[] Decode(ReadOnlySpan input) { + input = input.TrimEnd(Padding); if (input.IsEmpty) return []; - - Span output = stackalloc byte[5 * input.Length / 8]; - + + var outputLength = 5 * ((input.Length + 7) / 8); + Span output = outputLength <= MaxBytesStack + ? stackalloc byte[outputLength] + : new byte[outputLength]; + var size = Decode(input, output); return output[..size].ToArray(); @@ -60,7 +64,14 @@ public static class Base32 public static int Decode(ReadOnlySpan input, Span output) { input = input.TrimEnd(Padding); - Span inputspan = stackalloc char[input.Length]; + + var outputLength = 5 * ((input.Length + 7) / 8); + output = output[..outputLength]; + output.Clear(); + + Span inputspan = outputLength <= MaxBytesStack + ? stackalloc char[input.Length] + : new char[input.Length]; input.ToUpperInvariant(inputspan); int bitIndex = 0; @@ -79,7 +90,7 @@ public static class Base32 { throw new FormatException("Provided string contains invalid characters."); } - + bitPos = 5 - bitIndex; outBitPos = 8 - outputBits; bits = bitPos < outBitPos ? bitPos : outBitPos; @@ -106,7 +117,6 @@ public static class Base32 return outputIndex + (outputBits + 7) / 8; } - // returns the number of bytes that were output [Pure] private static int GetNextGroup(ReadOnlySpan input, ref int offset, out byte a, out byte b, out byte c, out byte d, out byte e, out byte f, out byte g, out byte h) diff --git a/Core/Core.csproj b/Core/Core.csproj index 8805c26..d139347 100644 --- a/Core/Core.csproj +++ b/Core/Core.csproj @@ -1,7 +1,7 @@  - net8.0 + net8.0;net9.0 enable enable Just.Core @@ -10,7 +10,7 @@ Small .Net library with useful helper classes, functions and extensions. extensions;helpers;helper-functions JustFixMe - Copyright (c) 2023,2024 JustFixMe + Copyright (c) 2023-2025 JustFixMe LICENSE README.md https://github.com/JustFixMe/Just.Core/ diff --git a/Core/Extensions/SystemIOStreamExtensions.cs b/Core/Extensions/SystemIOStreamExtensions.cs index b3869a2..258b09c 100644 --- a/Core/Extensions/SystemIOStreamExtensions.cs +++ b/Core/Extensions/SystemIOStreamExtensions.cs @@ -1,44 +1,114 @@ namespace Just.Core.Extensions; +/// +/// Provides extension methods for to fully populate buffers. +/// public static class SystemIOStreamExtensions { + /// + /// Reads data from the stream until the specified section of the buffer is filled. + /// + /// The stream to read from + /// The buffer to populate + /// The starting offset in the buffer + /// The number of bytes to read + /// Thrown when is null + /// Thrown when or is invalid + /// Thrown if the stream ends before filling the buffer + /// Thrown for I/O errors during reading public static void Populate(this Stream stream, byte[] buffer, int offset, int length) => stream.Populate(buffer.AsSpan(offset, length)); + /// + /// Reads data from the stream until the entire buffer is filled. + /// + /// The stream to read from + /// The buffer to populate + /// Thrown when is null + /// Thrown if the stream ends before filling the buffer + /// Thrown for I/O errors during reading public static void Populate(this Stream stream, byte[] buffer) => stream.Populate(buffer.AsSpan()); + /// + /// Reads data from the stream until the specified span is filled. + /// + /// The stream to read from + /// The span to populate + /// Thrown when is null + /// Thrown if the stream ends before filling the buffer + /// Thrown for I/O errors during reading public static void Populate(this Stream stream, Span buffer) { - while (buffer.Length > 0) - { - var readed = stream.Read(buffer); + ArgumentNullException.ThrowIfNull(stream); - if (readed == 0) + while (!buffer.IsEmpty) + { + var bytesRead = stream.Read(buffer); + + if (bytesRead == 0) { throw new EndOfStreamException(); } - buffer = buffer[readed..]; + buffer = buffer[bytesRead..]; } } - public static async ValueTask PopulateAsync(this Stream stream, byte[] buffer, CancellationToken cancellationToken = default) - => await stream.PopulateAsync(buffer.AsMemory(), cancellationToken); - public static async ValueTask PopulateAsync(this Stream stream, byte[] buffer, int offset, int length, CancellationToken cancellationToken = default) - => await stream.PopulateAsync(buffer.AsMemory(offset, length), cancellationToken); + /// + /// Asynchronously reads data from the stream until the entire buffer is filled. + /// + /// The stream to read from + /// The buffer to populate + /// Cancellation token + /// A ValueTask representing the asynchronous operation + /// Thrown when is null + /// Thrown if the stream ends before filling the buffer + /// Thrown if canceled via cancellation token + /// Thrown for I/O errors during reading + public static ValueTask PopulateAsync(this Stream stream, byte[] buffer, CancellationToken cancellationToken = default) + => stream.PopulateAsync(buffer.AsMemory(), cancellationToken); + /// + /// Asynchronously reads data from the stream until the specified section of the buffer is filled. + /// + /// The stream to read from + /// The buffer to populate + /// The starting offset in the buffer + /// The number of bytes to read + /// Cancellation token + /// A ValueTask representing the asynchronous operation + /// Thrown when is null + /// Thrown when or is invalid + /// Thrown if the stream ends before filling the buffer + /// Thrown if canceled via cancellation token + /// Thrown for I/O errors during reading + public static ValueTask PopulateAsync(this Stream stream, byte[] buffer, int offset, int length, CancellationToken cancellationToken = default) + => stream.PopulateAsync(buffer.AsMemory(offset, length), cancellationToken); + /// + /// Asynchronously reads data from the stream until the specified memory region is filled. + /// + /// The stream to read from + /// The memory region to populate + /// Cancellation token + /// A ValueTask representing the asynchronous operation + /// Thrown when is null + /// Thrown if the stream ends before filling the buffer + /// Thrown if canceled via cancellation token + /// Thrown for I/O errors during reading public static async ValueTask PopulateAsync(this Stream stream, Memory buffer, CancellationToken cancellationToken = default) { - while (buffer.Length > 0) + ArgumentNullException.ThrowIfNull(stream); + + while (!buffer.IsEmpty) { cancellationToken.ThrowIfCancellationRequested(); - var readed = await stream.ReadAsync(buffer, cancellationToken); + var bytesRead = await stream.ReadAsync(buffer, cancellationToken).ConfigureAwait(false); - if (readed == 0) + if (bytesRead == 0) { throw new EndOfStreamException(); } - buffer = buffer[readed..]; + buffer = buffer[bytesRead..]; } } } diff --git a/Core/GuidV8.cs b/Core/GuidV8.cs index 41ae401..1c4d2e2 100644 --- a/Core/GuidV8.cs +++ b/Core/GuidV8.cs @@ -3,15 +3,13 @@ using System.Security.Cryptography; namespace Just.Core; -public enum GuidV8Entropy { Strong, Weak } - public static class GuidV8 { [Pure, MethodImpl(MethodImplOptions.AggressiveInlining)] - public static Guid NewGuid(GuidV8Entropy entropy = GuidV8Entropy.Strong) => NewGuid(DateTime.UtcNow, entropy); + public static Guid NewGuid(RngEntropy entropy = RngEntropy.Strong) => NewGuid(DateTime.UtcNow, entropy); [Pure] - public static Guid NewGuid(DateTime dateTime, GuidV8Entropy entropy = GuidV8Entropy.Strong) + public static Guid NewGuid(DateTime dateTime, RngEntropy entropy = RngEntropy.Strong) { var epoch = dateTime.Subtract(DateTime.UnixEpoch); var timestamp = epoch.Ticks / (TimeSpan.TicksPerMillisecond / 10); @@ -24,7 +22,7 @@ public static class GuidV8 ts[0..2].CopyTo(bytes[4..6]); ts[2..6].CopyTo(bytes[..4]); - if (entropy == GuidV8Entropy.Strong) + if (entropy == RngEntropy.Strong) { RandomNumberGenerator.Fill(bytes[6..]); } diff --git a/Core/RngEntropy.cs b/Core/RngEntropy.cs new file mode 100644 index 0000000..934e38f --- /dev/null +++ b/Core/RngEntropy.cs @@ -0,0 +1,16 @@ +namespace Just.Core; + +/// +/// Specifies the quality of random entropy used in ID generation +/// +public enum RngEntropy +{ + /// + /// Cryptographically secure random numbers (slower but collision-resistant) + /// + Strong, + /// + /// Standard pseudo-random numbers (faster but less collision-resistant) + /// + Weak +} diff --git a/Core/SeqId.cs b/Core/SeqId.cs new file mode 100644 index 0000000..ab1af6f --- /dev/null +++ b/Core/SeqId.cs @@ -0,0 +1,121 @@ +using System.Security.Cryptography; + +namespace Just.Core; + +/// +/// Generates time-based sequential IDs with entropy +/// ID Structure (64 bits): +/// +/// [1 bit] Always 0 (positive signed longs) +/// [41 bits] Milliseconds since epoch (covers ~69 years) +/// [8 bits] Sequence counter (0-255 per millisecond) +/// [14 bits] Random entropy (0-16383) +/// +/// +/// +/// Important behaviors: +/// +/// Guarantees monotonic ordering within same millisecond +/// Throws on backward time jumps +/// Sequence resets when timestamp advances +/// Thread-safe through locking +/// +/// +public sealed class SeqId(DateTime epoch) +{ + private const int TimestampBits = 41; + private const int TimestampShift = 63 - TimestampBits; + private const long TimestampMask = 0x000001FF_FFFFFFFF; + + private const int SeqBits = 8; + private const int SeqShift = TimestampShift - SeqBits; + private const long SeqMask = 0x00000000_000000FF; + + private const int RandExclusiveUpper = 1 << SeqShift; + + /// + /// Default epoch (2025-01-01 UTC) used for instance + /// + public static DateTime DefaultEpoch { get; } = new(2025, 1, 1, 0, 0, 0, DateTimeKind.Utc); + /// + /// Default instance using + /// + public static SeqId Default { get; } = new(DefaultEpoch); + + /// + /// Generates ID using default instance and current UTC time + /// + /// Entropy quality (default: Strong) + /// 64-bit sequential ID with random component + /// + /// Thrown if more than 255 IDs generated in 1ms + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static long NextId(RngEntropy entropy = RngEntropy.Strong) => Default.Next(DateTime.UtcNow, entropy); + +#if NET9_0_OR_GREATER + private readonly Lock _lock = new(); +#else + private readonly object _lock = new(); +#endif + + private readonly DateTime _epoch = epoch; + private int _seqId = 0; + private long _lastTimestamp = -1L; + + /// + /// Generates next ID using current UTC time + /// + /// Entropy quality (default: Strong) + /// 64-bit sequential ID with random component + /// + /// Thrown if more than 255 IDs generated in 1ms + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public long Next(RngEntropy entropy = RngEntropy.Strong) => Next(DateTime.UtcNow, entropy); + + /// + /// Generates next ID with explicit timestamp + /// + /// Timestamp basis for ID generation + /// Entropy quality (default: Strong) + /// 64-bit sequential ID with random component + /// + /// Thrown if is earlier than last used timestamp + /// + /// + /// Thrown if more than 255 IDs generated in 1ms + /// + public long Next(DateTime dateTime, RngEntropy entropy = RngEntropy.Strong) + { + var epoch = dateTime.Subtract(_epoch); + var timestamp = ((epoch.Ticks / TimeSpan.TicksPerMillisecond) & TimestampMask) << TimestampShift; + + long currentSeq; + lock (_lock) + { + if (timestamp > _lastTimestamp) + { + _lastTimestamp = timestamp; + _seqId = 0; + } + else if (timestamp < _lastTimestamp) + { + throw new InvalidOperationException("Refused to create new SeqId. Last timestamp is in the future."); + } + + if (_seqId == SeqMask) + { + throw new IndexOutOfRangeException("Refused to create new SeqId. Sequence exhausted."); + } + + currentSeq = ((_seqId++) & SeqMask) << SeqShift; + } + + long currentRand = entropy == RngEntropy.Strong + ? RandomNumberGenerator.GetInt32(RandExclusiveUpper) + : Random.Shared.Next(RandExclusiveUpper); + + return timestamp | currentSeq | currentRand; + } +} diff --git a/LICENSE b/LICENSE index 7102f5e..ded1dcf 100644 --- a/LICENSE +++ b/LICENSE @@ -1,4 +1,4 @@ -Copyright (c) 2023,2024 JustFixMe +Copyright (c) 2023-2025 JustFixMe Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal