#37 Replace JSON+base64 cache envelope with compact binary framing - #48
Merged
Merged
Conversation
matthewdevenny
force-pushed
the
matt/37-binary-envelope
branch
from
July 1, 2026 17:18
b099f82 to
985fe49
Compare
Contributor
There was a problem hiding this comment.
Pull request overview
This PR implements issue #37 by replacing the cache entry on-wire envelope from a JSON + base64-encoded payload to a compact, versioned binary framing. This reduces stored bytes and avoids JSON serialization overhead on every cache operation while intentionally treating legacy entries as cache misses.
Changes:
- Introduces
CacheEntryBinarySerializerimplementingINatsSerialize/INatsDeserializewith a version byte + flags-gated expiration fields and raw payload storage. - Updates
NatsCacheto use the new binary serializer for KV Set/Get/refresh paths (legacy JSON entries become misses). - Extends the benchmarks and tests to compare JSON vs binary sizes/perf and validate multi-segment/truncation/versioning behavior.
Reviewed changes
Copilot reviewed 9 out of 9 changed files in this pull request and generated 2 comments.
Show a summary per file
| File | Description |
|---|---|
| util/Benchmarks/SizeReport.cs | Prints a JSON vs binary stored-size comparison table including savings/ratio. |
| util/Benchmarks/README.md | Updates benchmark documentation to describe both envelope formats and measurements. |
| util/Benchmarks/CacheEntrySerializationBenchmarks.cs | Adds binary serialize/deserialize benchmarks alongside JSON baseline. |
| util/Benchmarks/BenchmarkData.cs | Adds factory for binary CacheEntry benchmark inputs. |
| test/UnitTests/Serialization/CacheEntryBinarySerializerTests.cs | Adds unit coverage for binary framing, versioning, truncation, and multi-segment reads. |
| test/IntegrationTests/Cache/HybridCacheSetAndRemoveTests.cs | Adjusts integration test to assert raw payload visibility in stored bytes (no base64/JSON). |
| src/NatsDistributedCache/NatsDistributedCache.csproj | Grants Benchmarks access to internals when not signing. |
| src/NatsDistributedCache/NatsCache.cs | Switches cache envelope serialization to the new binary serializer. |
| src/NatsDistributedCache/CacheEntryBinarySerializer.cs | Adds the new binary serializer implementation and wire format definition. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
matthewdevenny
force-pushed
the
matt/37-binary-envelope
branch
from
July 1, 2026 17:36
985fe49 to
ea55bdb
Compare
Replace the System.Text.Json CacheEntry envelope (payload base64-encoded inside a JSON object, ~33% inflation plus a JSON parse per Set/Get) with a compact binary framing: [version:1][flags:1][absExpTicks:8?][sldExpTicks:8?][raw payload] Flags gate the 8-byte expiration fields, so an entry without expiration carries just a 2-byte header and the payload is stored raw (no base64). Breaking on-wire change: entries whose leading version byte is not 0x01 (including pre-existing JSON entries) deserialize to null and are treated as cache misses. - CacheEntryBinarySerializer: INatsSerialize/INatsDeserialize; SequenceReader handles multi-segment buffers; version byte checked on read. - NatsCache: swap the envelope serializer; CacheEntry is now a plain POCO. - Unit tests: round-trip (all flag combos/empty/large), version + legacy-JSON miss, multi-segment, truncation, UTC-instant round-trip. - Extend util/Benchmarks with Binary_* methods + the size comparison. BenchmarkDotNet (net10.0): serialize 3-14x faster, deserialize 2-4x faster; stored size 240->146 B (128 B payload), 10992->8210 B (8 KiB). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Signed-off-by: Matthew DeVenny <matt@codecargo.com>
Address review feedback on the CacheEntry binary reader so any malformed v1 entry fails closed (cache miss) instead of returning wrong bytes or throwing: - Reject unknown flag bits (flags & ~KnownFlags): corrupt data or a future format that reused version 1 no longer has its trailing bytes misread as payload. - Range-check the absolute-expiration ticks before constructing DateTimeOffset, so a corrupt tick value returns null instead of throwing ArgumentOutOfRangeException out of GetAsync. Adds unit tests for both. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Signed-off-by: Matthew DeVenny <matt@codecargo.com>
matthewdevenny
force-pushed
the
matt/37-binary-envelope
branch
from
July 1, 2026 18:14
e4d0bd9 to
c11b682
Compare
This was referenced Jul 1, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Implements #37: replaces the
System.Text.JsonCacheEntryenvelope (payload base64-encoded inside a JSON object — ~33% inflation plus a JSON serialize/parse on everySet/Get/sliding-refresh) with a compact binary framing.Wire format (v1)
Flags gate the 8-byte expiration fields, so an entry with no expiration carries just a 2-byte header and the payload is stored raw (no base64).
Breaking on-wire change: entries whose leading version byte isn't
0x01— including pre-existing JSON entries (first byte{) — deserialize tonulland are treated as cache misses (re-populated on next write). No JSON read-shim.Changes
CacheEntryBinarySerializer—INatsSerialize/INatsDeserialize;SequenceReaderhandles multi-segment buffers; version byte checked on read; absolute expiration stored asUtcTicks(the UTC instant is preserved).NatsCache— swap the envelope serializer;CacheEntryis now a plain POCO (JSON context/attributes removed). Call sites unchanged.util/Benchmarkswith theBinary_*methods + the size comparison.Results (BenchmarkDotNet, net10.0,
--job short)Stored size (abs + sliding set): binary =
payload + 18 B; JSON converges to base64's ⁴⁄₃ inflation.Speed — binary vs JSON, same operation (× faster; large sizes have wide error bars under
--job short, so directional):Serialize allocates nothing either way (JSON's base64 encode is pure CPU). Deserialize allocates the payload
byte[]in both; at 256 KiB (Large Object Heap) JSON incurs ~2× the GC collections (Gen0/1/2 ≈ 66 vs 33 per 1000 ops) from its base64-decode buffers.Verification
dotnet build -p TreatWarningsAsErrors=true,dotnet format --verify-no-changes, BOM check, lockfile-sync: clean.🤖 Generated with Claude Code