Skip to content

#37 Replace JSON+base64 cache envelope with compact binary framing - #48

Merged
matthewdevenny merged 2 commits into
mainfrom
matt/37-binary-envelope
Jul 1, 2026
Merged

matthewdevenny merged 2 commits into
mainfrom
matt/37-binary-envelope

Conversation

@matthewdevenny

@matthewdevenny matthewdevenny commented Jul 1, 2026 •

Copy link
Copy Markdown
Contributor

Summary

Implements #37: replaces the System.Text.Json CacheEntry envelope (payload base64-encoded inside a JSON object — ~33% inflation plus a JSON serialize/parse on every Set/Get/sliding-refresh) with a compact binary framing.

Stacked on #47 (matt/37-serialization-benchmarks) — merge that first; this PR's diff is implementation-only.

Wire format (v1)

[version:1][flags:1][absExpTicks:8?][sldExpTicks:8?][raw payload]

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 to null and are treated as cache misses (re-populated on next write). No JSON read-shim.

Changes

  • CacheEntryBinarySerializer — INatsSerialize/INatsDeserialize; SequenceReader handles multi-segment buffers; version byte checked on read; absolute expiration stored as UtcTicks (the UTC instant is preserved).
  • NatsCache — swap the envelope serializer; CacheEntry is now a plain POCO (JSON context/attributes removed). Call sites unchanged.
  • Unit tests — round-trip (all flag combos / empty / large), version + legacy-JSON→miss, multi-segment, truncation, UTC-instant round-trip.
  • Fixed one HybridCache integration test that read back the (previously public) envelope serializer — now reads raw KV bytes, which also asserts the "stored raw, no base64" property.
  • Extends util/Benchmarks with the Binary_* 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.

Payload JSON Binary saved
128 B 240 146 39%
1 KiB 1436 1042 27%
8 KiB 10992 8210 25%
64 KiB 87452 65554 25%
256 KiB 349596 262162 25%

Speed — binary vs JSON, same operation (× faster; large sizes have wide error bars under --job short, so directional):

Payload Serialize Deserialize
128 B 15× 12×
1 KiB 5.6× 9×
8 KiB 3.2× 7×
64 KiB 2.5× 11×
256 KiB 2.8× 5×

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.
  • Unit 60/60 (net8 + net10); integration 58/58 — Set/Get/Remove plus absolute + sliding expiration confirmed end-to-end against NATS.

🤖 Generated with Claude Code

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 CacheEntryBinarySerializer implementing INatsSerialize/INatsDeserialize with a version byte + flags-gated expiration fields and raw payload storage.
  • Updates NatsCache to 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.

Comment thread src/NatsDistributedCache/CacheEntryBinarySerializer.cs Outdated
Comment thread src/NatsDistributedCache/CacheEntryBinarySerializer.cs Outdated
@matthewdevenny
matthewdevenny force-pushed the matt/37-binary-envelope branch from 985fe49 to ea55bdb Compare July 1, 2026 17:36
@matthewdevenny
matthewdevenny requested a review from mtmk July 1, 2026 17:54
Base automatically changed from matt/37-serialization-benchmarks to main July 1, 2026 18:11
matthewdevenny and others added 2 commits July 1, 2026 11:13
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
matthewdevenny force-pushed the matt/37-binary-envelope branch from e4d0bd9 to c11b682 Compare July 1, 2026 18:14

@mtmk mtmk left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM

@matthewdevenny
matthewdevenny merged commit 1d498e0 into main Jul 1, 2026
2 checks passed
@matthewdevenny
matthewdevenny deleted the matt/37-binary-envelope branch July 1, 2026 18:40
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

perf: replace JSON+base64 cache envelope with compact binary framing

3 participants