From 6f16c66d2e0346245dd146474ba3f6d3ac2037f3 Mon Sep 17 00:00:00 2001 From: tobias-tengler <45513122+tobias-tengler@users.noreply.github.com> Date: Fri, 9 Oct 2026 13:49:02 +0000 Subject: [PATCH 1/5] [Fusion] Write forwarded variables once per fetch Variable value sets for entity fetches wrote every forwarded variable again for each entity. The forwarded variables are now written once per fetch and their bytes are copied into each entity's variables, while a single entity still writes them directly. --- .../Results/FetchResultStore.Pooling.cs | 7 ++ .../Execution/Results/FetchResultStore.cs | 70 +++++++++++++++---- .../Results/FetchResultStoreTests.cs | 49 +++++++++++++ src/HotChocolate/Json/src/Json/JsonWriter.cs | 28 ++++++++ 4 files changed, 141 insertions(+), 13 deletions(-) diff --git a/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Results/FetchResultStore.Pooling.cs b/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Results/FetchResultStore.Pooling.cs index 65b9076a100..e429fd07cec 100644 --- a/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Results/FetchResultStore.Pooling.cs +++ b/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Results/FetchResultStore.Pooling.cs @@ -124,6 +124,13 @@ internal void Clean(int maxCollectTargetRetainLength, int maxDictionaryRetainCap // reset variable writer (returns excess chunks, keeps the first) _variableWriter.Clean(); + // release the forwarded variable buffer if a large input made it grow + if (_forwardedVariableWriter is { Capacity: > MaxRetainedForwardedVariableCapacity }) + { + _forwardedVariableWriter.Dispose(); + _forwardedVariableWriter = null; + } + // clear collect target arrays to unroot CompositeResultDocument references; // if they grew too large during a burst, swap them for smaller ones. TrimOrClearBuffer(ref _collectTargetA, maxCollectTargetRetainLength); diff --git a/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Results/FetchResultStore.cs b/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Results/FetchResultStore.cs index 94e5e732bfa..f7a069fc34c 100644 --- a/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Results/FetchResultStore.cs +++ b/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Results/FetchResultStore.cs @@ -32,6 +32,7 @@ namespace HotChocolate.Fusion.Execution.Results; internal sealed partial class FetchResultStore : IDisposable { private const int MaxRetainedDataElementStagingLength = 1024; + private const int MaxRetainedForwardedVariableCapacity = 16 * 1024; private const int MaxStackAllocPathSegments = 32; private static readonly ArrayPool s_variableValuePool = ArrayPool.Shared; @@ -48,6 +49,7 @@ internal sealed partial class FetchResultStore : IDisposable private readonly ChunkedArrayWriter _variableWriter = new(); private readonly JsonWriter _jsonWriter; private readonly VariableDedupTable _variableDedupTable; + private PooledArrayWriter? _forwardedVariableWriter; private FusionSchemaDefinition _schema = default!; private IErrorHandler _errorHandler = default!; private Operation _operation = default!; @@ -1038,6 +1040,9 @@ private ImmutableArray BuildVariableValueSetsFromSnapshot( VariableValues[]? variableValueSets = null; var additionalPaths = new AdditionalPathAccumulator(); var nextIndex = 0; + var forwardedVariables = importedEntries.Length > 1 + ? WriteForwardedVariables(requestVariables) + : []; foreach (var importedEntry in importedEntries) { @@ -1050,12 +1055,7 @@ private ImmutableArray BuildVariableValueSetsFromSnapshot( var startPosition = _variableWriter.Position; _jsonWriter.WriteStartObject(); - for (var i = 0; i < requestVariables.Count; i++) - { - var field = requestVariables[i]; - _jsonWriter.WritePropertyName(field.Name.Value); - WriteValueNode(field.Value); - } + WriteRequestVariables(requestVariables, forwardedVariables); if (!TryWriteRequestedRequirementValues(importedEntry.Values, requiredData)) { @@ -1133,6 +1133,9 @@ private ImmutableArray BuildVariableValueSets( VariableValues[]? variableValueSets = null; var additionalPaths = new AdditionalPathAccumulator(); var nextIndex = 0; + var forwardedVariables = elements.Length > 1 + ? WriteForwardedVariables(requestVariables) + : []; foreach (var result in elements) { @@ -1142,13 +1145,7 @@ private ImmutableArray BuildVariableValueSets( var startPosition = _variableWriter.Position; _jsonWriter.WriteStartObject(); - // Write forwarded variables. - for (var i = 0; i < requestVariables.Count; i++) - { - var field = requestVariables[i]; - _jsonWriter.WritePropertyName(field.Name.Value); - WriteValueNode(field.Value); - } + WriteRequestVariables(requestVariables, forwardedVariables); // Write requirement fields. var failed = false; @@ -1783,6 +1780,52 @@ private void WriteRawJsonValue(ReadOnlySequence value) _jsonWriter.WriteRawValue(value.ToArray()); } + private void WriteRequestVariables( + IReadOnlyList requestVariables, + ReadOnlySpan forwardedVariables) + { + if (!forwardedVariables.IsEmpty) + { + _jsonWriter.WriteRawProperties(forwardedVariables); + return; + } + + for (var i = 0; i < requestVariables.Count; i++) + { + var field = requestVariables[i]; + _jsonWriter.WritePropertyName(field.Name.Value); + WriteValueNode(field.Value); + } + } + + // Caller must hold _lock. + private ReadOnlySpan WriteForwardedVariables(IReadOnlyList requestVariables) + { + if (requestVariables.Count == 0) + { + return []; + } + + var writer = _forwardedVariableWriter ??= new PooledArrayWriter(); + writer.Reset(); + + _jsonWriter.Reset(writer); + _jsonWriter.WriteStartObject(); + + for (var i = 0; i < requestVariables.Count; i++) + { + var field = requestVariables[i]; + _jsonWriter.WritePropertyName(field.Name.Value); + WriteValueNode(field.Value); + } + + _jsonWriter.WriteEndObject(); + + // the forwarded variables are returned as properties without the enclosing braces. + var written = writer.WrittenSpan; + return written[1..^1]; + } + private void WriteValueNode(IValueNode value) { switch (value) @@ -2498,6 +2541,7 @@ public void Dispose() _variableDedupTable.Dispose(); _variableWriter.Dispose(); + _forwardedVariableWriter?.Dispose(); _pathPool?.Dispose(); } diff --git a/src/HotChocolate/Fusion/test/Fusion.Execution.Tests/Execution/Results/FetchResultStoreTests.cs b/src/HotChocolate/Fusion/test/Fusion.Execution.Tests/Execution/Results/FetchResultStoreTests.cs index ebe54130586..ef26710ba1f 100644 --- a/src/HotChocolate/Fusion/test/Fusion.Execution.Tests/Execution/Results/FetchResultStoreTests.cs +++ b/src/HotChocolate/Fusion/test/Fusion.Execution.Tests/Execution/Results/FetchResultStoreTests.cs @@ -1542,6 +1542,55 @@ input FooKeyInput { """)); } + [Fact] + public void CreateVariableValueSets_Should_WriteForwardedVariablesForEveryEntity_When_RequestHasForwardedVariables() + { + // arrange + var schema = ComposeSchema( + """ + # name: test + type Query { + foos: [Foo] + } + + type Foo { + id: ID! + } + """); + + using var resultArena = new MemoryArena(); + using var sourceArena = new MemoryArena(); + using var store = CreateLiveStore( + schema, + "{ foos { id } }", + """{"data":{"foos":[{"id":"1"},{"id":"2"},{"id":"3"}]}}""", + resultArena, + sourceArena); + + // act + var result = store.CreateVariableValueSets( + SelectionPath.Root.AppendField("foos"), + [ + Field("first", new IntValueNode(10)), + Field("filter", new ObjectValueNode(Field("name", new StringValueNode("a\"b")))) + ], + [Requirement(schema, "__fusion_1_id", "id", new NamedTypeNode("ID"))]); + + // assert + result.Select(entry => Normalize(entry.Values)).MatchInlineSnapshots( + [ + """ + {"first":10,"filter":{"name":"a\u0022b"},"__fusion_1_id":"1"} + """, + """ + {"first":10,"filter":{"name":"a\u0022b"},"__fusion_1_id":"2"} + """, + """ + {"first":10,"filter":{"name":"a\u0022b"},"__fusion_1_id":"3"} + """ + ]); + } + [Fact] public void CreateVariableValueSets_Should_ResolveInvariantNames_When_SelectionSetOrdinalsDiffer() { diff --git a/src/HotChocolate/Json/src/Json/JsonWriter.cs b/src/HotChocolate/Json/src/Json/JsonWriter.cs index 3fc7180c1f9..ef2634f8c84 100644 --- a/src/HotChocolate/Json/src/Json/JsonWriter.cs +++ b/src/HotChocolate/Json/src/Json/JsonWriter.cs @@ -536,6 +536,34 @@ internal void WriteRawValueEnd(JsonTokenType tokenType) _tokenType = tokenType; } + /// + /// Writes raw UTF-8 JSON object properties into the current object without validation. + /// + /// + /// One or more comma separated properties without the enclosing braces. + /// + internal void WriteRawProperties(ReadOnlySpan utf8Properties) + { + Debug.Assert(!_indented, "Raw properties are only supported for minimized output."); + + var maxRequired = utf8Properties.Length + 1; // Optionally, 1 list separator + var bytesWritten = 0; + + var output = _writer.GetSpan(maxRequired); + + if (_currentDepth < 0) + { + output[bytesWritten++] = JsonConstants.Comma; + } + + utf8Properties.CopyTo(output[bytesWritten..]); + bytesWritten += utf8Properties.Length; + + _writer.Advance(bytesWritten); + + SetFlagToAddListSeparatorBeforeNextItem(); + } + /// /// Internal buffer used for deferred property name writes. /// From 1294822f51ab3a31a9c70cad84fa8b67eb88330a Mon Sep 17 00:00:00 2001 From: tobias-tengler <45513122+tobias-tengler@users.noreply.github.com> Date: Fri, 9 Oct 2026 16:04:57 +0000 Subject: [PATCH 2/5] [Fusion] Splice forwarded variables into multi-entity fetches Serialize the forwarded variables once per fetch and splice the bytes into every entity when a fetch has more than one entity. A single entity still writes them directly. The scratch buffer is an eager field that is trimmed like the other buffers, and the raw property write ignores an empty span and asserts its contract. --- .../Results/FetchResultStore.Pooling.cs | 6 +- .../Execution/Results/FetchResultStore.cs | 61 ++++----- .../Results/FetchResultStoreTests.cs | 121 ++++++++++++++++++ .../src/Json/HotChocolate.Text.Json.csproj | 1 + src/HotChocolate/Json/src/Json/JsonWriter.cs | 10 +- .../Json/test/Json.Tests/JsonWriterTests.cs | 60 +++++++++ 6 files changed, 222 insertions(+), 37 deletions(-) diff --git a/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Results/FetchResultStore.Pooling.cs b/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Results/FetchResultStore.Pooling.cs index e429fd07cec..a16f5857463 100644 --- a/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Results/FetchResultStore.Pooling.cs +++ b/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Results/FetchResultStore.Pooling.cs @@ -124,11 +124,11 @@ internal void Clean(int maxCollectTargetRetainLength, int maxDictionaryRetainCap // reset variable writer (returns excess chunks, keeps the first) _variableWriter.Clean(); - // release the forwarded variable buffer if a large input made it grow - if (_forwardedVariableWriter is { Capacity: > MaxRetainedForwardedVariableCapacity }) + // drop the forwarded variable buffer if a large input made it grow + if (_forwardedVariableWriter.Capacity > MaxRetainedForwardedVariableCapacity) { _forwardedVariableWriter.Dispose(); - _forwardedVariableWriter = null; + _forwardedVariableWriter = new PooledArrayWriter(); } // clear collect target arrays to unroot CompositeResultDocument references; diff --git a/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Results/FetchResultStore.cs b/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Results/FetchResultStore.cs index f7a069fc34c..1abcbad00b4 100644 --- a/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Results/FetchResultStore.cs +++ b/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Results/FetchResultStore.cs @@ -49,7 +49,7 @@ internal sealed partial class FetchResultStore : IDisposable private readonly ChunkedArrayWriter _variableWriter = new(); private readonly JsonWriter _jsonWriter; private readonly VariableDedupTable _variableDedupTable; - private PooledArrayWriter? _forwardedVariableWriter; + private PooledArrayWriter _forwardedVariableWriter = new(); private FusionSchemaDefinition _schema = default!; private IErrorHandler _errorHandler = default!; private Operation _operation = default!; @@ -1040,9 +1040,7 @@ private ImmutableArray BuildVariableValueSetsFromSnapshot( VariableValues[]? variableValueSets = null; var additionalPaths = new AdditionalPathAccumulator(); var nextIndex = 0; - var forwardedVariables = importedEntries.Length > 1 - ? WriteForwardedVariables(requestVariables) - : []; + var forwardedVariables = SerializeForwardedVariables(requestVariables, importedEntries.Length); foreach (var importedEntry in importedEntries) { @@ -1054,8 +1052,7 @@ private ImmutableArray BuildVariableValueSetsFromSnapshot( _jsonWriter.Reset(_variableWriter); var startPosition = _variableWriter.Position; _jsonWriter.WriteStartObject(); - - WriteRequestVariables(requestVariables, forwardedVariables); + WriteForwardedVariables(requestVariables, forwardedVariables); if (!TryWriteRequestedRequirementValues(importedEntry.Values, requiredData)) { @@ -1133,9 +1130,7 @@ private ImmutableArray BuildVariableValueSets( VariableValues[]? variableValueSets = null; var additionalPaths = new AdditionalPathAccumulator(); var nextIndex = 0; - var forwardedVariables = elements.Length > 1 - ? WriteForwardedVariables(requestVariables) - : []; + var forwardedVariables = SerializeForwardedVariables(requestVariables, elements.Length); foreach (var result in elements) { @@ -1144,8 +1139,7 @@ private ImmutableArray BuildVariableValueSets( _jsonWriter.Reset(_variableWriter); var startPosition = _variableWriter.Position; _jsonWriter.WriteStartObject(); - - WriteRequestVariables(requestVariables, forwardedVariables); + WriteForwardedVariables(requestVariables, forwardedVariables); // Write requirement fields. var failed = false; @@ -1780,50 +1774,51 @@ private void WriteRawJsonValue(ReadOnlySequence value) _jsonWriter.WriteRawValue(value.ToArray()); } - private void WriteRequestVariables( + // Caller must hold _lock. The returned span is valid until the next call and is empty + // when a single entity writes the forwarded variables directly. + private ReadOnlySpan SerializeForwardedVariables( IReadOnlyList requestVariables, - ReadOnlySpan forwardedVariables) + int entityCount) { - if (!forwardedVariables.IsEmpty) + if (entityCount < 2 || requestVariables.Count == 0) { - _jsonWriter.WriteRawProperties(forwardedVariables); - return; + return []; } + _forwardedVariableWriter.Reset(); + _jsonWriter.Reset(_forwardedVariableWriter); + _jsonWriter.WriteStartObject(); + for (var i = 0; i < requestVariables.Count; i++) { var field = requestVariables[i]; _jsonWriter.WritePropertyName(field.Name.Value); WriteValueNode(field.Value); } + + _jsonWriter.WriteEndObject(); + _jsonWriter.Reset(_variableWriter); + + // strip the enclosing braces so the properties splice into another object. + return _forwardedVariableWriter.WrittenSpan[1..^1]; } - // Caller must hold _lock. - private ReadOnlySpan WriteForwardedVariables(IReadOnlyList requestVariables) + private void WriteForwardedVariables( + IReadOnlyList requestVariables, + ReadOnlySpan serialized) { - if (requestVariables.Count == 0) + if (!serialized.IsEmpty) { - return []; + _jsonWriter.WriteRawProperties(serialized); + return; } - var writer = _forwardedVariableWriter ??= new PooledArrayWriter(); - writer.Reset(); - - _jsonWriter.Reset(writer); - _jsonWriter.WriteStartObject(); - for (var i = 0; i < requestVariables.Count; i++) { var field = requestVariables[i]; _jsonWriter.WritePropertyName(field.Name.Value); WriteValueNode(field.Value); } - - _jsonWriter.WriteEndObject(); - - // the forwarded variables are returned as properties without the enclosing braces. - var written = writer.WrittenSpan; - return written[1..^1]; } private void WriteValueNode(IValueNode value) @@ -2541,7 +2536,7 @@ public void Dispose() _variableDedupTable.Dispose(); _variableWriter.Dispose(); - _forwardedVariableWriter?.Dispose(); + _forwardedVariableWriter.Dispose(); _pathPool?.Dispose(); } diff --git a/src/HotChocolate/Fusion/test/Fusion.Execution.Tests/Execution/Results/FetchResultStoreTests.cs b/src/HotChocolate/Fusion/test/Fusion.Execution.Tests/Execution/Results/FetchResultStoreTests.cs index ef26710ba1f..fd5f1ef3cc1 100644 --- a/src/HotChocolate/Fusion/test/Fusion.Execution.Tests/Execution/Results/FetchResultStoreTests.cs +++ b/src/HotChocolate/Fusion/test/Fusion.Execution.Tests/Execution/Results/FetchResultStoreTests.cs @@ -1591,6 +1591,127 @@ type Foo { ]); } + [Fact] + public void CreateVariableValueSets_Should_WriteForwardedVariables_When_RequestHasSingleEntity() + { + // arrange + var schema = ComposeSchema( + """ + # name: test + type Query { + foos: [Foo] + } + + type Foo { + id: ID! + } + """); + + using var resultArena = new MemoryArena(); + using var sourceArena = new MemoryArena(); + using var store = CreateLiveStore( + schema, + "{ foos { id } }", + """{"data":{"foos":[{"id":"1"}]}}""", + resultArena, + sourceArena); + + // act + var result = store.CreateVariableValueSets( + SelectionPath.Root.AppendField("foos"), + [ + Field("first", new IntValueNode(10)), + Field("filter", new ObjectValueNode(Field("name", new StringValueNode("a\"b")))) + ], + [Requirement(schema, "__fusion_1_id", "id", new NamedTypeNode("ID"))]); + + // assert + var entry = Assert.Single(result); + Normalize(entry.Values).MatchInlineSnapshot( + """ + {"first":10,"filter":{"name":"a\u0022b"},"__fusion_1_id":"1"} + """); + } + + [Fact] + public void CreateVariableValueSets_Should_WriteForwardedVariablesForRemainingEntities_When_FirstEntityFailsRequirement() + { + // arrange + var schema = ComposeSchema( + """ + # name: test + type Query { + foos: [Foo] + } + + type Foo { + id: ID + } + """); + + using var resultArena = new MemoryArena(); + using var sourceArena = new MemoryArena(); + using var store = CreateLiveStore( + schema, + "{ foos { id } }", + """{"data":{"foos":[{"id":null},{"id":"2"},{"id":"3"}]}}""", + resultArena, + sourceArena); + + // act + var result = store.CreateVariableValueSets( + SelectionPath.Root.AppendField("foos"), + [Field("first", new IntValueNode(10))], + [Requirement(schema, "__fusion_1_id", "id", new NonNullTypeNode(new NamedTypeNode("ID")))]); + + // assert + result.Select(entry => Normalize(entry.Values)).MatchInlineSnapshots( + [ + """ + {"first":10,"__fusion_1_id":"2"} + """, + """ + {"first":10,"__fusion_1_id":"3"} + """ + ]); + } + + [Fact] + public void CreateVariableValueSetsFromSnapshot_Should_WriteForwardedVariablesForEveryEntry_When_MultipleEntriesAreImported() + { + // arrange + using var source = new FetchResultStore(); + using var target = new FetchResultStore(); + + var first = CreateVariableValues(source, Path(1), Field("__fusion_1_id", new StringValueNode("1"))); + var second = CreateVariableValues(source, Path(2), Field("__fusion_1_id", new StringValueNode("2"))); + var third = CreateVariableValues(source, Path(3), Field("__fusion_1_id", new StringValueNode("3"))); + + // act + var result = target.CreateVariableValueSetsFromSnapshot( + [first, second, third], + ImportedKeys("__fusion_1_id"), + [ + Field("first", new IntValueNode(10)), + Field("filter", new ObjectValueNode(Field("name", new StringValueNode("a\"b")))) + ], + [Requirement("__fusion_1_id")]); + + // assert + result.Select(entry => Normalize(entry.Values)).MatchInlineSnapshots( + [ + """ + {"first":10,"filter":{"name":"a\u0022b"},"__fusion_1_id":"1"} + """, + """ + {"first":10,"filter":{"name":"a\u0022b"},"__fusion_1_id":"2"} + """, + """ + {"first":10,"filter":{"name":"a\u0022b"},"__fusion_1_id":"3"} + """ + ]); + } + [Fact] public void CreateVariableValueSets_Should_ResolveInvariantNames_When_SelectionSetOrdinalsDiffer() { diff --git a/src/HotChocolate/Json/src/Json/HotChocolate.Text.Json.csproj b/src/HotChocolate/Json/src/Json/HotChocolate.Text.Json.csproj index d801ea51031..ffb6c7b4319 100644 --- a/src/HotChocolate/Json/src/Json/HotChocolate.Text.Json.csproj +++ b/src/HotChocolate/Json/src/Json/HotChocolate.Text.Json.csproj @@ -16,6 +16,7 @@ + diff --git a/src/HotChocolate/Json/src/Json/JsonWriter.cs b/src/HotChocolate/Json/src/Json/JsonWriter.cs index ef2634f8c84..8a367a66e2e 100644 --- a/src/HotChocolate/Json/src/Json/JsonWriter.cs +++ b/src/HotChocolate/Json/src/Json/JsonWriter.cs @@ -538,13 +538,21 @@ internal void WriteRawValueEnd(JsonTokenType tokenType) /// /// Writes raw UTF-8 JSON object properties into the current object without validation. + /// Supported for minimized output only. An empty span writes nothing. /// /// - /// One or more comma separated properties without the enclosing braces. + /// Comma separated properties without the enclosing braces. /// internal void WriteRawProperties(ReadOnlySpan utf8Properties) { Debug.Assert(!_indented, "Raw properties are only supported for minimized output."); + Debug.Assert(_tokenType is not JsonTokenType.PropertyName && !HasDeferredPropertyName); + Debug.Assert(CurrentDepth > 0 && !IsInArray); + + if (utf8Properties.IsEmpty) + { + return; + } var maxRequired = utf8Properties.Length + 1; // Optionally, 1 list separator var bytesWritten = 0; diff --git a/src/HotChocolate/Json/test/Json.Tests/JsonWriterTests.cs b/src/HotChocolate/Json/test/Json.Tests/JsonWriterTests.cs index b9f8b734562..bac71ab169d 100644 --- a/src/HotChocolate/Json/test/Json.Tests/JsonWriterTests.cs +++ b/src/HotChocolate/Json/test/Json.Tests/JsonWriterTests.cs @@ -957,4 +957,64 @@ public void WriteStringValue_Should_FrameBareContent_When_SkipEscapingAndIndente """; Assert.Equal(expected, result); } + + [Fact] + public void WriteRawProperties_Should_WriteWithoutSeparator_When_PropertiesAreFirstInObject() + { + // arrange + var buffer = new ArrayBufferWriter(); + var options = new JsonWriterOptions { Indented = false, SkipValidation = true }; + var writer = new JsonWriter(buffer, options); + + // act + writer.WriteStartObject(); + writer.WriteRawProperties("\"a\":1,\"b\":\"x\""u8); + writer.WritePropertyName("c"); + writer.WriteBooleanValue(true); + writer.WriteEndObject(); + + // assert + var result = Encoding.UTF8.GetString(buffer.WrittenSpan); + Assert.Equal("""{"a":1,"b":"x","c":true}""", result); + } + + [Fact] + public void WriteRawProperties_Should_WriteSeparator_When_ObjectAlreadyHasProperties() + { + // arrange + var buffer = new ArrayBufferWriter(); + var options = new JsonWriterOptions { Indented = false, SkipValidation = true }; + var writer = new JsonWriter(buffer, options); + + // act + writer.WriteStartObject(); + writer.WritePropertyName("a"); + writer.WriteNumberValue(1); + writer.WriteRawProperties("\"b\":2"u8); + writer.WriteEndObject(); + + // assert + var result = Encoding.UTF8.GetString(buffer.WrittenSpan); + Assert.Equal("""{"a":1,"b":2}""", result); + } + + [Fact] + public void WriteRawProperties_Should_WriteNothing_When_PropertiesAreEmpty() + { + // arrange + var buffer = new ArrayBufferWriter(); + var options = new JsonWriterOptions { Indented = false, SkipValidation = true }; + var writer = new JsonWriter(buffer, options); + + // act + writer.WriteStartObject(); + writer.WriteRawProperties([]); + writer.WritePropertyName("a"); + writer.WriteNumberValue(1); + writer.WriteEndObject(); + + // assert + var result = Encoding.UTF8.GetString(buffer.WrittenSpan); + Assert.Equal("""{"a":1}""", result); + } } From 458cc4b7dce1e62d51045645ef21dd320dea33b7 Mon Sep 17 00:00:00 2001 From: tobias-tengler <45513122+tobias-tengler@users.noreply.github.com> Date: Fri, 9 Oct 2026 16:24:28 +0000 Subject: [PATCH 3/5] Cleanup --- .../Execution/Results/FetchResultStore.Pooling.cs | 7 ------- .../Fusion.Execution/Execution/Results/FetchResultStore.cs | 6 +----- 2 files changed, 1 insertion(+), 12 deletions(-) diff --git a/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Results/FetchResultStore.Pooling.cs b/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Results/FetchResultStore.Pooling.cs index a16f5857463..65b9076a100 100644 --- a/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Results/FetchResultStore.Pooling.cs +++ b/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Results/FetchResultStore.Pooling.cs @@ -124,13 +124,6 @@ internal void Clean(int maxCollectTargetRetainLength, int maxDictionaryRetainCap // reset variable writer (returns excess chunks, keeps the first) _variableWriter.Clean(); - // drop the forwarded variable buffer if a large input made it grow - if (_forwardedVariableWriter.Capacity > MaxRetainedForwardedVariableCapacity) - { - _forwardedVariableWriter.Dispose(); - _forwardedVariableWriter = new PooledArrayWriter(); - } - // clear collect target arrays to unroot CompositeResultDocument references; // if they grew too large during a burst, swap them for smaller ones. TrimOrClearBuffer(ref _collectTargetA, maxCollectTargetRetainLength); diff --git a/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Results/FetchResultStore.cs b/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Results/FetchResultStore.cs index 1abcbad00b4..7d17e60f1ae 100644 --- a/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Results/FetchResultStore.cs +++ b/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Results/FetchResultStore.cs @@ -32,7 +32,6 @@ namespace HotChocolate.Fusion.Execution.Results; internal sealed partial class FetchResultStore : IDisposable { private const int MaxRetainedDataElementStagingLength = 1024; - private const int MaxRetainedForwardedVariableCapacity = 16 * 1024; private const int MaxStackAllocPathSegments = 32; private static readonly ArrayPool s_variableValuePool = ArrayPool.Shared; @@ -47,9 +46,9 @@ internal sealed partial class FetchResultStore : IDisposable #endif private readonly List _memory = []; private readonly ChunkedArrayWriter _variableWriter = new(); + private readonly PooledArrayWriter _forwardedVariableWriter = new(); private readonly JsonWriter _jsonWriter; private readonly VariableDedupTable _variableDedupTable; - private PooledArrayWriter _forwardedVariableWriter = new(); private FusionSchemaDefinition _schema = default!; private IErrorHandler _errorHandler = default!; private Operation _operation = default!; @@ -1774,8 +1773,6 @@ private void WriteRawJsonValue(ReadOnlySequence value) _jsonWriter.WriteRawValue(value.ToArray()); } - // Caller must hold _lock. The returned span is valid until the next call and is empty - // when a single entity writes the forwarded variables directly. private ReadOnlySpan SerializeForwardedVariables( IReadOnlyList requestVariables, int entityCount) @@ -1799,7 +1796,6 @@ private ReadOnlySpan SerializeForwardedVariables( _jsonWriter.WriteEndObject(); _jsonWriter.Reset(_variableWriter); - // strip the enclosing braces so the properties splice into another object. return _forwardedVariableWriter.WrittenSpan[1..^1]; } From 9e2d6c966e40a4cf1554ce2f5985ec851b1d3987 Mon Sep 17 00:00:00 2001 From: tobias-tengler <45513122+tobias-tengler@users.noreply.github.com> Date: Fri, 9 Oct 2026 16:34:06 +0000 Subject: [PATCH 4/5] [Fusion] Create the forwarded variable buffer on first use The buffer is only rented when a fetch with more than one entity forwards variables, and it is released again when a large input made it grow. --- .../Execution/Results/FetchResultStore.Pooling.cs | 7 +++++++ .../Execution/Results/FetchResultStore.cs | 12 +++++++----- 2 files changed, 14 insertions(+), 5 deletions(-) diff --git a/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Results/FetchResultStore.Pooling.cs b/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Results/FetchResultStore.Pooling.cs index 65b9076a100..e429fd07cec 100644 --- a/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Results/FetchResultStore.Pooling.cs +++ b/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Results/FetchResultStore.Pooling.cs @@ -124,6 +124,13 @@ internal void Clean(int maxCollectTargetRetainLength, int maxDictionaryRetainCap // reset variable writer (returns excess chunks, keeps the first) _variableWriter.Clean(); + // release the forwarded variable buffer if a large input made it grow + if (_forwardedVariableWriter is { Capacity: > MaxRetainedForwardedVariableCapacity }) + { + _forwardedVariableWriter.Dispose(); + _forwardedVariableWriter = null; + } + // clear collect target arrays to unroot CompositeResultDocument references; // if they grew too large during a burst, swap them for smaller ones. TrimOrClearBuffer(ref _collectTargetA, maxCollectTargetRetainLength); diff --git a/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Results/FetchResultStore.cs b/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Results/FetchResultStore.cs index 7d17e60f1ae..b5223425a96 100644 --- a/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Results/FetchResultStore.cs +++ b/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Results/FetchResultStore.cs @@ -32,6 +32,7 @@ namespace HotChocolate.Fusion.Execution.Results; internal sealed partial class FetchResultStore : IDisposable { private const int MaxRetainedDataElementStagingLength = 1024; + private const int MaxRetainedForwardedVariableCapacity = 16 * 1024; private const int MaxStackAllocPathSegments = 32; private static readonly ArrayPool s_variableValuePool = ArrayPool.Shared; @@ -46,7 +47,7 @@ internal sealed partial class FetchResultStore : IDisposable #endif private readonly List _memory = []; private readonly ChunkedArrayWriter _variableWriter = new(); - private readonly PooledArrayWriter _forwardedVariableWriter = new(); + private PooledArrayWriter? _forwardedVariableWriter; private readonly JsonWriter _jsonWriter; private readonly VariableDedupTable _variableDedupTable; private FusionSchemaDefinition _schema = default!; @@ -1782,8 +1783,9 @@ private ReadOnlySpan SerializeForwardedVariables( return []; } - _forwardedVariableWriter.Reset(); - _jsonWriter.Reset(_forwardedVariableWriter); + var writer = _forwardedVariableWriter ??= new PooledArrayWriter(); + writer.Reset(); + _jsonWriter.Reset(writer); _jsonWriter.WriteStartObject(); for (var i = 0; i < requestVariables.Count; i++) @@ -1796,7 +1798,7 @@ private ReadOnlySpan SerializeForwardedVariables( _jsonWriter.WriteEndObject(); _jsonWriter.Reset(_variableWriter); - return _forwardedVariableWriter.WrittenSpan[1..^1]; + return writer.WrittenSpan[1..^1]; } private void WriteForwardedVariables( @@ -2532,7 +2534,7 @@ public void Dispose() _variableDedupTable.Dispose(); _variableWriter.Dispose(); - _forwardedVariableWriter.Dispose(); + _forwardedVariableWriter?.Dispose(); _pathPool?.Dispose(); } From 2a992c6676639becbeae810130b8ac31f790496d Mon Sep 17 00:00:00 2001 From: tobias-tengler <45513122+tobias-tengler@users.noreply.github.com> Date: Fri, 9 Oct 2026 17:03:27 +0000 Subject: [PATCH 5/5] Add extra test --- .../Results/FetchResultStoreTests.cs | 49 +++++++++++++++++++ 1 file changed, 49 insertions(+) diff --git a/src/HotChocolate/Fusion/test/Fusion.Execution.Tests/Execution/Results/FetchResultStoreTests.cs b/src/HotChocolate/Fusion/test/Fusion.Execution.Tests/Execution/Results/FetchResultStoreTests.cs index fd5f1ef3cc1..62a754861c8 100644 --- a/src/HotChocolate/Fusion/test/Fusion.Execution.Tests/Execution/Results/FetchResultStoreTests.cs +++ b/src/HotChocolate/Fusion/test/Fusion.Execution.Tests/Execution/Results/FetchResultStoreTests.cs @@ -1712,6 +1712,55 @@ public void CreateVariableValueSetsFromSnapshot_Should_WriteForwardedVariablesFo ]); } + [Fact] + public void CreateVariableValueSetsFromSnapshot_Should_WriteForwardedVariables_When_StoreIsReusedAfterLargeInputWasCleaned() + { + // arrange + using var source = new FetchResultStore(); + using var target = new FetchResultStore(); + + var first = CreateVariableValues(source, Path(1), Field("__fusion_1_id", new StringValueNode("1"))); + var second = CreateVariableValues(source, Path(2), Field("__fusion_1_id", new StringValueNode("2"))); + + // a forwarded value above the retained capacity grows the forwarded variable buffer, so Clean releases it + var payload = new string('a', 40_000); + + var largeValues = target.CreateVariableValueSetsFromSnapshot( + [first, second], + ImportedKeys("__fusion_1_id"), + [Field("payload", new StringValueNode(payload))], + [Requirement("__fusion_1_id")]) + .Select(entry => Normalize(entry.Values)) + .ToArray(); + + target.Clean(maxCollectTargetRetainLength: 256, maxDictionaryRetainCapacity: 256); + + // act + var result = target.CreateVariableValueSetsFromSnapshot( + [first, second], + ImportedKeys("__fusion_1_id"), + [Field("first", new IntValueNode(10))], + [Requirement("__fusion_1_id")]); + + // assert + string[] expectedLargeValues = + [ + $$"""{"payload":"{{payload}}","__fusion_1_id":"1"}""", + $$"""{"payload":"{{payload}}","__fusion_1_id":"2"}""" + ]; + Assert.Equal(expectedLargeValues, largeValues); + + result.Select(entry => Normalize(entry.Values)).MatchInlineSnapshots( + [ + """ + {"first":10,"__fusion_1_id":"1"} + """, + """ + {"first":10,"__fusion_1_id":"2"} + """ + ]); + } + [Fact] public void CreateVariableValueSets_Should_ResolveInvariantNames_When_SelectionSetOrdinalsDiffer() {