diff --git a/src/NatsDistributedCache/CacheEntryBinarySerializer.cs b/src/NatsDistributedCache/CacheEntryBinarySerializer.cs
new file mode 100644
index 0000000..d7f526b
--- /dev/null
+++ b/src/NatsDistributedCache/CacheEntryBinarySerializer.cs
@@ -0,0 +1,134 @@
+using System.Buffers;
+using System.Buffers.Binary;
+using NATS.Client.Core;
+
+namespace CodeCargo.Nats.DistributedCache;
+
+///
+/// Serializes and deserializes using a compact binary framing that avoids the
+/// base64 inflation and JSON overhead of the previous envelope.
+///
+///
+/// Wire format (little-endian):
+///
+/// [version:1][flags:1][absExpTicks:8?][sldExpTicks:8?][payload...]
+///
+/// The 8-byte expiration fields are only present when their corresponding flag bit is set, so an entry
+/// without expiration carries just a 2-byte header. Any buffer whose leading version byte does not match
+/// — including pre-existing JSON entries, whose first byte is '{' (0x7B) —
+/// deserializes to null and is treated as a cache miss.
+///
+internal sealed class CacheEntryBinarySerializer : INatsSerialize, INatsDeserialize
+{
+ ///
+ /// Current on-wire format version. Bump when the framing changes so that entries written by an older
+ /// version are treated as cache misses instead of being misinterpreted.
+ ///
+ internal const byte FormatVersion = 1;
+
+ private const byte HasAbsoluteExpiration = 0b0000_0001;
+ private const byte HasSlidingExpiration = 0b0000_0010;
+ private const byte KnownFlags = HasAbsoluteExpiration | HasSlidingExpiration;
+
+ ///
+ /// Gets the shared, stateless serializer instance.
+ ///
+ public static CacheEntryBinarySerializer Default { get; } = new();
+
+ ///
+ public void Serialize(IBufferWriter bufferWriter, CacheEntry value)
+ {
+ var hasAbsolute = value.AbsoluteExpiration.HasValue;
+ var hasSliding = value.SlidingExpirationTicks.HasValue;
+
+ var flags = (byte)0;
+ if (hasAbsolute)
+ {
+ flags |= HasAbsoluteExpiration;
+ }
+
+ if (hasSliding)
+ {
+ flags |= HasSlidingExpiration;
+ }
+
+ var headerLength = 2 + (hasAbsolute ? 8 : 0) + (hasSliding ? 8 : 0);
+ var header = bufferWriter.GetSpan(headerLength);
+ header[0] = FormatVersion;
+ header[1] = flags;
+
+ var offset = 2;
+ if (hasAbsolute)
+ {
+ BinaryPrimitives.WriteInt64LittleEndian(header.Slice(offset), value.AbsoluteExpiration!.Value.UtcTicks);
+ offset += 8;
+ }
+
+ if (hasSliding)
+ {
+ BinaryPrimitives.WriteInt64LittleEndian(header.Slice(offset), value.SlidingExpirationTicks!.Value);
+ offset += 8;
+ }
+
+ bufferWriter.Advance(offset);
+
+ if (value.Data is { Length: > 0 } data)
+ {
+ bufferWriter.Write(data);
+ }
+ }
+
+ ///
+ public CacheEntry? Deserialize(in ReadOnlySequence buffer)
+ {
+ var reader = new SequenceReader(buffer);
+
+ if (!reader.TryRead(out var version) || version != FormatVersion)
+ {
+ // Unknown or legacy (e.g. JSON) entry: treat as a cache miss.
+ return null;
+ }
+
+ if (!reader.TryRead(out var flags) || (flags & ~KnownFlags) != 0)
+ {
+ // Unknown flag bits: corrupt data, or a future format that mistakenly reused this version.
+ // Fail closed rather than misinterpreting the remaining bytes.
+ return null;
+ }
+
+ DateTimeOffset? absoluteExpiration = null;
+ if ((flags & HasAbsoluteExpiration) != 0)
+ {
+ if (!reader.TryReadLittleEndian(out long absoluteTicks) ||
+ absoluteTicks < DateTimeOffset.MinValue.Ticks ||
+ absoluteTicks > DateTimeOffset.MaxValue.Ticks)
+ {
+ // Missing or out-of-range ticks (corrupt entry): treat as a miss instead of throwing.
+ return null;
+ }
+
+ absoluteExpiration = new DateTimeOffset(absoluteTicks, TimeSpan.Zero);
+ }
+
+ long? slidingExpirationTicks = null;
+ if ((flags & HasSlidingExpiration) != 0)
+ {
+ if (!reader.TryReadLittleEndian(out long slidingTicks))
+ {
+ return null;
+ }
+
+ slidingExpirationTicks = slidingTicks;
+ }
+
+ var remaining = reader.UnreadSequence;
+ var data = remaining.IsEmpty ? Array.Empty() : remaining.ToArray();
+
+ return new CacheEntry
+ {
+ AbsoluteExpiration = absoluteExpiration,
+ SlidingExpirationTicks = slidingExpirationTicks,
+ Data = data,
+ };
+ }
+}
diff --git a/src/NatsDistributedCache/NatsCache.cs b/src/NatsDistributedCache/NatsCache.cs
index 348d305..beb86d1 100644
--- a/src/NatsDistributedCache/NatsCache.cs
+++ b/src/NatsDistributedCache/NatsCache.cs
@@ -1,5 +1,4 @@
using System.Buffers;
-using System.Text.Json.Serialization;
using Microsoft.Extensions.Caching.Distributed;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
@@ -15,32 +14,20 @@ namespace CodeCargo.Nats.DistributedCache;
///
public class CacheEntry
{
- [JsonPropertyName("absexp")]
public DateTimeOffset? AbsoluteExpiration { get; set; }
- [JsonPropertyName("sldexp")]
public long? SlidingExpirationTicks { get; set; }
- [JsonPropertyName("data")]
public byte[]? Data { get; set; }
}
-///
-/// JsonSerializerContext for CacheEntry
-///
-[JsonSerializable(typeof(CacheEntry))]
-public partial class CacheEntryJsonContext : JsonSerializerContext
-{
-}
-
///
/// Distributed cache implementation using NATS Key-Value Store.
///
public partial class NatsCache : IBufferDistributedCache
{
- // Static JSON serializer for CacheEntry
- private static readonly NatsJsonContextSerializer CacheEntrySerializer =
- new(CacheEntryJsonContext.Default);
+ // Compact binary serializer for the CacheEntry envelope (replaces the previous JSON+base64 format).
+ private static readonly CacheEntryBinarySerializer CacheEntrySerializer = CacheEntryBinarySerializer.Default;
private readonly string _bucketName;
private readonly INatsCacheKeyEncoder _keyEncoder;
diff --git a/src/NatsDistributedCache/NatsDistributedCache.csproj b/src/NatsDistributedCache/NatsDistributedCache.csproj
index c2af08b..02772af 100644
--- a/src/NatsDistributedCache/NatsDistributedCache.csproj
+++ b/src/NatsDistributedCache/NatsDistributedCache.csproj
@@ -47,6 +47,7 @@
.snk into keys/ locally flips the build to signed and will break the UnitTests compile. -->
+
diff --git a/test/IntegrationTests/Cache/HybridCacheSetAndRemoveTests.cs b/test/IntegrationTests/Cache/HybridCacheSetAndRemoveTests.cs
index b3dd429..b1f4e9f 100644
--- a/test/IntegrationTests/Cache/HybridCacheSetAndRemoveTests.cs
+++ b/test/IntegrationTests/Cache/HybridCacheSetAndRemoveTests.cs
@@ -70,12 +70,13 @@ public async Task HybridCacheSerializesDateTime()
var serializedDateString = Encoding.ASCII.GetString(writer.WrittenSpan.ToArray());
var kvStore = await NatsConnection.CreateKeyValueStoreContext().GetStoreAsync("cache");
- NatsJsonContextSerializer cacheEntrySerializer = new(CacheEntryJsonContext.Default);
- var kvEntry = await kvStore.GetEntryAsync(key, serializer: cacheEntrySerializer);
- Assert.NotNull(kvEntry.Value?.Data);
- var storedDateString = Encoding.ASCII.GetString(kvEntry.Value.Data);
+ var kvEntry = await kvStore.GetEntryAsync(key);
+ Assert.NotNull(kvEntry.Value);
+ var storedDateString = Encoding.ASCII.GetString(kvEntry.Value);
- // HybridCache adds additional data to the front of the serialized value, so we're matching only the relevant data
+ // The compact binary envelope stores the payload raw (no base64/JSON), so the HybridCache-serialized
+ // value appears verbatim in the stored bytes. HybridCache also adds framing of its own to the front,
+ // so we match only the relevant portion.
Assert.Contains(serializedDateString, storedDateString);
// Assert - date is deserialized as expected
diff --git a/test/UnitTests/Serialization/CacheEntryBinarySerializerTests.cs b/test/UnitTests/Serialization/CacheEntryBinarySerializerTests.cs
new file mode 100644
index 0000000..13022a4
--- /dev/null
+++ b/test/UnitTests/Serialization/CacheEntryBinarySerializerTests.cs
@@ -0,0 +1,226 @@
+using System.Buffers;
+using System.Buffers.Binary;
+using System.Text;
+
+namespace CodeCargo.Nats.DistributedCache.UnitTests.Serialization;
+
+///
+/// Tests for the compact binary serializer.
+///
+public class CacheEntryBinarySerializerTests
+{
+ private static readonly DateTimeOffset SampleAbsolute =
+ new(2030, 1, 2, 3, 4, 5, TimeSpan.Zero);
+
+ private static readonly long SampleSlidingTicks = TimeSpan.FromMinutes(10).Ticks;
+
+ private readonly CacheEntryBinarySerializer _serializer = CacheEntryBinarySerializer.Default;
+
+ [Theory]
+ [InlineData(false, false)]
+ [InlineData(true, false)]
+ [InlineData(false, true)]
+ [InlineData(true, true)]
+ public void RoundTrips_AllExpirationCombinations(bool hasAbsolute, bool hasSliding)
+ {
+ var entry = new CacheEntry
+ {
+ AbsoluteExpiration = hasAbsolute ? SampleAbsolute : null,
+ SlidingExpirationTicks = hasSliding ? SampleSlidingTicks : null,
+ Data = [1, 2, 3, 4, 5],
+ };
+
+ var result = Deserialize(Serialize(entry));
+
+ Assert.NotNull(result);
+ Assert.Equal(entry.AbsoluteExpiration, result.AbsoluteExpiration);
+ Assert.Equal(entry.SlidingExpirationTicks, result.SlidingExpirationTicks);
+ Assert.Equal(entry.Data, result.Data);
+ }
+
+ [Fact]
+ public void RoundTrips_EmptyData()
+ {
+ var entry = new CacheEntry { Data = [] };
+
+ var result = Deserialize(Serialize(entry));
+
+ Assert.NotNull(result);
+ Assert.NotNull(result.Data);
+ Assert.Empty(result.Data);
+ }
+
+ [Fact]
+ public void RoundTrips_LargePayload()
+ {
+ var payload = new byte[64 * 1024];
+ for (var i = 0; i < payload.Length; i++)
+ {
+ payload[i] = (byte)(i & 0xFF);
+ }
+
+ var entry = new CacheEntry
+ {
+ AbsoluteExpiration = SampleAbsolute,
+ SlidingExpirationTicks = SampleSlidingTicks,
+ Data = payload,
+ };
+
+ var result = Deserialize(Serialize(entry));
+
+ Assert.NotNull(result);
+ Assert.Equal(payload, result.Data);
+ }
+
+ [Fact]
+ public void Serialize_WritesVersionedHeaderAndRawPayload()
+ {
+ var entry = new CacheEntry
+ {
+ AbsoluteExpiration = SampleAbsolute,
+ SlidingExpirationTicks = SampleSlidingTicks,
+ Data = [10, 20, 30],
+ };
+
+ var bytes = Serialize(entry);
+
+ // [version:1][flags:1][absTicks:8][sldTicks:8][payload:3]
+ Assert.Equal(2 + 8 + 8 + 3, bytes.Length);
+ Assert.Equal(CacheEntryBinarySerializer.FormatVersion, bytes[0]);
+ Assert.Equal(0b0000_0011, bytes[1]);
+ Assert.Equal(SampleAbsolute.UtcTicks, BinaryPrimitives.ReadInt64LittleEndian(bytes.AsSpan(2, 8)));
+ Assert.Equal(SampleSlidingTicks, BinaryPrimitives.ReadInt64LittleEndian(bytes.AsSpan(10, 8)));
+ Assert.Equal(new byte[] { 10, 20, 30 }, bytes[18..]);
+ }
+
+ [Fact]
+ public void Serialize_WithoutExpiration_WritesTwoByteHeader()
+ {
+ var entry = new CacheEntry { Data = [42] };
+
+ var bytes = Serialize(entry);
+
+ Assert.Equal(3, bytes.Length);
+ Assert.Equal(CacheEntryBinarySerializer.FormatVersion, bytes[0]);
+ Assert.Equal(0, bytes[1]);
+ Assert.Equal(42, bytes[2]);
+ }
+
+ [Fact]
+ public void Deserialize_WrongVersion_ReturnsNull()
+ {
+ var bytes = Serialize(new CacheEntry { Data = [1, 2, 3] });
+ bytes[0] = (byte)(CacheEntryBinarySerializer.FormatVersion + 1);
+
+ Assert.Null(Deserialize(bytes));
+ }
+
+ [Fact]
+ public void Deserialize_LegacyJsonBytes_ReturnsNull()
+ {
+ // Old envelope format: a JSON object whose first byte is '{' (0x7B).
+ var legacy = Encoding.UTF8.GetBytes("{\"absexp\":null,\"sldexp\":null,\"data\":\"AQID\"}");
+
+ Assert.Null(Deserialize(legacy));
+ }
+
+ [Fact]
+ public void Deserialize_EmptyBuffer_ReturnsNull() =>
+ Assert.Null(Deserialize([]));
+
+ [Fact]
+ public void Deserialize_TruncatedExpirationField_ReturnsNull()
+ {
+ // Header claims an absolute expiration but only supplies 4 of the required 8 bytes.
+ byte[] truncated = [CacheEntryBinarySerializer.FormatVersion, 0b0000_0001, 1, 2, 3, 4];
+
+ Assert.Null(Deserialize(truncated));
+ }
+
+ [Fact]
+ public void Deserialize_UnknownFlagBits_ReturnsNull()
+ {
+ // A v1 entry with an undefined flag bit set (0b100) must fail closed rather than treat the
+ // remaining bytes as payload.
+ byte[] bytes = [CacheEntryBinarySerializer.FormatVersion, 0b0000_0100, 1, 2, 3];
+
+ Assert.Null(Deserialize(bytes));
+ }
+
+ [Fact]
+ public void Deserialize_OutOfRangeAbsoluteTicks_ReturnsNull()
+ {
+ // flags = HasAbsoluteExpiration, followed by a tick value beyond DateTimeOffset's range.
+ var bytes = new byte[2 + 8];
+ bytes[0] = CacheEntryBinarySerializer.FormatVersion;
+ bytes[1] = 0b0000_0001;
+ BinaryPrimitives.WriteInt64LittleEndian(bytes.AsSpan(2), long.MaxValue);
+
+ Assert.Null(Deserialize(bytes));
+ }
+
+ [Fact]
+ public void Deserialize_MultiSegmentSequence_RoundTrips()
+ {
+ var entry = new CacheEntry
+ {
+ AbsoluteExpiration = SampleAbsolute,
+ SlidingExpirationTicks = SampleSlidingTicks,
+ Data = [7, 8, 9, 10],
+ };
+ var bytes = Serialize(entry);
+
+ // Split mid-header so the reader must cross a segment boundary.
+ var sequence = CreateSegmented(bytes, splitAt: 5);
+ var result = _serializer.Deserialize(sequence);
+
+ Assert.NotNull(result);
+ Assert.Equal(entry.AbsoluteExpiration, result.AbsoluteExpiration);
+ Assert.Equal(entry.SlidingExpirationTicks, result.SlidingExpirationTicks);
+ Assert.Equal(entry.Data, result.Data);
+ }
+
+ [Fact]
+ public void AbsoluteExpiration_RoundTripsAsUtcInstant()
+ {
+ // A non-UTC offset must survive as the same instant, normalized to UTC.
+ var local = new DateTimeOffset(2030, 6, 1, 12, 0, 0, TimeSpan.FromHours(-5));
+ var entry = new CacheEntry { AbsoluteExpiration = local, Data = [1] };
+
+ var result = Deserialize(Serialize(entry));
+
+ Assert.NotNull(result);
+ Assert.Equal(local.UtcTicks, result.AbsoluteExpiration!.Value.UtcTicks);
+ Assert.Equal(TimeSpan.Zero, result.AbsoluteExpiration.Value.Offset);
+ Assert.Equal(local.UtcDateTime, result.AbsoluteExpiration.Value.UtcDateTime);
+ }
+
+ private static ReadOnlySequence CreateSegmented(byte[] data, int splitAt)
+ {
+ var first = new BufferSegment(data.AsMemory(0, splitAt));
+ var second = first.Append(data.AsMemory(splitAt));
+ return new ReadOnlySequence(first, 0, second, second.Memory.Length);
+ }
+
+ private byte[] Serialize(CacheEntry entry)
+ {
+ var writer = new ArrayBufferWriter();
+ _serializer.Serialize(writer, entry);
+ return writer.WrittenMemory.ToArray();
+ }
+
+ private CacheEntry? Deserialize(byte[] bytes) =>
+ _serializer.Deserialize(new ReadOnlySequence(bytes));
+
+ private sealed class BufferSegment : ReadOnlySequenceSegment
+ {
+ public BufferSegment(ReadOnlyMemory memory) => Memory = memory;
+
+ public BufferSegment Append(ReadOnlyMemory memory)
+ {
+ var segment = new BufferSegment(memory) { RunningIndex = RunningIndex + Memory.Length };
+ Next = segment;
+ return segment;
+ }
+ }
+}
diff --git a/util/Benchmarks/BenchmarkData.cs b/util/Benchmarks/BenchmarkData.cs
index 251012a..be834cc 100644
--- a/util/Benchmarks/BenchmarkData.cs
+++ b/util/Benchmarks/BenchmarkData.cs
@@ -33,7 +33,7 @@ public static byte[] CreatePayload(int size)
}
///
- /// Builds a JSON envelope with a payload of the requested size.
+ /// Builds a legacy JSON envelope with a payload of the requested size.
///
/// Payload size in bytes.
/// The populated .
@@ -43,4 +43,16 @@ public static byte[] CreatePayload(int size)
SlidingExpirationTicks = SlidingExpirationTicks,
Data = CreatePayload(size),
};
+
+ ///
+ /// Builds a binary-envelope with a payload of the requested size.
+ ///
+ /// Payload size in bytes.
+ /// The populated .
+ public static CacheEntry CreateBinaryEntry(int size) => new()
+ {
+ AbsoluteExpiration = AbsoluteExpiration,
+ SlidingExpirationTicks = SlidingExpirationTicks,
+ Data = CreatePayload(size),
+ };
}
diff --git a/util/Benchmarks/CacheEntrySerializationBenchmarks.cs b/util/Benchmarks/CacheEntrySerializationBenchmarks.cs
index 2c5db6e..405a9be 100644
--- a/util/Benchmarks/CacheEntrySerializationBenchmarks.cs
+++ b/util/Benchmarks/CacheEntrySerializationBenchmarks.cs
@@ -5,10 +5,9 @@
namespace CodeCargo.Nats.DistributedCache.Benchmarks;
///
-/// Baseline serialization benchmarks for the cache envelope. This measures the current
-/// JSON+base64 envelope; issue #37 adds a compact binary format and the head-to-head comparison.
-/// MemoryDiagnoser reports per-operation allocations; the --sizes report
-/// (see ) shows the stored byte counts.
+/// Compares the legacy JSON+base64 envelope against the compact binary framing for serializing and
+/// deserializing a . MemoryDiagnoser reports the per-operation
+/// allocations; the --sizes report (see ) shows the stored byte counts.
///
[MemoryDiagnoser]
public class CacheEntrySerializationBenchmarks
@@ -16,10 +15,14 @@ public class CacheEntrySerializationBenchmarks
private static readonly NatsJsonContextSerializer JsonEnvelopeSerializer =
new(BenchmarkJsonContext.Default);
+ private static readonly CacheEntryBinarySerializer BinaryEnvelopeSerializer = CacheEntryBinarySerializer.Default;
+
private readonly ArrayBufferWriter _writer = new();
private JsonCacheEntry _jsonEntry = null!;
+ private CacheEntry _binaryEntry = null!;
private ReadOnlySequence _jsonBytes;
+ private ReadOnlySequence _binaryBytes;
///
/// Gets or sets the size, in bytes, of the cached payload under test.
@@ -28,20 +31,22 @@ public class CacheEntrySerializationBenchmarks
public int PayloadSize { get; set; }
///
- /// Builds the sample entry and pre-serialized buffer used by the benchmarks.
+ /// Builds the sample entries and pre-serialized buffers used by the benchmarks.
///
[GlobalSetup]
public void Setup()
{
_jsonEntry = BenchmarkData.CreateJsonEntry(PayloadSize);
+ _binaryEntry = BenchmarkData.CreateBinaryEntry(PayloadSize);
_jsonBytes = ToSequence(JsonEnvelopeSerializer, _jsonEntry);
+ _binaryBytes = ToSequence(BinaryEnvelopeSerializer, _binaryEntry);
}
///
- /// Serializes the entry using the JSON envelope.
+ /// Serializes the entry using the legacy JSON envelope (baseline).
///
/// The number of bytes written.
- [Benchmark]
+ [Benchmark(Baseline = true)]
public int Json_Serialize()
{
_writer.Clear();
@@ -50,12 +55,31 @@ public int Json_Serialize()
}
///
- /// Deserializes a JSON envelope.
+ /// Serializes the entry using the compact binary framing.
+ ///
+ /// The number of bytes written.
+ [Benchmark]
+ public int Binary_Serialize()
+ {
+ _writer.Clear();
+ BinaryEnvelopeSerializer.Serialize(_writer, _binaryEntry);
+ return _writer.WrittenCount;
+ }
+
+ ///
+ /// Deserializes a JSON envelope (baseline read path).
///
/// The decoded entry.
[Benchmark]
public JsonCacheEntry? Json_Deserialize() => JsonEnvelopeSerializer.Deserialize(_jsonBytes);
+ ///
+ /// Deserializes a binary envelope.
+ ///
+ /// The decoded entry.
+ [Benchmark]
+ public CacheEntry? Binary_Deserialize() => BinaryEnvelopeSerializer.Deserialize(_binaryBytes);
+
private static ReadOnlySequence ToSequence(INatsSerialize serializer, T value)
{
var writer = new ArrayBufferWriter();
diff --git a/util/Benchmarks/README.md b/util/Benchmarks/README.md
index 5e9ce0e..16396af 100644
--- a/util/Benchmarks/README.md
+++ b/util/Benchmarks/README.md
@@ -1,11 +1,10 @@
# Benchmarks
-Micro-benchmarks for the `NatsCache` value envelope — the framing that wraps every cached payload
-together with its expiration metadata before it is stored in NATS KV.
+Micro-benchmarks comparing the cache envelope formats used by `NatsCache`:
-Today this measures the **JSON** envelope (payload base64-encoded inside a JSON object), establishing a
-baseline. Issue #37 replaces it with a compact binary framing and extends these benchmarks with a
-`Binary` variant for a side-by-side comparison.
+- **JSON** — the legacy `System.Text.Json` envelope (payload base64-encoded inside a JSON object).
+- **Binary** — the compact binary framing introduced in issue #37
+ (`[version:1][flags:1][absExpTicks:8?][sldExpTicks:8?][payload...]`).
These are in-memory serialize/deserialize benchmarks — **no NATS server or Docker required**.
@@ -35,9 +34,11 @@ dotnet run -c Release --project util/Benchmarks -f net10.0 -- --filter '*_Serial
## What is measured
-`CacheEntrySerializationBenchmarks` runs `Json_Serialize` / `Json_Deserialize` across
-`[Params(128, 1024, 8192, 65536, 262144)]` payload sizes (128 B up to 256 KiB — the practical range
-for a NATS-backed cache, whose default `max_payload` is 1 MB). `MemoryDiagnoser` reports `Allocated`
-and `Gen0`; the `--sizes` report shows the stored byte counts.
+`CacheEntrySerializationBenchmarks` runs `Json_Serialize` / `Binary_Serialize` and
+`Json_Deserialize` / `Binary_Deserialize` across `[Params(128, 1024, 8192, 65536, 262144)]` payload
+sizes (128 B up to 256 KiB — the practical range for a NATS-backed cache, whose default `max_payload`
+is 1 MB), with `Json_Serialize` as the baseline. `MemoryDiagnoser` reports `Allocated` and `Gen0`; the
+`--sizes` report shows the stored byte counts and the binary/JSON ratio.
-The JSON baseline is defined locally (`JsonCacheEntry`) so it stays fixed as the library evolves.
+The JSON baseline is defined locally (`JsonCacheEntry`) so it stays fixed even though the library no
+longer serializes with JSON.
diff --git a/util/Benchmarks/SizeReport.cs b/util/Benchmarks/SizeReport.cs
index ade64e4..5211c4c 100644
--- a/util/Benchmarks/SizeReport.cs
+++ b/util/Benchmarks/SizeReport.cs
@@ -4,29 +4,31 @@
namespace CodeCargo.Nats.DistributedCache.Benchmarks;
///
-/// Prints the serialized envelope size (in bytes) of the JSON format for each payload size, so the
-/// stored size can be inspected without running the full timing benchmarks. Issue #37 extends this
-/// with the binary format for a side-by-side comparison.
+/// Prints the serialized envelope size (in bytes) of the JSON and binary formats for each payload size,
+/// so the byte-size reduction can be inspected without running the full timing benchmarks.
///
internal static class SizeReport
{
private static readonly int[] PayloadSizes = [128, 1024, 8192, 65536, 262144];
///
- /// Writes the size table to the console.
+ /// Writes the size-comparison table to the console.
///
public static void Print()
{
var jsonSerializer = new NatsJsonContextSerializer(BenchmarkJsonContext.Default);
+ var binarySerializer = CacheEntryBinarySerializer.Default;
Console.WriteLine("Stored envelope size (bytes) — absolute + sliding expiration set");
- Console.WriteLine($"{"Payload",10} {"JSON",10}");
- Console.WriteLine(new string('-', 22));
+ Console.WriteLine($"{"Payload",10} {"JSON",10} {"Binary",10} {"Saved",10} {"Binary/JSON",12}");
+ Console.WriteLine(new string('-', 56));
foreach (var size in PayloadSizes)
{
var jsonSize = Measure(jsonSerializer, BenchmarkData.CreateJsonEntry(size));
- Console.WriteLine($"{size,10} {jsonSize,10}");
+ var binarySize = Measure(binarySerializer, BenchmarkData.CreateBinaryEntry(size));
+ var ratio = (double)binarySize / jsonSize;
+ Console.WriteLine($"{size,10} {jsonSize,10} {binarySize,10} {jsonSize - binarySize,10} {ratio,12:P1}");
}
}