diff --git a/.github/workflows/apicompat.yml b/.github/workflows/apicompat.yml index 8ead46507..314aa94aa 100644 --- a/.github/workflows/apicompat.yml +++ b/.github/workflows/apicompat.yml @@ -24,7 +24,10 @@ jobs: - name: Setup dotnet uses: actions/setup-dotnet@v4 with: - dotnet-version: 8.x + dotnet-version: | + 6.x + 8.x + 10.x - name: Run API Compatibility Check run: bash scripts/apicompat.sh --build diff --git a/.github/workflows/docs-preview.yml b/.github/workflows/docs-preview.yml index b5b27b217..c0c97c69d 100644 --- a/.github/workflows/docs-preview.yml +++ b/.github/workflows/docs-preview.yml @@ -24,7 +24,7 @@ jobs: dotnet-version: | 6.x 8.x - 9.x + 10.x - run: dotnet build - run: dotnet tool update -g docfx diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index dd43ee9b1..a2cf2a43e 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -28,7 +28,7 @@ jobs: dotnet-version: | 6.x 8.x - 9.x + 10.x - run: dotnet build - run: dotnet tool update -g docfx diff --git a/.github/workflows/format.yml b/.github/workflows/format.yml index 47d50b431..a45db11f1 100644 --- a/.github/workflows/format.yml +++ b/.github/workflows/format.yml @@ -17,7 +17,7 @@ jobs: dotnet-version: | 6.x 8.x - 9.x + 10.x - name: Check formatting run: | diff --git a/.github/workflows/perf.yml b/.github/workflows/perf.yml index fa0d8fc43..55ee1ac7f 100644 --- a/.github/workflows/perf.yml +++ b/.github/workflows/perf.yml @@ -45,7 +45,7 @@ jobs: dotnet-version: | 6.x 8.x - 9.x + 10.x - name: Release Build run: dotnet build -c Release tests/NATS.Client.Perf/NATS.Client.Perf.csproj diff --git a/.github/workflows/test_linux.yml b/.github/workflows/test_linux.yml index 0cae81f63..d62759c97 100644 --- a/.github/workflows/test_linux.yml +++ b/.github/workflows/test_linux.yml @@ -44,7 +44,7 @@ jobs: dotnet-version: | 6.x 8.x - 9.x + 10.x - name: Build run: | @@ -71,6 +71,11 @@ jobs: cd tests/NATS.Client.Abstractions.Tests dotnet test -c Release --no-build + - name: Test Object Store Encoder + run: | + cd tests/NATS.Client.ObjectStore.Encoder.Tests + dotnet test -c Release --no-build + - name: Test JetStream run: | killall nats-server 2> /dev/null | echo -n diff --git a/.github/workflows/test_linux_core.yml b/.github/workflows/test_linux_core.yml index 8d90af12c..adedb84e1 100644 --- a/.github/workflows/test_linux_core.yml +++ b/.github/workflows/test_linux_core.yml @@ -44,7 +44,7 @@ jobs: dotnet-version: | 6.x 8.x - 9.x + 10.x - name: Build run: | diff --git a/.github/workflows/test_windows.yml b/.github/workflows/test_windows.yml index 44325dbc1..6c14c3107 100644 --- a/.github/workflows/test_windows.yml +++ b/.github/workflows/test_windows.yml @@ -36,7 +36,7 @@ jobs: dotnet-version: | 6.x 8.x - 9.x + 10.x - name: Set up Go uses: actions/setup-go@v5 @@ -83,6 +83,12 @@ jobs: cd tests/NATS.Client.Abstractions.Tests dotnet test -c Release --no-build + # net481 covers the netstandard2.0 branch; run TFMs serially as the platform tests do + - name: Test Object Store Encoder + run: | + cd tests/NATS.Client.ObjectStore.Encoder.Tests + dotnet test -c Release --no-build -p:TestTfmsInParallel=false + - name: Test JetStream run: | tasklist | grep -i nats-server && taskkill -F -IM nats-server.exe diff --git a/NATS.Net.slnx b/NATS.Net.slnx index 187f663d9..b6c83097e 100644 --- a/NATS.Net.slnx +++ b/NATS.Net.slnx @@ -27,6 +27,7 @@ + @@ -82,6 +83,7 @@ + diff --git a/sandbox/MicroBench2/.gitignore b/sandbox/MicroBench2/.gitignore new file mode 100644 index 000000000..b0f1a2c50 --- /dev/null +++ b/sandbox/MicroBench2/.gitignore @@ -0,0 +1 @@ +/BenchmarkDotNet.Artifacts diff --git a/sandbox/MicroBench2/Base64UrlEncoderBenchmarks.cs b/sandbox/MicroBench2/Base64UrlEncoderBenchmarks.cs new file mode 100644 index 000000000..244e103ec --- /dev/null +++ b/sandbox/MicroBench2/Base64UrlEncoderBenchmarks.cs @@ -0,0 +1,98 @@ +using BenchmarkDotNet.Attributes; +using BenchmarkDotNet.Jobs; +using NATS.Client.ObjectStore.Internal; + +namespace MicroBench2; + +[MemoryDiagnoser] +[ShortRunJob(RuntimeMoniker.Net481)] +[ShortRunJob(RuntimeMoniker.Net80)] +[ShortRunJob(RuntimeMoniker.Net10_0)] +public class Base64UrlEncoderBenchmarks +{ + private const char PadChar = '='; + + private static readonly char[] SBase64Table = + { + 'A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'I', 'J', 'K', 'L', 'M', 'N', 'O', 'P', 'Q', 'R', 'S', 'T', 'U', 'V', 'W', 'X', 'Y', 'Z', + 'a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j', 'k', 'l', 'm', 'n', 'o', 'p', 'q', 'r', 's', 't', 'u', 'v', 'w', 'x', 'y', 'z', + '0', '1', '2', '3', '4', '5', '6', '7', '8', '9', '-', '_', + }; + + private byte[] _data = null!; + + // SHA-256 digest (32) is the common case; the others bracket small and larger inputs. + [Params(16, 32, 256, 4096)] + public int Size { get; set; } + + [GlobalSetup] + public void Setup() + { + _data = new byte[Size]; + for (var i = 0; i < Size; i++) + _data[i] = (byte)((i * 37) + 11); + } + + // Baseline: the original lookup-table encoder that allocated a fresh char[] per call. + [Benchmark(Baseline = true, Description = "table (new char[])")] + public string EncodeTableNewArray() => EncodeTableNewArrayImpl(_data); + + [Benchmark(Description = "current")] + public string Encode() => Base64UrlEncoder.Encode(_data); + + private static string EncodeTableNewArrayImpl(Span inArray) + { + var length = inArray.Length; + if (length == 0) + return string.Empty; + + var lengthMod3 = length % 3; + var limit = length - lengthMod3; + var output = new char[(length + 2) / 3 * 4]; + var table = SBase64Table; + var j = 0; + + for (var i = 0; i < limit; i += 3) + { + var d0 = inArray[i]; + var d1 = inArray[i + 1]; + var d2 = inArray[i + 2]; + + output[j + 0] = table[d0 >> 2]; + output[j + 1] = table[((d0 & 0x03) << 4) | (d1 >> 4)]; + output[j + 2] = table[((d1 & 0x0f) << 2) | (d2 >> 6)]; + output[j + 3] = table[d2 & 0x3f]; + j += 4; + } + + switch (lengthMod3) + { + case 2: + { + var d0 = inArray[limit]; + var d1 = inArray[limit + 1]; + output[j + 0] = table[d0 >> 2]; + output[j + 1] = table[((d0 & 0x03) << 4) | (d1 >> 4)]; + output[j + 2] = table[(d1 & 0x0f) << 2]; + j += 3; + } + + break; + + case 1: + { + var d0 = inArray[limit]; + output[j + 0] = table[d0 >> 2]; + output[j + 1] = table[(d0 & 0x03) << 4]; + j += 2; + } + + break; + } + + for (var k = j; k < output.Length; k++) + output[k] = PadChar; + + return new string(output); + } +} diff --git a/sandbox/MicroBench2/MicroBench2.csproj b/sandbox/MicroBench2/MicroBench2.csproj new file mode 100644 index 000000000..6cdccf6bb --- /dev/null +++ b/sandbox/MicroBench2/MicroBench2.csproj @@ -0,0 +1,28 @@ + + + + + Exe + net8.0;net10.0 + + $(TargetFrameworks);net481 + enable + enable + latest + false + $(NoWarn);CS8002 + + + + + + + + + + + diff --git a/sandbox/MicroBench2/Program.cs b/sandbox/MicroBench2/Program.cs new file mode 100644 index 000000000..813b138e5 --- /dev/null +++ b/sandbox/MicroBench2/Program.cs @@ -0,0 +1,4 @@ +using System.Reflection; +using BenchmarkDotNet.Running; + +BenchmarkSwitcher.FromAssembly(Assembly.GetExecutingAssembly()).Run(args); diff --git a/src/NATS.Client.ObjectStore/Internal/Encoder.cs b/src/NATS.Client.ObjectStore/Internal/Encoder.cs index 07aaadc1f..86921a128 100644 --- a/src/NATS.Client.ObjectStore/Internal/Encoder.cs +++ b/src/NATS.Client.ObjectStore/Internal/Encoder.cs @@ -1,4 +1,7 @@ using System.Buffers; +#if NET9_0_OR_GREATER +using System.Buffers.Text; +#endif using System.Security.Cryptography; namespace NATS.Client.ObjectStore.Internal; @@ -18,14 +21,15 @@ internal static class Base64UrlEncoder private const char Base64UrlCharacter62 = '-'; private const char Base64UrlCharacter63 = '_'; - /// - /// Encoding table - /// - private static readonly char[] SBase64Table = new[] +#if !NET9_0_OR_GREATER + // base64url alphabet (the 63rd/64th entries are '-'/'_'), so the table encodes url-safe directly. + private static readonly char[] SBase64Table = { - 'A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'I', 'J', 'K', 'L', 'M', 'N', 'O', 'P', 'Q', 'R', 'S', 'T', 'U', 'V', 'W', 'X', 'Y', 'Z', 'a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j', 'k', 'l', 'm', 'n', 'o', 'p', 'q', 'r', 's', 't', 'u', 'v', 'w', 'x', 'y', 'z', '0', '1', '2', '3', '4', - '5', '6', '7', '8', '9', Base64UrlCharacter62, Base64UrlCharacter63, + 'A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'I', 'J', 'K', 'L', 'M', 'N', 'O', 'P', 'Q', 'R', 'S', 'T', 'U', 'V', 'W', 'X', 'Y', 'Z', + 'a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j', 'k', 'l', 'm', 'n', 'o', 'p', 'q', 'r', 's', 't', 'u', 'v', 'w', 'x', 'y', 'z', + '0', '1', '2', '3', '4', '5', '6', '7', '8', '9', Base64UrlCharacter62, Base64UrlCharacter63, }; +#endif public static string Sha256(ReadOnlySpan value) { @@ -59,83 +63,115 @@ public static string Encode(string arg) } /// - /// Converts a subset of an array of 8-bit unsigned integers to its equivalent string representation which is encoded with base-64-url digits. Parameters specify - /// the subset as an offset in the input array, and the number of elements in the array to convert. + /// Converts the bytes to their equivalent base64url string representation. /// - /// An array of 8-bit unsigned integers. - /// Remove padding - /// The string representation in base 64 url encoding of length elements of inArray, starting at position offset. - /// 'inArray' is null. - /// offset or length is negative OR offset plus length is greater than the length of inArray. + /// The bytes to encode. + /// Remove padding. + /// The base64url encoding of the bytes. public static string Encode(Span inArray, bool raw = false) { - var offset = 0; var length = inArray.Length; - if (length == 0) return string.Empty; - var lengthMod3 = length % 3; - var limit = length - lengthMod3; - var output = new char[(length + 2) / 3 * 4]; - var table = SBase64Table; - int i, j = 0; + var base64Length = (length + 2) / 3 * 4; +#if NET9_0_OR_GREATER + // Base64Url encodes url-safe in a single pass (no separate swap). It omits padding, so for + // the default (raw == false) the trailing '=' is appended to match base64.URLEncoding. + char[]? rented = null; + var chars = base64Length <= 512 + ? stackalloc char[base64Length] + : (rented = ArrayPool.Shared.Rent(base64Length)).AsSpan(0, base64Length); + try + { + var written = Base64Url.EncodeToChars(inArray, chars); + if (raw) + return new string(chars.Slice(0, written)); - // takes 3 bytes from inArray and insert 4 bytes into output - for (i = offset; i < limit; i += 3) + chars.Slice(written, base64Length - written).Fill(Base64PadCharacter); + return new string(chars.Slice(0, base64Length)); + } + finally { - var d0 = inArray[i]; - var d1 = inArray[i + 1]; - var d2 = inArray[i + 2]; - - output[j + 0] = table[d0 >> 2]; - output[j + 1] = table[((d0 & 0x03) << 4) | (d1 >> 4)]; - output[j + 2] = table[((d1 & 0x0f) << 2) | (d2 >> 6)]; - output[j + 3] = table[d2 & 0x3f]; - j += 4; + if (rented != null) + ArrayPool.Shared.Return(rented); } - - // Where we left off before - i = limit; - - switch (lengthMod3) +#else + // Pre-net9 there is no url-safe base64 encoder, so use the lookup table (which maps directly + // to the url-safe alphabet). Encode into a stack/pooled buffer with an unsafe pointer loop + // (no bounds checks), so only the result string is allocated. + char[]? rented = null; + var chars = base64Length <= 512 + ? stackalloc char[base64Length] + : (rented = ArrayPool.Shared.Rent(base64Length)).AsSpan(0, base64Length); + try { - case 2: - { - var d0 = inArray[i]; - var d1 = inArray[i + 1]; - - output[j + 0] = table[d0 >> 2]; - output[j + 1] = table[((d0 & 0x03) << 4) | (d1 >> 4)]; - output[j + 2] = table[(d1 & 0x0f) << 2]; - j += 3; - } - - break; + var lengthMod3 = length % 3; + var limit = length - lengthMod3; + var j = 0; - case 1: + unsafe { - var d0 = inArray[i]; + // Pin the table and output and index them without bounds checks; inArray stays a + // checked span (its accesses are bounded by limit, derived from its own length). + fixed (char* dst = chars, tbl = SBase64Table) + { + // Each 3 input bytes map to 4 output chars. + for (var i = 0; i < limit; i += 3) + { + int d0 = inArray[i]; + int d1 = inArray[i + 1]; + int d2 = inArray[i + 2]; + + dst[j + 0] = tbl[d0 >> 2]; + dst[j + 1] = tbl[((d0 & 0x03) << 4) | (d1 >> 4)]; + dst[j + 2] = tbl[((d1 & 0x0f) << 2) | (d2 >> 6)]; + dst[j + 3] = tbl[d2 & 0x3f]; + j += 4; + } - output[j + 0] = table[d0 >> 2]; - output[j + 1] = table[(d0 & 0x03) << 4]; - j += 2; - } + switch (lengthMod3) + { + case 2: + { + int d0 = inArray[limit]; + int d1 = inArray[limit + 1]; + dst[j + 0] = tbl[d0 >> 2]; + dst[j + 1] = tbl[((d0 & 0x03) << 4) | (d1 >> 4)]; + dst[j + 2] = tbl[(d1 & 0x0f) << 2]; + j += 3; + } + + break; + + case 1: + { + int d0 = inArray[limit]; + dst[j + 0] = tbl[d0 >> 2]; + dst[j + 1] = tbl[(d0 & 0x03) << 4]; + j += 2; + } + + break; + } - break; + if (!raw) + { + for (var k = j; k < base64Length; k++) + dst[k] = Base64PadCharacter; + j = base64Length; + } - // default or case 0: no further operations are needed. + return new string(dst, 0, j); + } + } } - - if (raw) - return new string(output, 0, j); - - for (var k = j; k < output.Length; k++) + finally { - output[k] = Base64PadCharacter; + if (rented != null) + ArrayPool.Shared.Return(rented); } - - return new string(output); +#endif } /// diff --git a/src/NATS.Client.ObjectStore/NATS.Client.ObjectStore.csproj b/src/NATS.Client.ObjectStore/NATS.Client.ObjectStore.csproj index 7babd0d46..2b5b8de0d 100644 --- a/src/NATS.Client.ObjectStore/NATS.Client.ObjectStore.csproj +++ b/src/NATS.Client.ObjectStore/NATS.Client.ObjectStore.csproj @@ -9,6 +9,8 @@ + + diff --git a/src/NATS.Client.ObjectStore/NatsObjStore.cs b/src/NATS.Client.ObjectStore/NatsObjStore.cs index befdca53c..c9d32d0dd 100644 --- a/src/NATS.Client.ObjectStore/NatsObjStore.cs +++ b/src/NATS.Client.ObjectStore/NatsObjStore.cs @@ -96,8 +96,8 @@ public async ValueTask GetAsync(string key, Stream stream, bool { // We have to make sure to carry on consuming the channel to avoid any blocking: // e.g. if the channel is full, we would be blocking the reads off the socket (this was intentionally - // done ot avoid bloating the memory with a large backlog of messages or dropping messages at this level - // and signal the server that we are a slow consumer); then when we make an request-reply API call to + // done to avoid bloating the memory with a large backlog of messages or dropping messages at this level + // and signal the server that we are a slow consumer); then when we make a request-reply API call to // delete the consumer, the socket would be blocked trying to send the response back to us; so we need to // keep consuming the channel to avoid this. if (pushConsumer.IsDone) @@ -127,7 +127,10 @@ public async ValueTask GetAsync(string key, Stream stream, bool digest = Base64UrlEncoder.Encode(sha256.Hash); } - if ($"SHA-256={digest}" != info.Digest) + const string digestPrefix = "SHA-256="; + if (info.Digest == null + || info.Digest.StartsWith(digestPrefix, StringComparison.Ordinal) == false + || info.Digest.AsSpan(digestPrefix.Length).SequenceEqual(digest.AsSpan()) == false) { throw new NatsObjException("SHA-256 digest mismatch"); } diff --git a/tests/NATS.Client.ObjectStore.Tests/Base64UrlEncoderTest.cs b/tests/NATS.Client.ObjectStore.Encoder.Tests/Base64UrlEncoderTest.cs similarity index 71% rename from tests/NATS.Client.ObjectStore.Tests/Base64UrlEncoderTest.cs rename to tests/NATS.Client.ObjectStore.Encoder.Tests/Base64UrlEncoderTest.cs index f349e8c7a..922210ca4 100644 --- a/tests/NATS.Client.ObjectStore.Tests/Base64UrlEncoderTest.cs +++ b/tests/NATS.Client.ObjectStore.Encoder.Tests/Base64UrlEncoderTest.cs @@ -1,7 +1,8 @@ +using System.Security.Cryptography; using System.Text; using NATS.Client.ObjectStore.Internal; -namespace NATS.Client.ObjectStore.Tests; +namespace NATS.Client.ObjectStore.Encoder.Tests; public class Base64UrlEncoderTest { @@ -37,6 +38,25 @@ public void Decoding_test(string input) _output.WriteLine($">>{decoded}<<"); } + [Theory] + [InlineData("")] + [InlineData("Hello World!")] + [InlineData("The quick brown fox jumps over the lazy dog")] + public void Sha256_test(string input) + { + var data = Encoding.UTF8.GetBytes(input); + var actual = Base64UrlEncoder.Sha256(data); + + // Independent reference: hash, then base64url-encode the 32-byte digest keeping the '=' pad. + using var sha256 = SHA256.Create(); + var expected = Convert.ToBase64String(sha256.ComputeHash(data)) + .Replace('+', '-') + .Replace('/', '_'); + + Assert.Equal(expected, actual); + _output.WriteLine($">>{actual}<<"); + } + private string Encode(string input, bool raw = false) { var base64String = Convert.ToBase64String(Encoding.UTF8.GetBytes(input)); diff --git a/tests/NATS.Client.ObjectStore.Encoder.Tests/NATS.Client.ObjectStore.Encoder.Tests.csproj b/tests/NATS.Client.ObjectStore.Encoder.Tests/NATS.Client.ObjectStore.Encoder.Tests.csproj new file mode 100644 index 000000000..19430987f --- /dev/null +++ b/tests/NATS.Client.ObjectStore.Encoder.Tests/NATS.Client.ObjectStore.Encoder.Tests.csproj @@ -0,0 +1,43 @@ + + + + + net6.0;net8.0;net10.0 + $(TargetFrameworks);net481 + any;win-x86 + enable + enable + latest + false + + true + $(MSBuildProjectDirectory)\..\xunit.runsettings + false + true + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + + +