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..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,6 +47,7 @@ internal sealed partial class FetchResultStore : IDisposable #endif private readonly List _memory = []; private readonly ChunkedArrayWriter _variableWriter = new(); + private PooledArrayWriter? _forwardedVariableWriter; private readonly JsonWriter _jsonWriter; private readonly VariableDedupTable _variableDedupTable; private FusionSchemaDefinition _schema = default!; @@ -1038,6 +1040,7 @@ private ImmutableArray BuildVariableValueSetsFromSnapshot( VariableValues[]? variableValueSets = null; var additionalPaths = new AdditionalPathAccumulator(); var nextIndex = 0; + var forwardedVariables = SerializeForwardedVariables(requestVariables, importedEntries.Length); foreach (var importedEntry in importedEntries) { @@ -1049,13 +1052,7 @@ private ImmutableArray BuildVariableValueSetsFromSnapshot( _jsonWriter.Reset(_variableWriter); 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); - } + WriteForwardedVariables(requestVariables, forwardedVariables); if (!TryWriteRequestedRequirementValues(importedEntry.Values, requiredData)) { @@ -1133,6 +1130,7 @@ private ImmutableArray BuildVariableValueSets( VariableValues[]? variableValueSets = null; var additionalPaths = new AdditionalPathAccumulator(); var nextIndex = 0; + var forwardedVariables = SerializeForwardedVariables(requestVariables, elements.Length); foreach (var result in elements) { @@ -1141,14 +1139,7 @@ private ImmutableArray BuildVariableValueSets( _jsonWriter.Reset(_variableWriter); 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); - } + WriteForwardedVariables(requestVariables, forwardedVariables); // Write requirement fields. var failed = false; @@ -1783,6 +1774,51 @@ private void WriteRawJsonValue(ReadOnlySequence value) _jsonWriter.WriteRawValue(value.ToArray()); } + private ReadOnlySpan SerializeForwardedVariables( + IReadOnlyList requestVariables, + int entityCount) + { + if (entityCount < 2 || 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(); + _jsonWriter.Reset(_variableWriter); + + return writer.WrittenSpan[1..^1]; + } + + private void WriteForwardedVariables( + IReadOnlyList requestVariables, + ReadOnlySpan serialized) + { + if (!serialized.IsEmpty) + { + _jsonWriter.WriteRawProperties(serialized); + return; + } + + for (var i = 0; i < requestVariables.Count; i++) + { + var field = requestVariables[i]; + _jsonWriter.WritePropertyName(field.Name.Value); + WriteValueNode(field.Value); + } + } + private void WriteValueNode(IValueNode value) { switch (value) @@ -2498,6 +2534,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..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 @@ -1542,6 +1542,225 @@ 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_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 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() { 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 3fc7180c1f9..8a367a66e2e 100644 --- a/src/HotChocolate/Json/src/Json/JsonWriter.cs +++ b/src/HotChocolate/Json/src/Json/JsonWriter.cs @@ -536,6 +536,42 @@ internal void WriteRawValueEnd(JsonTokenType tokenType) _tokenType = tokenType; } + /// + /// Writes raw UTF-8 JSON object properties into the current object without validation. + /// Supported for minimized output only. An empty span writes nothing. + /// + /// + /// 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; + + 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. /// 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); + } }