Simplify object store base64url encoder#1199
Conversation
This comment was marked as outdated.
This comment was marked as outdated.
Replace the hand-rolled bit-twiddling encode loop with the in-box Base64.EncodeToUtf8 (vectorized, available on every target framework via System.Memory) plus an in-place '+'/'/' -> '-'/'_' swap. The '=' padding is kept so output stays byte-for-byte compatible with the padded base64.URLEncoding other clients use for digests and metadata subject names. On read, compare the digest as spans instead of building a "SHA-256=" interpolated string per object. Co-authored-by: to11mtm <12536917+to11mtm@users.noreply.github.com>
f681e00 to
163c818
Compare
|
@claude please review again, there was a typo preventing build before. |
|
Claude finished @mtmk's task in 1m 28s —— View job PR Review: Simplify object store base64url encoder
|
| output[k] = Base64PadCharacter; | ||
| _ = System.Buffers.Text.Base64.EncodeToUtf8(inArray, utf8, out _, out var written); | ||
| var count = MakeUrlSafe(utf8.Slice(0, written), raw); | ||
| return Encoding.ASCII.GetString(utf8.Slice(0, count)); |
There was a problem hiding this comment.
It'd probably be more efficient to use EncodeToChars instead
You can also use the int-returning EncodeToUtf8/EncodeToChars helper if you're going to ignore the OperationStatus
There was a problem hiding this comment.
makes sense. using EncodeToChars now. thanks for the feedback.
Co-authored-by: Miha Zupan <mihazupan.zupan1@gmail.com>
The encoder built the string by encoding to UTF8 bytes and then transcoding with Encoding.ASCII.GetString. Encode straight to a char span instead and build the string from it, dropping the transcode: net9+ uses Base64Url (url-safe in one pass), net8 and netstandard2.1 use Convert.TryToBase64Chars, and netstandard2.0 keeps the byte path since it has no span-based encode-to-chars. The per-framework branches are flattened into a single #if chain.
Move the encoder test into a lean project that references only the ObjectStore assembly so it can multi-target down to net6.0; the integration test project cannot, as some of its tests do not build on net6.0. Each test TFM pins a distinct compiled branch via asset selection: net6.0 -> netstandard2.1, net8.0 -> net8.0, net10.0 -> net9+, net481 -> netstandard2.0 (Windows only). Add a BenchmarkDotNet project under sandbox that compares the same branches side by side with per-runtime jobs.
Run the new encoder tests on Linux (net6/8/10) and Windows (also net481). Install the net10 SDK explicitly instead of relying on the runner image happening to ship it, and drop the unused net9 SDK as no project targets net9.
net9+ keeps Base64Url. Below net9 there is no url-safe base64 encoder, so use the lookup table; its 63rd/64th entries are '-'/'_', so it maps to the url-safe alphabet directly and needs no post-swap. Encode into a stack or pooled buffer instead of a fresh char[], so only the result string is allocated: half the allocations of the original encoder with no measurable CPU change, and net9+ is faster on top of that.
Below net9 every build shares one code path (the lookup table), so net8 represents it and net10 covers Base64Url; drop the net6/net481 jobs. Keep a table baseline next to the current encoder for comparison.
Make it a general lean BenchmarkDotNet host (only the project under test plus BenchmarkDotNet, no heavier third-party deps like MicroBenchmark) so future minimal-dependency micro-benchmarks can be added alongside the base64url encoder one.
The apicompat job builds the whole solution, which now includes the encoder test project targeting net6.0. Building net6.0 needs the net6.0 apphost pack, absent when only the 8.x SDK is installed. Match the test workflows and install 6.x/8.x/10.x.
|
@claude please take another look. The design has changed substantially since your first review (the encoder now multi-targets a separate test project to pin each Base64UrlEncoder branch via NuGet asset selection, plus the apicompat workflow change). Please review the full current diff fresh. |
|
Claude finished @mtmk's task in 4m 42s —— View job PR Review: Simplify object store base64url encoder
|
| Test TFM | ObjectStore TFM selected | Active branch |
|---|---|---|
net6.0 |
netstandard2.1 |
#else path (table encoder) |
net8.0 |
net8.0 |
#else path (table encoder) |
net10.0 |
net10.0 |
#if NET9_0_OR_GREATER (Base64Url.EncodeToChars) |
net481 (Windows) |
netstandard2.0 |
#else + #if NETSTANDARD2_0 in Sha256 |
This is an elegant and correct approach — each TFM genuinely exercises a different compiled branch. CheckEolTargetFramework=false and SuppressTfmSupportBuildWarnings=true correctly silence EOL noise for net6.0/net481. ✓
Windows uses -p:TestTfmsInParallel=false which is necessary to avoid port conflicts in platform-test suites — correct here even though the encoder tests are unit tests (consistent with the platform tests' precedent). ✓
Test coverage gap: Base64UrlEncoderTest.cs has no test for raw=true mode and no test for Sha256() output format. Since raw=true is not used by NatsObjStore.cs today this is acceptable, but a Sha256 round-trip test (verifying the padded output matches the digest wire format) would catch any future regression.
src/NATS.Client.ObjectStore/NATS.Client.ObjectStore.csproj
Two new InternalsVisibleTo entries are added: Encoder.Tests and MicroBench2. The public key matches the existing Tests entry. ✓
Minor note: granting InternalsVisibleTo to a sandbox benchmark project from a production library is unusual. Since Base64UrlEncoder is internal, this is the only way to access it from the benchmark. Acceptable for this repo's conventions, but worth being aware that MicroBench2 must always keep the same signing key.
src/NATS.Client.ObjectStore/NatsObjStore.cs
Unchanged from the previous review: null guard, Ordinal comparison, and AsSpan().SequenceEqual() are all correct. ✓
.github/workflows/apicompat.yml and test workflows
apicompat.yml now installs net6, net8, and net10 SDKs — required because the ObjectStore project multi-targets to net10.0 and the apicompat script must build all TFMs. ✓
test_linux.yml and test_windows.yml both add the "Test Object Store Encoder" step and the net6 SDK. The Linux step runs without extra flags; the Windows step correctly adds -p:TestTfmsInParallel=false for the net481 TFM. ✓
Verdict
The design is sound and correct. Both encoding paths handle memory safely, the multi-TFM test project is a clean solution to pinning each compiler branch, and the CI changes follow naturally. The only open items are:
- Close MihaZupan's review threads — both suggestions are now moot; a brief reply to each would keep the review history clean.
- Optional: add a
Sha256()test toBase64UrlEncoderTest.csto guard the wire-format digest path.
LGTM ✅
Replace the object store's hand-rolled base64url encoder. The old loop allocated a fresh
char[]on every call; the new code encodes into a stack-allocated buffer (pooled for inputs over 512 chars), so only the result string is allocated. On net9.0+ it uses the built-inBase64Urlencoder (url-safe in a single pass); on older target frameworks it keeps a lookup-table loop over the url-safe alphabet. The'='padding is retained so digests and metadata subject names stay wire-compatible with the other clients. The read path compares the digest as spans rather than building an interpolated string. Verified against the Go client by round-tripping objects in both directions.Benchmark below (Intel Xeon w5-2445, BenchmarkDotNet ShortRun; baseline = the old
new char[]table encoder, current = this PR). Allocation halves across every size and runtime. Throughput improves substantially on net9.0+ where the built-inBase64Urlpath runs (up to ~4.5x on 4 KB input); on net8.0 it is roughly unchanged, and on net481 it is slightly slower at small inputs, so on pre-net9 frameworks the change is an allocation reduction rather than a speedup. Size 32 is the SHA-256 digest, the common case.Supersedes #565; original work by @to11mtm.
Fixes #557
PS opened a proposal in the dotnet/runtime repo to cover this case so we can avoid the extra translation step dotnet/runtime#129847