From a97586dfaf68222b33b2782eee5f43368d2f1de3 Mon Sep 17 00:00:00 2001 From: eMelgooG Date: Thu, 28 May 2026 16:54:53 +0200 Subject: [PATCH 01/19] Avoid full-document string allocation in CosmosSystemTextJsonSerializer Both read paths in CosmosSystemTextJsonSerializer materialize the entire document as a System.String before parsing: 1. Binary path: cosmosObject.ToString() builds a UTF-16 JSON string from the UTF-8 bytes produced by IJsonWriter.GetResult() (via Utf8StringHelpers), only for JsonSerializer.Deserialize(string, ...) to re-parse that UTF-16. Fix: feed the UTF-8 ReadOnlyMemory directly into JsonSerializer.Deserialize(ReadOnlySpan, ...). 2. DeserializeStream helper: StreamReader.ReadToEnd() transcodes UTF-8 to a full UTF-16 string before Deserialize(string, ...). Fix: call JsonSerializer.Deserialize(Stream, ...) which feeds Utf8JsonReader directly from the UTF-8 byte stream. Both changes are semantically equivalent for Cosmos response bodies (UTF-8 JSON) and eliminate full-document string allocations that land on the Large Object Heap for non-trivial documents. Refs: https://github.com/Azure/azure-cosmos-dotnet-v3/pull/4652#discussion_r1737084941 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../src/Serializer/CosmosSystemTextJsonSerializer.cs | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/Microsoft.Azure.Cosmos/src/Serializer/CosmosSystemTextJsonSerializer.cs b/Microsoft.Azure.Cosmos/src/Serializer/CosmosSystemTextJsonSerializer.cs index 113f2a38c6..913c653076 100644 --- a/Microsoft.Azure.Cosmos/src/Serializer/CosmosSystemTextJsonSerializer.cs +++ b/Microsoft.Azure.Cosmos/src/Serializer/CosmosSystemTextJsonSerializer.cs @@ -62,7 +62,9 @@ public override T FromStream(Stream stream) if (CosmosObject.TryCreateFromBuffer(content, out CosmosObject cosmosObject)) { - return System.Text.Json.JsonSerializer.Deserialize(cosmosObject.ToString(), this.jsonSerializerOptions); + IJsonWriter jsonWriter = JsonWriter.Create(JsonSerializationFormat.Text); + cosmosObject.WriteTo(jsonWriter); + return System.Text.Json.JsonSerializer.Deserialize(jsonWriter.GetResult().Span, this.jsonSerializerOptions); } else { @@ -133,8 +135,7 @@ public override string SerializeMemberName(MemberInfo memberInfo) private T DeserializeStream( Stream stream) { - using StreamReader reader = new (stream); - return System.Text.Json.JsonSerializer.Deserialize(reader.ReadToEnd(), this.jsonSerializerOptions); + return System.Text.Json.JsonSerializer.Deserialize(stream, this.jsonSerializerOptions); } } } From ba039b54ec340dd7b1e8caee362ce1960428bc29 Mon Sep 17 00:00:00 2001 From: eMelgooG Date: Thu, 28 May 2026 17:04:01 +0200 Subject: [PATCH 02/19] Add changelog entry for CosmosSystemTextJsonSerializer allocation fix Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- changelog.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/changelog.md b/changelog.md index 104ea54480..ffcf5b95ce 100644 --- a/changelog.md +++ b/changelog.md @@ -28,6 +28,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 #### Other Changes +- [#5908](https://github.com/Azure/azure-cosmos-dotnet-v3/pull/5908) Performance: Avoid full-document `string` allocation in `CosmosSystemTextJsonSerializer` read paths (binary and text). UTF-8 bytes are now fed directly into `Utf8JsonReader` via `JsonSerializer.Deserialize(Stream, ...)` and `Deserialize(ReadOnlySpan, ...)`, eliminating LOH pressure from per-read full-document string materialization. + ### [3.62.0-preview.0](https://www.nuget.org/packages/Microsoft.Azure.Cosmos/3.62.0-preview.0) - 2026-6-1 #### Features Added From d8aac58e086bb50db2aa37a9a9a856236e59ab2a Mon Sep 17 00:00:00 2001 From: eMelgooG Date: Thu, 28 May 2026 17:10:47 +0200 Subject: [PATCH 03/19] Expand changelog entry to cover CPU, transcoding, and bandwidth wins Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- changelog.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/changelog.md b/changelog.md index ffcf5b95ce..f01a4c8c0d 100644 --- a/changelog.md +++ b/changelog.md @@ -28,7 +28,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 #### Other Changes -- [#5908](https://github.com/Azure/azure-cosmos-dotnet-v3/pull/5908) Performance: Avoid full-document `string` allocation in `CosmosSystemTextJsonSerializer` read paths (binary and text). UTF-8 bytes are now fed directly into `Utf8JsonReader` via `JsonSerializer.Deserialize(Stream, ...)` and `Deserialize(ReadOnlySpan, ...)`, eliminating LOH pressure from per-read full-document string materialization. +- [#5908](https://github.com/Azure/azure-cosmos-dotnet-v3/pull/5908) Performance: Avoid full-document UTF-16 `string` allocation and redundant UTF-8↔UTF-16 transcoding in `CosmosSystemTextJsonSerializer` read paths (binary and text). UTF-8 bytes are now fed directly into `Utf8JsonReader` via `JsonSerializer.Deserialize(Stream, ...)` and `Deserialize(ReadOnlySpan, ...)`, reducing CPU, memory bandwidth, and GC pressure (including LOH pressure for larger documents). ### [3.62.0-preview.0](https://www.nuget.org/packages/Microsoft.Azure.Cosmos/3.62.0-preview.0) - 2026-6-1 From 2cadc2586c9b3f643247d195a44acee6944cc75a Mon Sep 17 00:00:00 2001 From: Emanuel Adrian Lucaci Date: Tue, 9 Jun 2026 18:47:11 +0200 Subject: [PATCH 04/19] Serializer: Pools binary transcode buffer to eliminate LOH allocations Builds on the read-path change: the binary read path in CosmosSystemTextJsonSerializer now rents its transcode buffer from the shared ArrayPool (opt-in JsonWriter.Create(pooled: true) + disposal in JsonMemoryWriter/JsonWriter) and returns it after deserialization. This removes the Large Object Heap (Gen2) collections that the plain new byte[] / Array.Resize transcode buffer incurred for larger documents, cutting allocations a further ~24% over the span path and stabilizing throughput/tail latency. Adds StjBinaryPooledBenchmark (team SDK config). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../src/Json/JsonMemoryWriter.cs | 34 ++++- .../src/Json/JsonWriter.JsonTextWriter.cs | 14 +- Microsoft.Azure.Cosmos/src/Json/JsonWriter.cs | 18 ++- .../CosmosSystemTextJsonSerializer.cs | 2 +- .../Json/StjBinaryPooledBenchmark.cs | 142 ++++++++++++++++++ changelog.md | 2 +- 6 files changed, 199 insertions(+), 13 deletions(-) create mode 100644 Microsoft.Azure.Cosmos/tests/Microsoft.Azure.Cosmos.Performance.Tests/Json/StjBinaryPooledBenchmark.cs diff --git a/Microsoft.Azure.Cosmos/src/Json/JsonMemoryWriter.cs b/Microsoft.Azure.Cosmos/src/Json/JsonMemoryWriter.cs index 044c57e781..aa4ecae595 100644 --- a/Microsoft.Azure.Cosmos/src/Json/JsonMemoryWriter.cs +++ b/Microsoft.Azure.Cosmos/src/Json/JsonMemoryWriter.cs @@ -5,14 +5,20 @@ namespace Microsoft.Azure.Cosmos.Json { using System; + using System.Buffers; - internal abstract class JsonMemoryWriter + internal abstract class JsonMemoryWriter : IDisposable { protected byte[] buffer; - protected JsonMemoryWriter(int initialCapacity = 256) + private readonly bool pooled; + + protected JsonMemoryWriter(int initialCapacity = 256, bool pooled = false) { - this.buffer = new byte[initialCapacity]; + this.pooled = pooled; + this.buffer = pooled + ? ArrayPool.Shared.Rent(initialCapacity) + : new byte[initialCapacity]; } public int Position @@ -53,7 +59,27 @@ private void Resize(int minNewSize) long newLength = minNewSize * 2; newLength = Math.Min(newLength, int.MaxValue); - Array.Resize(ref this.buffer, (int)newLength); + + if (this.pooled) + { + byte[] newBuffer = ArrayPool.Shared.Rent((int)newLength); + Array.Copy(this.buffer, newBuffer, this.Position); + ArrayPool.Shared.Return(this.buffer); + this.buffer = newBuffer; + } + else + { + Array.Resize(ref this.buffer, (int)newLength); + } + } + + public void Dispose() + { + if (this.pooled && this.buffer != null) + { + ArrayPool.Shared.Return(this.buffer); + this.buffer = null; + } } } } diff --git a/Microsoft.Azure.Cosmos/src/Json/JsonWriter.JsonTextWriter.cs b/Microsoft.Azure.Cosmos/src/Json/JsonWriter.JsonTextWriter.cs index 16c9a5284c..69301ed008 100644 --- a/Microsoft.Azure.Cosmos/src/Json/JsonWriter.JsonTextWriter.cs +++ b/Microsoft.Azure.Cosmos/src/Json/JsonWriter.JsonTextWriter.cs @@ -94,10 +94,16 @@ private sealed class JsonTextWriter : JsonWriter, IJsonTextWriterExtensions /// /// Initializes a new instance of the JsonTextWriter class. /// - public JsonTextWriter(int initialCapacity = 256) + public JsonTextWriter(int initialCapacity = 256, bool pooled = false) { this.firstValue = true; - this.jsonTextMemoryWriter = new JsonTextMemoryWriter(initialCapacity); + this.jsonTextMemoryWriter = new JsonTextMemoryWriter(initialCapacity, pooled); + } + + /// + public override void Dispose() + { + this.jsonTextMemoryWriter.Dispose(); } /// @@ -543,8 +549,8 @@ private sealed class JsonTextMemoryWriter : JsonMemoryWriter private static readonly StandardFormat doubleFormat = new StandardFormat( symbol: 'R'); - public JsonTextMemoryWriter(int initialCapacity = 256) - : base(initialCapacity) + public JsonTextMemoryWriter(int initialCapacity = 256, bool pooled = false) + : base(initialCapacity, pooled) { } diff --git a/Microsoft.Azure.Cosmos/src/Json/JsonWriter.cs b/Microsoft.Azure.Cosmos/src/Json/JsonWriter.cs index 0cfd50a852..0c79ac79db 100644 --- a/Microsoft.Azure.Cosmos/src/Json/JsonWriter.cs +++ b/Microsoft.Azure.Cosmos/src/Json/JsonWriter.cs @@ -19,7 +19,7 @@ namespace Microsoft.Azure.Cosmos.Json #else internal #endif - abstract partial class JsonWriter : IJsonWriter + abstract partial class JsonWriter : IJsonWriter, IDisposable { private const int MaxStackAlloc = 4 * 1024; @@ -49,16 +49,18 @@ protected JsonWriter() /// The write options the control the write behavior. /// Initial capacity to help avoid intermediary allocations. /// The dictionary to use for user string encoding. + /// Whether the writer should rent its internal buffer from the shared . When true, the writer must be disposed and the result of GetResult must not be used after disposal. Only honored for the text format. /// A JsonWriter that can write in a particular JsonSerializationFormat public static IJsonWriter Create( JsonSerializationFormat jsonSerializationFormat, JsonWriteOptions writeOptions = JsonWriteOptions.None, int initialCapacity = 256, - IJsonStringDictionary jsonStringDictionary = null) + IJsonStringDictionary jsonStringDictionary = null, + bool pooled = false) { return jsonSerializationFormat switch { - JsonSerializationFormat.Text => new JsonTextWriter(initialCapacity), + JsonSerializationFormat.Text => new JsonTextWriter(initialCapacity, pooled), JsonSerializationFormat.Binary => new JsonBinaryWriter( enableNumberArrays: writeOptions.HasFlag(JsonWriteOptions.EnableNumberArrays), enableUint64Values: writeOptions.HasFlag(JsonWriteOptions.EnableUInt64Values), @@ -252,5 +254,15 @@ public virtual void WriteNumberArray(IReadOnlyList values) /// public abstract ReadOnlyMemory GetResult(); + + /// + /// Releases any pooled buffers held by this writer. Writers created with + /// pooled: true return their rented buffer to the shared + /// ; otherwise this is a no-op. + /// The result of must not be used after disposal. + /// + public virtual void Dispose() + { + } } } diff --git a/Microsoft.Azure.Cosmos/src/Serializer/CosmosSystemTextJsonSerializer.cs b/Microsoft.Azure.Cosmos/src/Serializer/CosmosSystemTextJsonSerializer.cs index 913c653076..a66cae6e91 100644 --- a/Microsoft.Azure.Cosmos/src/Serializer/CosmosSystemTextJsonSerializer.cs +++ b/Microsoft.Azure.Cosmos/src/Serializer/CosmosSystemTextJsonSerializer.cs @@ -62,7 +62,7 @@ public override T FromStream(Stream stream) if (CosmosObject.TryCreateFromBuffer(content, out CosmosObject cosmosObject)) { - IJsonWriter jsonWriter = JsonWriter.Create(JsonSerializationFormat.Text); + using JsonWriter jsonWriter = (JsonWriter)JsonWriter.Create(JsonSerializationFormat.Text, pooled: true); cosmosObject.WriteTo(jsonWriter); return System.Text.Json.JsonSerializer.Deserialize(jsonWriter.GetResult().Span, this.jsonSerializerOptions); } diff --git a/Microsoft.Azure.Cosmos/tests/Microsoft.Azure.Cosmos.Performance.Tests/Json/StjBinaryPooledBenchmark.cs b/Microsoft.Azure.Cosmos/tests/Microsoft.Azure.Cosmos.Performance.Tests/Json/StjBinaryPooledBenchmark.cs new file mode 100644 index 0000000000..3e521f14ec --- /dev/null +++ b/Microsoft.Azure.Cosmos/tests/Microsoft.Azure.Cosmos.Performance.Tests/Json/StjBinaryPooledBenchmark.cs @@ -0,0 +1,142 @@ +// ---------------------------------------------------------------- +// Copyright (c) Microsoft Corporation. All rights reserved. +// ---------------------------------------------------------------- + +namespace Microsoft.Azure.Cosmos.Performance.Tests.Json +{ + using System.IO; + using System.Text; + using System.Text.Json; + using BenchmarkDotNet.Attributes; + using Microsoft.Azure.Cosmos.CosmosElements; + using Microsoft.Azure.Cosmos.Json; + using Microsoft.Azure.Cosmos.Tests.Json; + + /// + /// A/B/C micro-benchmark for the binary read path of + /// CosmosSystemTextJsonSerializer.FromStream, isolating the + /// transcode-buffer allocation. Uses the team's + /// (Op/s throughput, percentiles, MemoryDiagnoser + ThreadingDiagnoser). + /// + /// Each method mirrors the SDK binary branch exactly + /// (ReadAll -> TryCreateFromBuffer -> transcode -> deserialize): + /// Old = JsonSerializer.Deserialize<T>(cosmosObject.ToString()) // UTF-16 string + /// New = WriteTo(JsonWriter Text) + Deserialize<T>(ReadOnlySpan<byte>) // PR #5908 + /// Pooled = WriteTo(JsonWriter Text, pooled) + Deserialize + Dispose // this prototype + /// + [Config(typeof(SdkBenchmarkConfiguration))] + public class StjBinaryPooledBenchmark + { + private static readonly JsonSerializerOptions Options = new () + { + PropertyNameCaseInsensitive = true, + }; + + [Params(1, 100, 1000)] + public int DocumentCount; + + private CosmosObject cosmosObject; + + [GlobalSetup] + public void Setup() + { + string unit = File.ReadAllText("samplepayload.json"); + + StringBuilder sb = new (); + sb.Append("{\"id\":\"root\",\"items\":["); + for (int i = 0; i < this.DocumentCount; i++) + { + if (i > 0) + { + sb.Append(','); + } + + sb.Append(unit); + } + + sb.Append("]}"); + + byte[] binaryBuffer = JsonTestUtils.ConvertTextToBinary(sb.ToString()); + this.cosmosObject = CosmosObject.CreateFromBuffer(binaryBuffer); + } + + [Benchmark(Description = "Binary_Old (ToString -> Deserialize)", Baseline = true)] + public object Binary_Old() + { + string text = this.cosmosObject.ToString(); + return System.Text.Json.JsonSerializer.Deserialize(text, Options); + } + + [Benchmark(Description = "Binary_New (WriteTo -> Deserialize>)")] + public object Binary_New() + { + IJsonWriter jsonWriter = JsonWriter.Create(JsonSerializationFormat.Text); + this.cosmosObject.WriteTo(jsonWriter); + return System.Text.Json.JsonSerializer.Deserialize(jsonWriter.GetResult().Span, Options); + } + + [Benchmark(Description = "Binary_Pooled (WriteTo pooled -> Deserialize -> Dispose)")] + public object Binary_Pooled() + { + using JsonWriter jsonWriter = (JsonWriter)JsonWriter.Create(JsonSerializationFormat.Text, pooled: true); + this.cosmosObject.WriteTo(jsonWriter); + return System.Text.Json.JsonSerializer.Deserialize(jsonWriter.GetResult().Span, Options); + } + + private class FamilyRoot + { + public string Id { get; set; } + + public Family[] Items { get; set; } + } + + private class Family + { + public string Id { get; set; } + + public string LastName { get; set; } + + public Parent[] Parents { get; set; } + + public Child[] Children { get; set; } + + public Location Location { get; set; } + + public bool IsRegistered { get; set; } + } + + private class Parent + { + public string FirstName { get; set; } + + public string Relationship { get; set; } + } + + private class Child + { + public string FirstName { get; set; } + + public string Gender { get; set; } + + public int Grade { get; set; } + + public Pet[] Pets { get; set; } + } + + private class Pet + { + public string GivenName { get; set; } + + public string Type { get; set; } + } + + private class Location + { + public string State { get; set; } + + public string County { get; set; } + + public string City { get; set; } + } + } +} diff --git a/changelog.md b/changelog.md index f01a4c8c0d..f69b53e719 100644 --- a/changelog.md +++ b/changelog.md @@ -28,7 +28,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 #### Other Changes -- [#5908](https://github.com/Azure/azure-cosmos-dotnet-v3/pull/5908) Performance: Avoid full-document UTF-16 `string` allocation and redundant UTF-8↔UTF-16 transcoding in `CosmosSystemTextJsonSerializer` read paths (binary and text). UTF-8 bytes are now fed directly into `Utf8JsonReader` via `JsonSerializer.Deserialize(Stream, ...)` and `Deserialize(ReadOnlySpan, ...)`, reducing CPU, memory bandwidth, and GC pressure (including LOH pressure for larger documents). +- [#5908](https://github.com/Azure/azure-cosmos-dotnet-v3/pull/5908) Performance: Avoid full-document UTF-16 `string` allocation and redundant UTF-8↔UTF-16 transcoding in `CosmosSystemTextJsonSerializer` read paths (binary and text). UTF-8 bytes are now fed directly into `Utf8JsonReader` via `JsonSerializer.Deserialize(Stream, ...)` and `Deserialize(ReadOnlySpan, ...)`. The binary transcode buffer is additionally rented from the shared `ArrayPool`, eliminating Large Object Heap (Gen2) collections for larger documents. Reduces CPU, memory bandwidth, and GC pressure on every read. ### [3.62.0-preview.0](https://www.nuget.org/packages/Microsoft.Azure.Cosmos/3.62.0-preview.0) - 2026-6-1 From 97ae5610ef87b74210e34495614456a521859c58 Mon Sep 17 00:00:00 2001 From: Emanuel Adrian Lucaci Date: Tue, 9 Jun 2026 19:01:06 +0200 Subject: [PATCH 05/19] Test: Simplifies binary pooling benchmark to before/after Drops the intermediate span-only variant; the benchmark now compares only the original string path (Before) against the pooled span path (After). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Json/StjBinaryPooledBenchmark.cs | 17 ++++------------- 1 file changed, 4 insertions(+), 13 deletions(-) diff --git a/Microsoft.Azure.Cosmos/tests/Microsoft.Azure.Cosmos.Performance.Tests/Json/StjBinaryPooledBenchmark.cs b/Microsoft.Azure.Cosmos/tests/Microsoft.Azure.Cosmos.Performance.Tests/Json/StjBinaryPooledBenchmark.cs index 3e521f14ec..46847e9719 100644 --- a/Microsoft.Azure.Cosmos/tests/Microsoft.Azure.Cosmos.Performance.Tests/Json/StjBinaryPooledBenchmark.cs +++ b/Microsoft.Azure.Cosmos/tests/Microsoft.Azure.Cosmos.Performance.Tests/Json/StjBinaryPooledBenchmark.cs @@ -20,9 +20,8 @@ namespace Microsoft.Azure.Cosmos.Performance.Tests.Json /// /// Each method mirrors the SDK binary branch exactly /// (ReadAll -> TryCreateFromBuffer -> transcode -> deserialize): - /// Old = JsonSerializer.Deserialize<T>(cosmosObject.ToString()) // UTF-16 string - /// New = WriteTo(JsonWriter Text) + Deserialize<T>(ReadOnlySpan<byte>) // PR #5908 - /// Pooled = WriteTo(JsonWriter Text, pooled) + Deserialize + Dispose // this prototype + /// Before = JsonSerializer.Deserialize<T>(cosmosObject.ToString()) // UTF-16 string + /// After = WriteTo(JsonWriter Text, pooled) + Deserialize<T>(ReadOnlySpan<byte>) + Dispose /// [Config(typeof(SdkBenchmarkConfiguration))] public class StjBinaryPooledBenchmark @@ -60,22 +59,14 @@ public void Setup() this.cosmosObject = CosmosObject.CreateFromBuffer(binaryBuffer); } - [Benchmark(Description = "Binary_Old (ToString -> Deserialize)", Baseline = true)] + [Benchmark(Description = "Before (ToString -> Deserialize)", Baseline = true)] public object Binary_Old() { string text = this.cosmosObject.ToString(); return System.Text.Json.JsonSerializer.Deserialize(text, Options); } - [Benchmark(Description = "Binary_New (WriteTo -> Deserialize>)")] - public object Binary_New() - { - IJsonWriter jsonWriter = JsonWriter.Create(JsonSerializationFormat.Text); - this.cosmosObject.WriteTo(jsonWriter); - return System.Text.Json.JsonSerializer.Deserialize(jsonWriter.GetResult().Span, Options); - } - - [Benchmark(Description = "Binary_Pooled (WriteTo pooled -> Deserialize -> Dispose)")] + [Benchmark(Description = "After (WriteTo pooled -> Deserialize> -> Dispose)")] public object Binary_Pooled() { using JsonWriter jsonWriter = (JsonWriter)JsonWriter.Create(JsonSerializationFormat.Text, pooled: true); From a734f17a909f16d86f6f845d71dfa0b768914767 Mon Sep 17 00:00:00 2001 From: Emanuel Adrian Lucaci Date: Tue, 9 Jun 2026 19:10:32 +0200 Subject: [PATCH 06/19] Test: Refactors pooling benchmark to clean MediumRun table with Op/s Switches from the verbose SdkBenchmarkConfiguration (percentiles, threading) to MediumRun + MemoryDiagnoser plus an Op/s throughput column, matching the read-path benchmark's concise output. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Json/StjBinaryPooledBenchmark.cs | 21 +++++++++++++++---- 1 file changed, 17 insertions(+), 4 deletions(-) diff --git a/Microsoft.Azure.Cosmos/tests/Microsoft.Azure.Cosmos.Performance.Tests/Json/StjBinaryPooledBenchmark.cs b/Microsoft.Azure.Cosmos/tests/Microsoft.Azure.Cosmos.Performance.Tests/Json/StjBinaryPooledBenchmark.cs index 46847e9719..3e0a47a7f7 100644 --- a/Microsoft.Azure.Cosmos/tests/Microsoft.Azure.Cosmos.Performance.Tests/Json/StjBinaryPooledBenchmark.cs +++ b/Microsoft.Azure.Cosmos/tests/Microsoft.Azure.Cosmos.Performance.Tests/Json/StjBinaryPooledBenchmark.cs @@ -8,24 +8,37 @@ namespace Microsoft.Azure.Cosmos.Performance.Tests.Json using System.Text; using System.Text.Json; using BenchmarkDotNet.Attributes; + using BenchmarkDotNet.Columns; + using BenchmarkDotNet.Configs; + using BenchmarkDotNet.Jobs; using Microsoft.Azure.Cosmos.CosmosElements; using Microsoft.Azure.Cosmos.Json; using Microsoft.Azure.Cosmos.Tests.Json; /// - /// A/B/C micro-benchmark for the binary read path of + /// Before/after micro-benchmark for the binary read path of /// CosmosSystemTextJsonSerializer.FromStream, isolating the - /// transcode-buffer allocation. Uses the team's - /// (Op/s throughput, percentiles, MemoryDiagnoser + ThreadingDiagnoser). + /// transcode-buffer allocation. MediumRun + MemoryDiagnoser, with an + /// Op/s column (throughput; higher = less CPU per operation). /// /// Each method mirrors the SDK binary branch exactly /// (ReadAll -> TryCreateFromBuffer -> transcode -> deserialize): /// Before = JsonSerializer.Deserialize<T>(cosmosObject.ToString()) // UTF-16 string /// After = WriteTo(JsonWriter Text, pooled) + Deserialize<T>(ReadOnlySpan<byte>) + Dispose /// - [Config(typeof(SdkBenchmarkConfiguration))] + [Config(typeof(MediumRunConfig))] + [MemoryDiagnoser] public class StjBinaryPooledBenchmark { + private class MediumRunConfig : ManualConfig + { + public MediumRunConfig() + { + this.AddJob(Job.MediumRun); + this.AddColumn(StatisticColumn.OperationsPerSecond); + } + } + private static readonly JsonSerializerOptions Options = new () { PropertyNameCaseInsensitive = true, From 9fe2ccc4b9e47ddcf02da38dc0b5d61d128dd0fd Mon Sep 17 00:00:00 2001 From: Emanuel Adrian Lucaci Date: Tue, 9 Jun 2026 19:12:28 +0200 Subject: [PATCH 07/19] Test: Adds CPU hardware counters to pooling benchmark Collects total cycles, cache misses, branch instructions and branch mispredictions via HardwareCounters (BenchmarkDotNet.Diagnostics.Windows). Requires an elevated console on x64; columns are omitted otherwise. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Json/StjBinaryPooledBenchmark.cs | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/Microsoft.Azure.Cosmos/tests/Microsoft.Azure.Cosmos.Performance.Tests/Json/StjBinaryPooledBenchmark.cs b/Microsoft.Azure.Cosmos/tests/Microsoft.Azure.Cosmos.Performance.Tests/Json/StjBinaryPooledBenchmark.cs index 3e0a47a7f7..c38db3af44 100644 --- a/Microsoft.Azure.Cosmos/tests/Microsoft.Azure.Cosmos.Performance.Tests/Json/StjBinaryPooledBenchmark.cs +++ b/Microsoft.Azure.Cosmos/tests/Microsoft.Azure.Cosmos.Performance.Tests/Json/StjBinaryPooledBenchmark.cs @@ -10,6 +10,7 @@ namespace Microsoft.Azure.Cosmos.Performance.Tests.Json using BenchmarkDotNet.Attributes; using BenchmarkDotNet.Columns; using BenchmarkDotNet.Configs; + using BenchmarkDotNet.Diagnosers; using BenchmarkDotNet.Jobs; using Microsoft.Azure.Cosmos.CosmosElements; using Microsoft.Azure.Cosmos.Json; @@ -21,6 +22,12 @@ namespace Microsoft.Azure.Cosmos.Performance.Tests.Json /// transcode-buffer allocation. MediumRun + MemoryDiagnoser, with an /// Op/s column (throughput; higher = less CPU per operation). /// + /// CPU hardware counters (cycles, cache misses, branch mispredictions) are + /// also collected. These require an ELEVATED (Administrator) console on + /// Windows; run from an admin shell, otherwise the counter columns are + /// silently omitted. (Hardware counters are x86/x64 only - they are not + /// available on Arm64 hosts.) + /// /// Each method mirrors the SDK binary branch exactly /// (ReadAll -> TryCreateFromBuffer -> transcode -> deserialize): /// Before = JsonSerializer.Deserialize<T>(cosmosObject.ToString()) // UTF-16 string @@ -28,6 +35,11 @@ namespace Microsoft.Azure.Cosmos.Performance.Tests.Json /// [Config(typeof(MediumRunConfig))] [MemoryDiagnoser] + [HardwareCounters( + HardwareCounter.TotalCycles, + HardwareCounter.CacheMisses, + HardwareCounter.BranchInstructions, + HardwareCounter.BranchMispredictions)] public class StjBinaryPooledBenchmark { private class MediumRunConfig : ManualConfig From c8631903fcddf85429f47e4a919fe4d99b93f81c Mon Sep 17 00:00:00 2001 From: Emanuel Adrian Lucaci Date: Tue, 9 Jun 2026 19:15:12 +0200 Subject: [PATCH 08/19] Test: Removes hardware counters from pooling benchmark Hardware PMU counters are unavailable under Hyper-V/VBS (and on Arm64), so they only emit errors. Op/s remains the portable CPU/throughput signal. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Json/StjBinaryPooledBenchmark.cs | 12 ------------ 1 file changed, 12 deletions(-) diff --git a/Microsoft.Azure.Cosmos/tests/Microsoft.Azure.Cosmos.Performance.Tests/Json/StjBinaryPooledBenchmark.cs b/Microsoft.Azure.Cosmos/tests/Microsoft.Azure.Cosmos.Performance.Tests/Json/StjBinaryPooledBenchmark.cs index c38db3af44..3e0a47a7f7 100644 --- a/Microsoft.Azure.Cosmos/tests/Microsoft.Azure.Cosmos.Performance.Tests/Json/StjBinaryPooledBenchmark.cs +++ b/Microsoft.Azure.Cosmos/tests/Microsoft.Azure.Cosmos.Performance.Tests/Json/StjBinaryPooledBenchmark.cs @@ -10,7 +10,6 @@ namespace Microsoft.Azure.Cosmos.Performance.Tests.Json using BenchmarkDotNet.Attributes; using BenchmarkDotNet.Columns; using BenchmarkDotNet.Configs; - using BenchmarkDotNet.Diagnosers; using BenchmarkDotNet.Jobs; using Microsoft.Azure.Cosmos.CosmosElements; using Microsoft.Azure.Cosmos.Json; @@ -22,12 +21,6 @@ namespace Microsoft.Azure.Cosmos.Performance.Tests.Json /// transcode-buffer allocation. MediumRun + MemoryDiagnoser, with an /// Op/s column (throughput; higher = less CPU per operation). /// - /// CPU hardware counters (cycles, cache misses, branch mispredictions) are - /// also collected. These require an ELEVATED (Administrator) console on - /// Windows; run from an admin shell, otherwise the counter columns are - /// silently omitted. (Hardware counters are x86/x64 only - they are not - /// available on Arm64 hosts.) - /// /// Each method mirrors the SDK binary branch exactly /// (ReadAll -> TryCreateFromBuffer -> transcode -> deserialize): /// Before = JsonSerializer.Deserialize<T>(cosmosObject.ToString()) // UTF-16 string @@ -35,11 +28,6 @@ namespace Microsoft.Azure.Cosmos.Performance.Tests.Json /// [Config(typeof(MediumRunConfig))] [MemoryDiagnoser] - [HardwareCounters( - HardwareCounter.TotalCycles, - HardwareCounter.CacheMisses, - HardwareCounter.BranchInstructions, - HardwareCounter.BranchMispredictions)] public class StjBinaryPooledBenchmark { private class MediumRunConfig : ManualConfig From 245665cd5ff6ddec5f77d464fe5226813e43c37d Mon Sep 17 00:00:00 2001 From: Emanuel Adrian Lucaci Date: Tue, 9 Jun 2026 19:27:04 +0200 Subject: [PATCH 09/19] Test: Adds text before/after rows and Op/s to pooling benchmark Restores the original 12-row table layout (Binary + Text, before/after) with short labels, adds an Op/s throughput column as the portable CPU signal, and drops the verbose per-method descriptions. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Json/StjBinaryPooledBenchmark.cs | 45 ++++++++++++++----- 1 file changed, 34 insertions(+), 11 deletions(-) diff --git a/Microsoft.Azure.Cosmos/tests/Microsoft.Azure.Cosmos.Performance.Tests/Json/StjBinaryPooledBenchmark.cs b/Microsoft.Azure.Cosmos/tests/Microsoft.Azure.Cosmos.Performance.Tests/Json/StjBinaryPooledBenchmark.cs index 3e0a47a7f7..94ca27624f 100644 --- a/Microsoft.Azure.Cosmos/tests/Microsoft.Azure.Cosmos.Performance.Tests/Json/StjBinaryPooledBenchmark.cs +++ b/Microsoft.Azure.Cosmos/tests/Microsoft.Azure.Cosmos.Performance.Tests/Json/StjBinaryPooledBenchmark.cs @@ -16,15 +16,19 @@ namespace Microsoft.Azure.Cosmos.Performance.Tests.Json using Microsoft.Azure.Cosmos.Tests.Json; /// - /// Before/after micro-benchmark for the binary read path of - /// CosmosSystemTextJsonSerializer.FromStream, isolating the - /// transcode-buffer allocation. MediumRun + MemoryDiagnoser, with an - /// Op/s column (throughput; higher = less CPU per operation). + /// Before/after micro-benchmark for both read paths of + /// CosmosSystemTextJsonSerializer.FromStream. MediumRun + + /// MemoryDiagnoser, with an Op/s column (throughput; higher = less CPU + /// per operation). /// - /// Each method mirrors the SDK binary branch exactly - /// (ReadAll -> TryCreateFromBuffer -> transcode -> deserialize): - /// Before = JsonSerializer.Deserialize<T>(cosmosObject.ToString()) // UTF-16 string - /// After = WriteTo(JsonWriter Text, pooled) + Deserialize<T>(ReadOnlySpan<byte>) + Dispose + /// Each method mirrors the SDK branch exactly: + /// Binary_Old = JsonSerializer.Deserialize<T>(cosmosObject.ToString()) // UTF-16 string + /// Binary_New = WriteTo(JsonWriter Text, pooled) + Deserialize<T>(ReadOnlySpan<byte>) + Dispose // span + ArrayPool + /// Text_Old = new StreamReader(stream).ReadToEnd() + Deserialize<T>(string) // UTF-16 string + /// Text_New = Deserialize<T>(stream) // streamed, no buffer + /// + /// Note: the text path has no transcode buffer, so there is nothing to pool; + /// its "New" is the stream optimization only. /// [Config(typeof(MediumRunConfig))] [MemoryDiagnoser] @@ -48,6 +52,7 @@ public MediumRunConfig() public int DocumentCount; private CosmosObject cosmosObject; + private byte[] textUtf8; [GlobalSetup] public void Setup() @@ -67,19 +72,22 @@ public void Setup() } sb.Append("]}"); + string json = sb.ToString(); + + this.textUtf8 = Encoding.UTF8.GetBytes(json); - byte[] binaryBuffer = JsonTestUtils.ConvertTextToBinary(sb.ToString()); + byte[] binaryBuffer = JsonTestUtils.ConvertTextToBinary(json); this.cosmosObject = CosmosObject.CreateFromBuffer(binaryBuffer); } - [Benchmark(Description = "Before (ToString -> Deserialize)", Baseline = true)] + [Benchmark(Description = "Binary Before")] public object Binary_Old() { string text = this.cosmosObject.ToString(); return System.Text.Json.JsonSerializer.Deserialize(text, Options); } - [Benchmark(Description = "After (WriteTo pooled -> Deserialize> -> Dispose)")] + [Benchmark(Description = "Binary After")] public object Binary_Pooled() { using JsonWriter jsonWriter = (JsonWriter)JsonWriter.Create(JsonSerializationFormat.Text, pooled: true); @@ -87,6 +95,21 @@ public object Binary_Pooled() return System.Text.Json.JsonSerializer.Deserialize(jsonWriter.GetResult().Span, Options); } + [Benchmark(Description = "Text Before")] + public object Text_Old() + { + using MemoryStream stream = new (this.textUtf8, writable: false); + using StreamReader reader = new (stream); + return System.Text.Json.JsonSerializer.Deserialize(reader.ReadToEnd(), Options); + } + + [Benchmark(Description = "Text After")] + public object Text_New() + { + using MemoryStream stream = new (this.textUtf8, writable: false); + return System.Text.Json.JsonSerializer.Deserialize(stream, Options); + } + private class FamilyRoot { public string Id { get; set; } From ef30579788b4aebdf4e3362c9b782938c41e69d5 Mon Sep 17 00:00:00 2001 From: Emanuel Adrian Lucaci Date: Wed, 10 Jun 2026 13:00:17 +0200 Subject: [PATCH 10/19] Test: Renames pooling benchmark rows to STJ / STJ + Binary Clarifies that all rows use the STJ serializer; binary rows additionally exercise the binary->text transcode. Trims verbose header/doc comments. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Json/StjBinaryPooledBenchmark.cs | 28 ++++--------------- 1 file changed, 5 insertions(+), 23 deletions(-) diff --git a/Microsoft.Azure.Cosmos/tests/Microsoft.Azure.Cosmos.Performance.Tests/Json/StjBinaryPooledBenchmark.cs b/Microsoft.Azure.Cosmos/tests/Microsoft.Azure.Cosmos.Performance.Tests/Json/StjBinaryPooledBenchmark.cs index 94ca27624f..41762f2f1a 100644 --- a/Microsoft.Azure.Cosmos/tests/Microsoft.Azure.Cosmos.Performance.Tests/Json/StjBinaryPooledBenchmark.cs +++ b/Microsoft.Azure.Cosmos/tests/Microsoft.Azure.Cosmos.Performance.Tests/Json/StjBinaryPooledBenchmark.cs @@ -1,7 +1,3 @@ -// ---------------------------------------------------------------- -// Copyright (c) Microsoft Corporation. All rights reserved. -// ---------------------------------------------------------------- - namespace Microsoft.Azure.Cosmos.Performance.Tests.Json { using System.IO; @@ -15,21 +11,7 @@ namespace Microsoft.Azure.Cosmos.Performance.Tests.Json using Microsoft.Azure.Cosmos.Json; using Microsoft.Azure.Cosmos.Tests.Json; - /// - /// Before/after micro-benchmark for both read paths of - /// CosmosSystemTextJsonSerializer.FromStream. MediumRun + - /// MemoryDiagnoser, with an Op/s column (throughput; higher = less CPU - /// per operation). - /// - /// Each method mirrors the SDK branch exactly: - /// Binary_Old = JsonSerializer.Deserialize<T>(cosmosObject.ToString()) // UTF-16 string - /// Binary_New = WriteTo(JsonWriter Text, pooled) + Deserialize<T>(ReadOnlySpan<byte>) + Dispose // span + ArrayPool - /// Text_Old = new StreamReader(stream).ReadToEnd() + Deserialize<T>(string) // UTF-16 string - /// Text_New = Deserialize<T>(stream) // streamed, no buffer - /// - /// Note: the text path has no transcode buffer, so there is nothing to pool; - /// its "New" is the stream optimization only. - /// + // Before/after benchmark for both read paths [Config(typeof(MediumRunConfig))] [MemoryDiagnoser] public class StjBinaryPooledBenchmark @@ -80,14 +62,14 @@ public void Setup() this.cosmosObject = CosmosObject.CreateFromBuffer(binaryBuffer); } - [Benchmark(Description = "Binary Before")] + [Benchmark(Description = "STJ + Binary before")] public object Binary_Old() { string text = this.cosmosObject.ToString(); return System.Text.Json.JsonSerializer.Deserialize(text, Options); } - [Benchmark(Description = "Binary After")] + [Benchmark(Description = "STJ + Binary after")] public object Binary_Pooled() { using JsonWriter jsonWriter = (JsonWriter)JsonWriter.Create(JsonSerializationFormat.Text, pooled: true); @@ -95,7 +77,7 @@ public object Binary_Pooled() return System.Text.Json.JsonSerializer.Deserialize(jsonWriter.GetResult().Span, Options); } - [Benchmark(Description = "Text Before")] + [Benchmark(Description = "STJ before")] public object Text_Old() { using MemoryStream stream = new (this.textUtf8, writable: false); @@ -103,7 +85,7 @@ public object Text_Old() return System.Text.Json.JsonSerializer.Deserialize(reader.ReadToEnd(), Options); } - [Benchmark(Description = "Text After")] + [Benchmark(Description = "STJ after")] public object Text_New() { using MemoryStream stream = new (this.textUtf8, writable: false); From 6b20ec4d51a35447806d815e9316205d41b89e24 Mon Sep 17 00:00:00 2001 From: Emanuel Adrian Lucaci Date: Wed, 10 Jun 2026 13:06:40 +0200 Subject: [PATCH 11/19] Serializer: Refactors IJsonWriter to IDisposable to drop downcast Makes IJsonWriter extend IDisposable so JsonWriter.Create's result can be used with 'using' directly, removing the (JsonWriter) downcast in the serializer and benchmark. Restores the benchmark file copyright header. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- Microsoft.Azure.Cosmos/src/Json/IJsonWriter.cs | 2 +- Microsoft.Azure.Cosmos/src/Json/JsonWriter.cs | 2 +- .../src/Serializer/CosmosSystemTextJsonSerializer.cs | 2 +- .../Json/StjBinaryPooledBenchmark.cs | 6 +++++- 4 files changed, 8 insertions(+), 4 deletions(-) diff --git a/Microsoft.Azure.Cosmos/src/Json/IJsonWriter.cs b/Microsoft.Azure.Cosmos/src/Json/IJsonWriter.cs index 94228f9776..cef88fb5d8 100644 --- a/Microsoft.Azure.Cosmos/src/Json/IJsonWriter.cs +++ b/Microsoft.Azure.Cosmos/src/Json/IJsonWriter.cs @@ -15,7 +15,7 @@ namespace Microsoft.Azure.Cosmos.Json #else internal #endif - interface IJsonWriter + interface IJsonWriter : IDisposable { /// /// Gets the SerializationFormat of the JsonWriter. diff --git a/Microsoft.Azure.Cosmos/src/Json/JsonWriter.cs b/Microsoft.Azure.Cosmos/src/Json/JsonWriter.cs index 0c79ac79db..a9d973853b 100644 --- a/Microsoft.Azure.Cosmos/src/Json/JsonWriter.cs +++ b/Microsoft.Azure.Cosmos/src/Json/JsonWriter.cs @@ -19,7 +19,7 @@ namespace Microsoft.Azure.Cosmos.Json #else internal #endif - abstract partial class JsonWriter : IJsonWriter, IDisposable + abstract partial class JsonWriter : IJsonWriter { private const int MaxStackAlloc = 4 * 1024; diff --git a/Microsoft.Azure.Cosmos/src/Serializer/CosmosSystemTextJsonSerializer.cs b/Microsoft.Azure.Cosmos/src/Serializer/CosmosSystemTextJsonSerializer.cs index a66cae6e91..425f60470d 100644 --- a/Microsoft.Azure.Cosmos/src/Serializer/CosmosSystemTextJsonSerializer.cs +++ b/Microsoft.Azure.Cosmos/src/Serializer/CosmosSystemTextJsonSerializer.cs @@ -62,7 +62,7 @@ public override T FromStream(Stream stream) if (CosmosObject.TryCreateFromBuffer(content, out CosmosObject cosmosObject)) { - using JsonWriter jsonWriter = (JsonWriter)JsonWriter.Create(JsonSerializationFormat.Text, pooled: true); + using IJsonWriter jsonWriter = JsonWriter.Create(JsonSerializationFormat.Text, pooled: true); cosmosObject.WriteTo(jsonWriter); return System.Text.Json.JsonSerializer.Deserialize(jsonWriter.GetResult().Span, this.jsonSerializerOptions); } diff --git a/Microsoft.Azure.Cosmos/tests/Microsoft.Azure.Cosmos.Performance.Tests/Json/StjBinaryPooledBenchmark.cs b/Microsoft.Azure.Cosmos/tests/Microsoft.Azure.Cosmos.Performance.Tests/Json/StjBinaryPooledBenchmark.cs index 41762f2f1a..d3bc4eea14 100644 --- a/Microsoft.Azure.Cosmos/tests/Microsoft.Azure.Cosmos.Performance.Tests/Json/StjBinaryPooledBenchmark.cs +++ b/Microsoft.Azure.Cosmos/tests/Microsoft.Azure.Cosmos.Performance.Tests/Json/StjBinaryPooledBenchmark.cs @@ -1,3 +1,7 @@ +// ---------------------------------------------------------------- +// Copyright (c) Microsoft Corporation. All rights reserved. +// ---------------------------------------------------------------- + namespace Microsoft.Azure.Cosmos.Performance.Tests.Json { using System.IO; @@ -72,7 +76,7 @@ public object Binary_Old() [Benchmark(Description = "STJ + Binary after")] public object Binary_Pooled() { - using JsonWriter jsonWriter = (JsonWriter)JsonWriter.Create(JsonSerializationFormat.Text, pooled: true); + using IJsonWriter jsonWriter = JsonWriter.Create(JsonSerializationFormat.Text, pooled: true); this.cosmosObject.WriteTo(jsonWriter); return System.Text.Json.JsonSerializer.Deserialize(jsonWriter.GetResult().Span, Options); } From 2935847d71ced483cb893e4a40ee87917fde8f95 Mon Sep 17 00:00:00 2001 From: Emanuel Adrian Lucaci Date: Wed, 10 Jun 2026 13:27:28 +0200 Subject: [PATCH 12/19] Json: Forwards Dispose in JsonBinaryWriter for symmetric buffer release JsonBinaryWriter now overrides Dispose to release its JsonMemoryWriter buffer, matching JsonTextWriter. Harmless today (binary is never pooled) but future-proofs binary pooling and removes the disposal asymmetry. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../src/Json/JsonWriter.JsonBinaryWriter.cs | 6 ++++++ Microsoft.Azure.Cosmos/src/Json/JsonWriter.cs | 2 +- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/Microsoft.Azure.Cosmos/src/Json/JsonWriter.JsonBinaryWriter.cs b/Microsoft.Azure.Cosmos/src/Json/JsonWriter.JsonBinaryWriter.cs index 22bad028f2..39f1d80351 100644 --- a/Microsoft.Azure.Cosmos/src/Json/JsonWriter.JsonBinaryWriter.cs +++ b/Microsoft.Azure.Cosmos/src/Json/JsonWriter.JsonBinaryWriter.cs @@ -269,6 +269,12 @@ private enum RawValueType : byte /// private readonly JsonBinaryMemoryWriter binaryWriter; + /// + public override void Dispose() + { + this.binaryWriter.Dispose(); + } + /// /// With binary encoding all the JSON elements are length prefixed, /// unfortunately the caller of this class only provides what tokens to write. diff --git a/Microsoft.Azure.Cosmos/src/Json/JsonWriter.cs b/Microsoft.Azure.Cosmos/src/Json/JsonWriter.cs index a9d973853b..f5172bec50 100644 --- a/Microsoft.Azure.Cosmos/src/Json/JsonWriter.cs +++ b/Microsoft.Azure.Cosmos/src/Json/JsonWriter.cs @@ -256,7 +256,7 @@ public virtual void WriteNumberArray(IReadOnlyList values) public abstract ReadOnlyMemory GetResult(); /// - /// Releases any pooled buffers held by this writer. Writers created with + /// Releases any pooled buffer held by this writer. Writers created with /// pooled: true return their rented buffer to the shared /// ; otherwise this is a no-op. /// The result of must not be used after disposal. From 1f3cbc4a96761b468c43c1d4cd7f754522e47e21 Mon Sep 17 00:00:00 2001 From: Emanuel Adrian Lucaci Date: Wed, 10 Jun 2026 13:44:01 +0200 Subject: [PATCH 13/19] Serializer: Adds binary read-path unit test and trims doc comments Adds TestFromStreamBinaryFormat which round-trips a POCO through a binary CloneableStream, covering the pooled binary transcode branch in FromStream. Shortens the pooled/Dispose doc comments to match the terse project style. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- Microsoft.Azure.Cosmos/src/Json/JsonWriter.cs | 7 ++-- .../CosmosSystemTextJsonSerializerTest.cs | 36 +++++++++++++++++++ 2 files changed, 38 insertions(+), 5 deletions(-) diff --git a/Microsoft.Azure.Cosmos/src/Json/JsonWriter.cs b/Microsoft.Azure.Cosmos/src/Json/JsonWriter.cs index f5172bec50..c8417ef1f0 100644 --- a/Microsoft.Azure.Cosmos/src/Json/JsonWriter.cs +++ b/Microsoft.Azure.Cosmos/src/Json/JsonWriter.cs @@ -49,7 +49,7 @@ protected JsonWriter() /// The write options the control the write behavior. /// Initial capacity to help avoid intermediary allocations. /// The dictionary to use for user string encoding. - /// Whether the writer should rent its internal buffer from the shared . When true, the writer must be disposed and the result of GetResult must not be used after disposal. Only honored for the text format. + /// Whether to rent the internal buffer from the shared . When true, the writer must be disposed and the GetResult span is invalid after disposal. Text format only. /// A JsonWriter that can write in a particular JsonSerializationFormat public static IJsonWriter Create( JsonSerializationFormat jsonSerializationFormat, @@ -256,10 +256,7 @@ public virtual void WriteNumberArray(IReadOnlyList values) public abstract ReadOnlyMemory GetResult(); /// - /// Releases any pooled buffer held by this writer. Writers created with - /// pooled: true return their rented buffer to the shared - /// ; otherwise this is a no-op. - /// The result of must not be used after disposal. + /// Returns any pooled buffer to the shared ; otherwise a no-op. /// public virtual void Dispose() { diff --git a/Microsoft.Azure.Cosmos/tests/Microsoft.Azure.Cosmos.Tests/Json/CosmosSystemTextJsonSerializerTest.cs b/Microsoft.Azure.Cosmos/tests/Microsoft.Azure.Cosmos.Tests/Json/CosmosSystemTextJsonSerializerTest.cs index 5756b70547..1d3f1428ef 100644 --- a/Microsoft.Azure.Cosmos/tests/Microsoft.Azure.Cosmos.Tests/Json/CosmosSystemTextJsonSerializerTest.cs +++ b/Microsoft.Azure.Cosmos/tests/Microsoft.Azure.Cosmos.Tests/Json/CosmosSystemTextJsonSerializerTest.cs @@ -7,6 +7,7 @@ using System.Reflection; using System.Text.Json; using Microsoft.Azure.Cosmos.Tests.Poco.STJ; + using Microsoft.Azure.Documents; using Microsoft.VisualStudio.TestTools.UnitTesting; [TestClass] @@ -240,5 +241,40 @@ public void TestFromStreamWithBaseStreamType() Assert.AreSame(memoryStream, result); } + [TestMethod] + public void TestFromStreamBinaryFormat() + { + // Arrange - build a binary-encoded CloneableStream to exercise the pooled binary read path. + BinaryRoundTripDoc original = new() { Id = "abc", Name = "widget", Count = 42 }; + string json; + using (Stream textStream = this.stjSerializer.ToStream(original)) + using (StreamReader reader = new(textStream)) + { + json = reader.ReadToEnd(); + } + + byte[] binary = JsonTestUtils.ConvertTextToBinary(json); + using CloneableStream binaryStream = new( + internalStream: new MemoryStream(binary, index: 0, count: binary.Length, writable: false, publiclyVisible: true), + allowUnsafeDataAccess: true); + + // Act. + BinaryRoundTripDoc result = this.stjSerializer.FromStream(binaryStream); + + // Assert - binary path yields the same object as the text path. + Assert.IsNotNull(result); + Assert.AreEqual(original.Id, result.Id); + Assert.AreEqual(original.Name, result.Name); + Assert.AreEqual(original.Count, result.Count); + } + + private sealed class BinaryRoundTripDoc + { + public string Id { get; set; } + + public string Name { get; set; } + + public int Count { get; set; } + } } } From 6e774c2957ad3fbf1f81d090377f9e3a9808cb7c Mon Sep 17 00:00:00 2001 From: Emanuel Adrian Lucaci Date: Wed, 10 Jun 2026 13:55:53 +0200 Subject: [PATCH 14/19] Test: Strengthens binary read-path test to pin branch and cover buffer growth Asserts the binary-format marker and TryCreateFromBuffer preconditions so the test fails if FromStream stops taking the pooled branch, and adds a large-payload DataRow that forces JsonMemoryWriter.Resize (pooled rent/copy/return). Also reword the Dispose doc comment to plain English. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- Microsoft.Azure.Cosmos/src/Json/JsonWriter.cs | 2 +- .../Json/CosmosSystemTextJsonSerializerTest.cs | 16 +++++++++++++--- 2 files changed, 14 insertions(+), 4 deletions(-) diff --git a/Microsoft.Azure.Cosmos/src/Json/JsonWriter.cs b/Microsoft.Azure.Cosmos/src/Json/JsonWriter.cs index c8417ef1f0..153beeb2ed 100644 --- a/Microsoft.Azure.Cosmos/src/Json/JsonWriter.cs +++ b/Microsoft.Azure.Cosmos/src/Json/JsonWriter.cs @@ -256,7 +256,7 @@ public virtual void WriteNumberArray(IReadOnlyList values) public abstract ReadOnlyMemory GetResult(); /// - /// Returns any pooled buffer to the shared ; otherwise a no-op. + /// Releases the rented buffer back to the shared . Does nothing when not pooled. /// public virtual void Dispose() { diff --git a/Microsoft.Azure.Cosmos/tests/Microsoft.Azure.Cosmos.Tests/Json/CosmosSystemTextJsonSerializerTest.cs b/Microsoft.Azure.Cosmos/tests/Microsoft.Azure.Cosmos.Tests/Json/CosmosSystemTextJsonSerializerTest.cs index 1d3f1428ef..a068f92bbb 100644 --- a/Microsoft.Azure.Cosmos/tests/Microsoft.Azure.Cosmos.Tests/Json/CosmosSystemTextJsonSerializerTest.cs +++ b/Microsoft.Azure.Cosmos/tests/Microsoft.Azure.Cosmos.Tests/Json/CosmosSystemTextJsonSerializerTest.cs @@ -6,6 +6,8 @@ using System.Linq; using System.Reflection; using System.Text.Json; + using Microsoft.Azure.Cosmos.CosmosElements; + using Microsoft.Azure.Cosmos.Json; using Microsoft.Azure.Cosmos.Tests.Poco.STJ; using Microsoft.Azure.Documents; using Microsoft.VisualStudio.TestTools.UnitTesting; @@ -241,11 +243,13 @@ public void TestFromStreamWithBaseStreamType() Assert.AreSame(memoryStream, result); } - [TestMethod] - public void TestFromStreamBinaryFormat() + [DataTestMethod] + [DataRow(3)] // small payload: single rented buffer, no Resize + [DataRow(2000)] // large payload: forces JsonMemoryWriter.Resize (pooled rent/copy/return) + public void TestFromStreamBinaryFormat(int nameLength) { // Arrange - build a binary-encoded CloneableStream to exercise the pooled binary read path. - BinaryRoundTripDoc original = new() { Id = "abc", Name = "widget", Count = 42 }; + BinaryRoundTripDoc original = new() { Id = "abc", Name = new string('x', nameLength), Count = 42 }; string json; using (Stream textStream = this.stjSerializer.ToStream(original)) using (StreamReader reader = new(textStream)) @@ -254,6 +258,12 @@ public void TestFromStreamBinaryFormat() } byte[] binary = JsonTestUtils.ConvertTextToBinary(json); + + // Pin the conditions that route FromStream into the pooled binary branch, so the test + // fails loudly instead of silently falling back to the text path if they ever regress. + Assert.AreEqual((byte)JsonSerializationFormat.Binary, binary[0]); + Assert.IsTrue(CosmosObject.TryCreateFromBuffer(binary, out _)); + using CloneableStream binaryStream = new( internalStream: new MemoryStream(binary, index: 0, count: binary.Length, writable: false, publiclyVisible: true), allowUnsafeDataAccess: true); From 9cb13e27dc0668fde1970a7097ef5fb93c8cc6ee Mon Sep 17 00:00:00 2001 From: Emanuel Adrian Lucaci Date: Wed, 10 Jun 2026 14:02:40 +0200 Subject: [PATCH 15/19] Json: Asserts pooled is only requested for the Text format JsonWriter.Create silently ignored pooled:true for non-Text formats. Adds a Debug.Assert so the unsupported combination is caught in debug/test builds instead of being a silent no-op. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- Microsoft.Azure.Cosmos/src/Json/JsonWriter.cs | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/Microsoft.Azure.Cosmos/src/Json/JsonWriter.cs b/Microsoft.Azure.Cosmos/src/Json/JsonWriter.cs index 153beeb2ed..c239c2900b 100644 --- a/Microsoft.Azure.Cosmos/src/Json/JsonWriter.cs +++ b/Microsoft.Azure.Cosmos/src/Json/JsonWriter.cs @@ -5,6 +5,7 @@ namespace Microsoft.Azure.Cosmos.Json { using System; using System.Collections.Generic; + using System.Diagnostics; using System.Globalization; using System.Text; using Microsoft.Azure.Cosmos.Core.Utf8; @@ -58,6 +59,10 @@ public static IJsonWriter Create( IJsonStringDictionary jsonStringDictionary = null, bool pooled = false) { + Debug.Assert( + !pooled || jsonSerializationFormat == JsonSerializationFormat.Text, + "Buffer pooling is only supported for the Text format."); + return jsonSerializationFormat switch { JsonSerializationFormat.Text => new JsonTextWriter(initialCapacity, pooled), From 3d50d61a5b8ea5fe05d5d4f4dddaf96b6f04e1e3 Mon Sep 17 00:00:00 2001 From: Emanuel Adrian Lucaci Date: Wed, 10 Jun 2026 14:04:18 +0200 Subject: [PATCH 16/19] Json: Clarifies pooled GetResult span lifetime in doc A pooled writer's GetResult span is invalidated by the next write (a Resize returns the backing array to the pool), not only by disposal. Updates the pooled param doc accordingly. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- Microsoft.Azure.Cosmos/src/Json/JsonWriter.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Microsoft.Azure.Cosmos/src/Json/JsonWriter.cs b/Microsoft.Azure.Cosmos/src/Json/JsonWriter.cs index c239c2900b..f4df6bff6f 100644 --- a/Microsoft.Azure.Cosmos/src/Json/JsonWriter.cs +++ b/Microsoft.Azure.Cosmos/src/Json/JsonWriter.cs @@ -50,7 +50,7 @@ protected JsonWriter() /// The write options the control the write behavior. /// Initial capacity to help avoid intermediary allocations. /// The dictionary to use for user string encoding. - /// Whether to rent the internal buffer from the shared . When true, the writer must be disposed and the GetResult span is invalid after disposal. Text format only. + /// Whether to rent the internal buffer from the shared . When true, the writer must be disposed and the GetResult span is invalid after the next write or disposal. Text format only. /// A JsonWriter that can write in a particular JsonSerializationFormat public static IJsonWriter Create( JsonSerializationFormat jsonSerializationFormat, From 616f6c778f48883d87b528fa8bfee1e3b09b379e Mon Sep 17 00:00:00 2001 From: Emanuel Adrian Lucaci Date: Wed, 10 Jun 2026 14:11:55 +0200 Subject: [PATCH 17/19] Json: Removes dead Dispose override from JsonBinaryWriter The binary writer is never pooled (enforced by the pooled-Text-only assert), so its Dispose forwarded to a no-op. Remove it; JsonBinaryWriter now inherits the base no-op Dispose. Only JsonTextWriter (the pooled writer) overrides it. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../src/Json/JsonWriter.JsonBinaryWriter.cs | 6 ------ 1 file changed, 6 deletions(-) diff --git a/Microsoft.Azure.Cosmos/src/Json/JsonWriter.JsonBinaryWriter.cs b/Microsoft.Azure.Cosmos/src/Json/JsonWriter.JsonBinaryWriter.cs index 39f1d80351..22bad028f2 100644 --- a/Microsoft.Azure.Cosmos/src/Json/JsonWriter.JsonBinaryWriter.cs +++ b/Microsoft.Azure.Cosmos/src/Json/JsonWriter.JsonBinaryWriter.cs @@ -269,12 +269,6 @@ private enum RawValueType : byte /// private readonly JsonBinaryMemoryWriter binaryWriter; - /// - public override void Dispose() - { - this.binaryWriter.Dispose(); - } - /// /// With binary encoding all the JSON elements are length prefixed, /// unfortunately the caller of this class only provides what tokens to write. From cbf03ce3f4181e2aebd80a157fe2d137a14b16ca Mon Sep 17 00:00:00 2001 From: Emanuel Adrian Lucaci Date: Wed, 10 Jun 2026 19:57:19 +0200 Subject: [PATCH 18/19] Test: Adds serialization edge-case coverage across text and binary read paths Adds parity tests that run the same payload through both the text-stream and pooled-binary FromStream paths and assert they agree: tricky strings (unicode, surrogate pairs, escapes, control chars, empty), numeric extremes (long/int/ double min-max, fractions), nulls/empty collections, and a comprehensive mixed document. Targets the UTF-8<->UTF-16 transcoding the change touches. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../CosmosSystemTextJsonSerializerTest.cs | 212 ++++++++++++++++++ 1 file changed, 212 insertions(+) diff --git a/Microsoft.Azure.Cosmos/tests/Microsoft.Azure.Cosmos.Tests/Json/CosmosSystemTextJsonSerializerTest.cs b/Microsoft.Azure.Cosmos/tests/Microsoft.Azure.Cosmos.Tests/Json/CosmosSystemTextJsonSerializerTest.cs index a068f92bbb..9eda052824 100644 --- a/Microsoft.Azure.Cosmos/tests/Microsoft.Azure.Cosmos.Tests/Json/CosmosSystemTextJsonSerializerTest.cs +++ b/Microsoft.Azure.Cosmos/tests/Microsoft.Azure.Cosmos.Tests/Json/CosmosSystemTextJsonSerializerTest.cs @@ -5,6 +5,7 @@ using System.IO; using System.Linq; using System.Reflection; + using System.Text; using System.Text.Json; using Microsoft.Azure.Cosmos.CosmosElements; using Microsoft.Azure.Cosmos.Json; @@ -278,6 +279,217 @@ public void TestFromStreamBinaryFormat(int nameLength) Assert.AreEqual(original.Count, result.Count); } + [DataTestMethod] + [DataRow("ascii simple value")] + [DataRow("")] + [DataRow("quotes \" backslash \\ and slash /")] + [DataRow("whitespace\ttab\r\nnewline")] + [DataRow("accents: café ñ über Ångström")] + [DataRow("cjk: 日本語 中文 한국어")] + [DataRow("emoji surrogate pairs: \uD83D\uDE00\uD83C\uDF89\uD835\uDD4F")] + [DataRow("control chars: \u0001\u0002\u001F end")] + [DataRow("literal escape sequence text: \\u0041 \\n \\t")] + public void TestStringValueRoundTripAcrossFormats(string value) + { + StringHolder original = new() { Value = value }; + string json = this.Serialize(original); + + Assert.AreEqual(value, this.DeserializeViaTextPath(json).Value); + Assert.AreEqual(value, this.DeserializeViaBinaryPath(json).Value); + } + + [TestMethod] + public void TestNumericEdgeCasesAcrossFormats() + { + NumberHolder original = new() + { + MaxLong = long.MaxValue, + MinLong = long.MinValue, + MaxInt = int.MaxValue, + MinInt = int.MinValue, + MaxDouble = double.MaxValue, + MinDouble = double.MinValue, + NegativeFraction = -123456.789, + SmallFraction = 0.000000123, + Zero = 0, + NegativeZero = -0.0, + }; + string json = this.Serialize(original); + + foreach (NumberHolder result in new[] + { + this.DeserializeViaTextPath(json), + this.DeserializeViaBinaryPath(json), + }) + { + Assert.AreEqual(original.MaxLong, result.MaxLong); + Assert.AreEqual(original.MinLong, result.MinLong); + Assert.AreEqual(original.MaxInt, result.MaxInt); + Assert.AreEqual(original.MinInt, result.MinInt); + Assert.AreEqual(original.MaxDouble, result.MaxDouble); + Assert.AreEqual(original.MinDouble, result.MinDouble); + Assert.AreEqual(original.NegativeFraction, result.NegativeFraction); + Assert.AreEqual(original.SmallFraction, result.SmallFraction); + Assert.AreEqual(original.Zero, result.Zero); + } + } + + [TestMethod] + public void TestNullAndEmptyAcrossFormats() + { + ComplexDoc original = new() + { + NullText = null, + EmptyText = string.Empty, + EmptyList = new List(), + NullList = null, + Nested = null, + }; + string json = this.Serialize(original); + + foreach (ComplexDoc result in new[] + { + this.DeserializeViaTextPath(json), + this.DeserializeViaBinaryPath(json), + }) + { + Assert.IsNull(result.NullText); + Assert.AreEqual(string.Empty, result.EmptyText); + Assert.IsNotNull(result.EmptyList); + Assert.AreEqual(0, result.EmptyList.Count); + Assert.IsNull(result.Nested); + } + } + + [TestMethod] + public void TestComprehensiveRoundTripAcrossFormats() + { + ComplexDoc original = new() + { + NullText = null, + EmptyText = string.Empty, + Text = "mixed café 日本語 \uD83D\uDE00 \"quote\" \\slash\\", + Flag = true, + When = new DateTime(2026, 6, 10, 13, 45, 59, DateTimeKind.Utc), + Identifier = Guid.Parse("3f7686c0-8cca-5292-e25a-511be5205e05"), + Numbers = new List { -1, 0, 1, int.MaxValue, int.MinValue }, + EmptyList = new List(), + Nested = new Address { City = "Seattle", Zip = "98052" }, + Addresses = new List
+ { + new() { City = "Redmond", Zip = "98052" }, + new() { City = "São Paulo", Zip = "01000" }, + }, + }; + string json = this.Serialize(original); + + foreach (ComplexDoc result in new[] + { + this.DeserializeViaTextPath(json), + this.DeserializeViaBinaryPath(json), + }) + { + Assert.IsNull(result.NullText); + Assert.AreEqual(original.EmptyText, result.EmptyText); + Assert.AreEqual(original.Text, result.Text); + Assert.AreEqual(original.Flag, result.Flag); + Assert.AreEqual(original.When, result.When); + Assert.AreEqual(original.Identifier, result.Identifier); + CollectionAssert.AreEqual(original.Numbers, result.Numbers); + Assert.AreEqual(0, result.EmptyList.Count); + Assert.AreEqual(original.Nested.City, result.Nested.City); + Assert.AreEqual(original.Nested.Zip, result.Nested.Zip); + Assert.AreEqual(original.Addresses.Count, result.Addresses.Count); + Assert.AreEqual(original.Addresses[1].City, result.Addresses[1].City); + } + } + + private string Serialize(T value) + { + using Stream stream = this.stjSerializer.ToStream(value); + using StreamReader reader = new(stream); + return reader.ReadToEnd(); + } + + // A plain (non-CloneableStream) MemoryStream routes FromStream through the text DeserializeStream path. + private T DeserializeViaTextPath(string json) + { + using MemoryStream stream = new(Encoding.UTF8.GetBytes(json), writable: false); + return this.stjSerializer.FromStream(stream); + } + + // A binary-format CloneableStream routes FromStream through the pooled binary transcode path. + private T DeserializeViaBinaryPath(string json) + { + byte[] binary = JsonTestUtils.ConvertTextToBinary(json); + Assert.AreEqual((byte)JsonSerializationFormat.Binary, binary[0]); + + using CloneableStream stream = new( + internalStream: new MemoryStream(binary, index: 0, count: binary.Length, writable: false, publiclyVisible: true), + allowUnsafeDataAccess: true); + return this.stjSerializer.FromStream(stream); + } + + private sealed class StringHolder + { + public string Value { get; set; } + } + + private sealed class NumberHolder + { + public long MaxLong { get; set; } + + public long MinLong { get; set; } + + public int MaxInt { get; set; } + + public int MinInt { get; set; } + + public double MaxDouble { get; set; } + + public double MinDouble { get; set; } + + public double NegativeFraction { get; set; } + + public double SmallFraction { get; set; } + + public int Zero { get; set; } + + public double NegativeZero { get; set; } + } + + private sealed class ComplexDoc + { + public string Text { get; set; } + + public string EmptyText { get; set; } + + public string NullText { get; set; } + + public bool Flag { get; set; } + + public DateTime When { get; set; } + + public Guid Identifier { get; set; } + + public List Numbers { get; set; } + + public List EmptyList { get; set; } + + public List NullList { get; set; } + + public Address Nested { get; set; } + + public List
Addresses { get; set; } + } + + private sealed class Address + { + public string City { get; set; } + + public string Zip { get; set; } + } + private sealed class BinaryRoundTripDoc { public string Id { get; set; } From b83ff127166fece9384cd54925fede477aeeefd1 Mon Sep 17 00:00:00 2001 From: Emanuel Adrian Lucaci Date: Wed, 10 Jun 2026 20:36:43 +0200 Subject: [PATCH 19/19] Test: Hardens edge-case tests per review feedback Adds the TryCreateFromBuffer guard to the binary-path helper so it cannot silently fall back to the text path, asserts NullList round-trips, and removes the unused NegativeZero field. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Json/CosmosSystemTextJsonSerializerTest.cs | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/Microsoft.Azure.Cosmos/tests/Microsoft.Azure.Cosmos.Tests/Json/CosmosSystemTextJsonSerializerTest.cs b/Microsoft.Azure.Cosmos/tests/Microsoft.Azure.Cosmos.Tests/Json/CosmosSystemTextJsonSerializerTest.cs index 9eda052824..4a8807586f 100644 --- a/Microsoft.Azure.Cosmos/tests/Microsoft.Azure.Cosmos.Tests/Json/CosmosSystemTextJsonSerializerTest.cs +++ b/Microsoft.Azure.Cosmos/tests/Microsoft.Azure.Cosmos.Tests/Json/CosmosSystemTextJsonSerializerTest.cs @@ -312,7 +312,6 @@ public void TestNumericEdgeCasesAcrossFormats() NegativeFraction = -123456.789, SmallFraction = 0.000000123, Zero = 0, - NegativeZero = -0.0, }; string json = this.Serialize(original); @@ -357,6 +356,7 @@ public void TestNullAndEmptyAcrossFormats() Assert.AreEqual(string.Empty, result.EmptyText); Assert.IsNotNull(result.EmptyList); Assert.AreEqual(0, result.EmptyList.Count); + Assert.IsNull(result.NullList); Assert.IsNull(result.Nested); } } @@ -422,7 +422,11 @@ private T DeserializeViaTextPath(string json) private T DeserializeViaBinaryPath(string json) { byte[] binary = JsonTestUtils.ConvertTextToBinary(json); + + // Pin the conditions that route FromStream into the pooled binary branch, so the test + // fails loudly instead of silently falling back to the text path if they ever regress. Assert.AreEqual((byte)JsonSerializationFormat.Binary, binary[0]); + Assert.IsTrue(CosmosObject.TryCreateFromBuffer(binary, out _)); using CloneableStream stream = new( internalStream: new MemoryStream(binary, index: 0, count: binary.Length, writable: false, publiclyVisible: true), @@ -454,8 +458,6 @@ private sealed class NumberHolder public double SmallFraction { get; set; } public int Zero { get; set; } - - public double NegativeZero { get; set; } } private sealed class ComplexDoc