From 03eb49eb80ea06eafa743fcf9cd955f0dec45775 Mon Sep 17 00:00:00 2001 From: tobias-tengler <45513122+tobias-tengler@users.noreply.github.com> Date: Sun, 18 Oct 2026 19:35:21 +0000 Subject: [PATCH 01/10] [Fusion] Copy completed leaf values into duplicate lookup targets When one lookup result is merged into a primary target and several additional targets, the primary target is completed from the source data and, when every completed value is a non-null, non-enum leaf of an object with the same selection set, the completed rows are copied into each additional target instead of completing the same source data again. Targets that do not qualify, merges with errors and pocketed errors keep the existing completion. --- .../Execution/Results/FetchResultStore.cs | 177 +++++++++++++++--- .../Text/Json/CompositeObjectContext.cs | 5 + .../Json/CompositeResultDocument.MetaDb.cs | 17 ++ .../Text/Json/CompositeResultDocument.cs | 12 ++ 4 files changed, 187 insertions(+), 24 deletions(-) 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 918db759542..44c3fe6feda 100644 --- a/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Results/FetchResultStore.cs +++ b/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Results/FetchResultStore.cs @@ -34,6 +34,7 @@ internal sealed partial class FetchResultStore : IDisposable private const int MaxRetainedDataElementStagingLength = 1024; private const int MaxRetainedForwardedVariableCapacity = 16 * 1024; private const int MaxStackAllocPathSegments = 32; + private const int MaxReplayLeafValues = 64; private static readonly ArrayPool s_variableValuePool = ArrayPool.Shared; private static readonly ArrayPool s_objectPool = ArrayPool.Shared; @@ -706,45 +707,156 @@ private bool SaveSafeResult( ErrorTrie? errorTrie, ResultSelectionSet resultSelectionSet) { + if (additionalPaths.Length > 0 && errorTrie is null && !HasPocketedErrors) + { + return SaveSafeResultWithLeafReplay( + resultData, + path, + additionalPaths, + dataElement, + resultSelectionSet); + } + if (!SaveSafeResult(resultData, path, dataElement, errorTrie, resultSelectionSet)) { return false; } - switch (additionalPaths.Length) + for (var i = 0; i < additionalPaths.Length; i++) { - case 0: - return true; + if (!SaveSafeResult(resultData, additionalPaths[i], dataElement, errorTrie, resultSelectionSet)) + { + return false; + } + } + + return true; + } - case 1: - return SaveSafeResult(resultData, additionalPaths[0], dataElement, errorTrie, resultSelectionSet); + /// + /// Completes the primary target from the source data and copies the completed leaf values into + /// every additional target, which are completed from the same source data. Targets that do not + /// qualify for the copy are completed from the source data. + /// + private bool SaveSafeResultWithLeafReplay( + CompositeResultElement resultData, + CompactPath path, + ReadOnlySpan additionalPaths, + SourceResultElement dataElement, + ResultSelectionSet resultSelectionSet) + { + if (resultData.IsNullOrInvalidated) + { + return false; + } - case 2: - return SaveSafeResult(resultData, additionalPaths[0], dataElement, errorTrie, resultSelectionSet) - && SaveSafeResult(resultData, additionalPaths[1], dataElement, errorTrie, resultSelectionSet); + var errorCount = _errors?.Count ?? 0; + var primary = path.IsRoot ? resultData : GetStartResult(path); - case 3: - return SaveSafeResult(resultData, additionalPaths[0], dataElement, errorTrie, resultSelectionSet) - && SaveSafeResult(resultData, additionalPaths[1], dataElement, errorTrie, resultSelectionSet) - && SaveSafeResult(resultData, additionalPaths[2], dataElement, errorTrie, resultSelectionSet); + if (!SaveSafeResult(resultData, primary, dataElement, errorTrie: null, resultSelectionSet)) + { + return false; + } - case 4: - return SaveSafeResult(resultData, additionalPaths[0], dataElement, errorTrie, resultSelectionSet) - && SaveSafeResult(resultData, additionalPaths[1], dataElement, errorTrie, resultSelectionSet) - && SaveSafeResult(resultData, additionalPaths[2], dataElement, errorTrie, resultSelectionSet) - && SaveSafeResult(resultData, additionalPaths[3], dataElement, errorTrie, resultSelectionSet); + Span rowOffsets = stackalloc int[MaxReplayLeafValues]; - default: - for (var i = 0; i < additionalPaths.Length; i++) + if (errorCount != (_errors?.Count ?? 0) + || HasPocketedErrors + || !TryGetLeafRowOffsets(primary, dataElement, resultSelectionSet, rowOffsets, out var primaryObject)) + { + for (var i = 0; i < additionalPaths.Length; i++) + { + if (!SaveSafeResult(resultData, additionalPaths[i], dataElement, errorTrie: null, resultSelectionSet)) { - if (!SaveSafeResult(resultData, additionalPaths[i], dataElement, errorTrie, resultSelectionSet)) - { - return false; - } + return false; } + } - return true; + return true; + } + + rowOffsets = rowOffsets[..primaryObject.RowOffsetCount]; + var canReplay = true; + + for (var i = 0; i < additionalPaths.Length; i++) + { + var target = GetStartResult(additionalPaths[i]); + Debug.Assert(target.ValueKind is JsonValueKind.Object or JsonValueKind.Null or JsonValueKind.Undefined); + + if (target.IsNullOrInvalidated) + { + continue; + } + + if (canReplay + && target.TryGetObjectContext(out var targetContext) + && ReferenceEquals(targetContext.SelectionSet, primaryObject.Context.SelectionSet)) + { + _result.CopyValues( + primaryObject.Context.ObjectStartCursor, + targetContext.ObjectStartCursor, + rowOffsets); + continue; + } + + if (!SaveSafeResult(resultData, target, dataElement, errorTrie: null, resultSelectionSet)) + { + return false; + } + + // Completing the source again can pocket errors that later targets must apply. + canReplay = !HasPocketedErrors; + } + + return true; + } + + /// + /// Collects the row offsets of the values that completing wrote into + /// , when every one of them is a non-null, non-enum leaf of an object + /// with a concrete selection set, so copying the rows is equivalent to completing the source again. + /// + private static bool TryGetLeafRowOffsets( + CompositeResultElement primary, + SourceResultElement dataElement, + ResultSelectionSet resultSelectionSet, + Span rowOffsets, + out LeafReplayObject primaryObject) + { + primaryObject = default; + + if (resultSelectionSet.HasSourceResponseNameMappings + || dataElement.ValueKind is not JsonValueKind.Object + || primary.IsNullOrInvalidated + || !primary.TryGetObjectContext(out var context) + || context.SelectionSet is not { Type.Kind: TypeKind.Object }) + { + return false; + } + + var start = context.ObjectStartCursor.Index; + var count = 0; + + foreach (var property in dataElement.EnumerateObject()) + { + if (!context.TryGetProperty(property.NameSpan, out var field, out var selection)) + { + continue; + } + + if (property.Value.ValueKind + is not (JsonValueKind.String or JsonValueKind.Number or JsonValueKind.True or JsonValueKind.False) + || selection.IsEnumValue + || count == rowOffsets.Length) + { + return false; + } + + rowOffsets[count++] = field.Cursor.Index - start; } + + primaryObject = new LeafReplayObject(context, count); + return true; } private bool SaveSafeResult( @@ -760,6 +872,21 @@ private bool SaveSafeResult( } var element = path.IsRoot ? resultData : GetStartResult(path); + return SaveSafeResult(resultData, element, dataElement, errorTrie, resultSelectionSet); + } + + private bool SaveSafeResult( + CompositeResultElement resultData, + CompositeResultElement element, + SourceResultElement dataElement, + ErrorTrie? errorTrie, + ResultSelectionSet resultSelectionSet) + { + if (resultData.IsNullOrInvalidated) + { + return false; + } + Debug.Assert(element.ValueKind is JsonValueKind.Object or JsonValueKind.Null or JsonValueKind.Undefined); if (element.IsNullOrInvalidated) @@ -783,6 +910,8 @@ private bool SaveSafeResult( return false; } + private readonly record struct LeafReplayObject(CompositeObjectContext Context, int RowOffsetCount); + public ImmutableArray CreateVariableValueSets( SelectionPath selectionSet, IReadOnlyList requestVariables, diff --git a/src/HotChocolate/Fusion/src/Fusion.Execution/Text/Json/CompositeObjectContext.cs b/src/HotChocolate/Fusion/src/Fusion.Execution/Text/Json/CompositeObjectContext.cs index dec3f5c15e4..144a5855b59 100644 --- a/src/HotChocolate/Fusion/src/Fusion.Execution/Text/Json/CompositeObjectContext.cs +++ b/src/HotChocolate/Fusion/src/Fusion.Execution/Text/Json/CompositeObjectContext.cs @@ -32,6 +32,11 @@ public CompositeObjectContext( /// public SelectionSet? SelectionSet => _selectionSet; + /// + /// Gets the cursor of the StartObject row of the object. + /// + public CompositeResultDocument.Cursor ObjectStartCursor => _objectStartCursor; + public bool TryGetProperty( ReadOnlySpan propertyName, out CompositeResultElement value, diff --git a/src/HotChocolate/Fusion/src/Fusion.Execution/Text/Json/CompositeResultDocument.MetaDb.cs b/src/HotChocolate/Fusion/src/Fusion.Execution/Text/Json/CompositeResultDocument.MetaDb.cs index 4c187c49dd1..43b7ddae683 100644 --- a/src/HotChocolate/Fusion/src/Fusion.Execution/Text/Json/CompositeResultDocument.MetaDb.cs +++ b/src/HotChocolate/Fusion/src/Fusion.Execution/Text/Json/CompositeResultDocument.MetaDb.cs @@ -640,6 +640,23 @@ ref Unsafe.Add(ref row, DbRow.SourceAndTypeOffset), (sourceDocumentId & 0x7FFF) | (((int)tokenType & 0x0F) << 15)); } + /// + /// Copies the value payload of the source row into the target row and keeps the parent + /// pointer of the target row. + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + internal readonly void CopyValuePreserveParent(Cursor source, Cursor target) + { + AssertValidCursor(source); + AssertValidCursor(target); + + // int 0 (parent) is intentionally left untouched. + Unsafe.CopyBlockUnaligned( + ref Unsafe.Add(ref RowRef(target), DbRow.SelectionAndFlagsOffset), + ref Unsafe.Add(ref RowRef(source), DbRow.SelectionAndFlagsOffset), + DbRow.Size - DbRow.SelectionAndFlagsOffset); + } + [MethodImpl(MethodImplOptions.AggressiveInlining)] internal readonly DbRow Get(Cursor cursor) { diff --git a/src/HotChocolate/Fusion/src/Fusion.Execution/Text/Json/CompositeResultDocument.cs b/src/HotChocolate/Fusion/src/Fusion.Execution/Text/Json/CompositeResultDocument.cs index cf692a380c6..922b419d24b 100644 --- a/src/HotChocolate/Fusion/src/Fusion.Execution/Text/Json/CompositeResultDocument.cs +++ b/src/HotChocolate/Fusion/src/Fusion.Execution/Text/Json/CompositeResultDocument.cs @@ -551,6 +551,18 @@ internal void UpgradeObject(CompositeResultElement element, SelectionSet concret AssignCompositeValue(element, newObject); } + /// + /// Copies the values at the given row offsets of one object into another object that has + /// the same selection set. + /// + internal void CopyValues(Cursor sourceObject, Cursor targetObject, ReadOnlySpan rowOffsets) + { + foreach (var rowOffset in rowOffsets) + { + _metaDb.CopyValuePreserveParent(sourceObject + rowOffset, targetObject + rowOffset); + } + } + internal CompositeResultElement CreateArray(Cursor parent, int length) { var cursor = WriteStartArray(parent, length); From 63eec36f24a378f5fcd5d4888a66f30e2cdbca16 Mon Sep 17 00:00:00 2001 From: tobias-tengler <45513122+tobias-tengler@users.noreply.github.com> Date: Sun, 18 Oct 2026 19:35:50 +0000 Subject: [PATCH 02/10] [Fusion] Keep duplicate lookup targets as result cursors Additional targets of a lookup variable set are kept as cursors into the result document instead of building a path per target. The merge uses the cursors directly while no value that references an object or a list was replaced since the targets were collected, and resolves the target from the root by its path otherwise. The paths of a CompactPathSegment are created on first access, so error attribution, telemetry and custom clients keep working. --- .../ForwardedVariableBlitBenchmark.cs | 3 +- .../Results/AdditionalPathAccumulator.cs | 163 +++++++++++++++--- .../FetchResultStore.RepresentationValue.cs | 148 ++-------------- .../Execution/Results/FetchResultStore.cs | 147 +++++++++++++--- .../Execution/Results/TargetCursorPaths.cs | 51 ++++++ .../Text/Json/CompactPathSegment.cs | 35 +++- .../Text/Json/CompactPathSource.cs | 12 ++ .../Text/Json/CompositeResultDocument.cs | 20 +++ .../Results/FetchResultStoreTests.cs | 43 +++++ 9 files changed, 426 insertions(+), 196 deletions(-) create mode 100644 src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Results/TargetCursorPaths.cs create mode 100644 src/HotChocolate/Fusion/src/Fusion.Execution/Text/Json/CompactPathSource.cs diff --git a/src/HotChocolate/Fusion/benchmarks/Fusion.Execution.Benchmarks/ForwardedVariableBlitBenchmark.cs b/src/HotChocolate/Fusion/benchmarks/Fusion.Execution.Benchmarks/ForwardedVariableBlitBenchmark.cs index 03b052b9069..2b1d0a9ef37 100644 --- a/src/HotChocolate/Fusion/benchmarks/Fusion.Execution.Benchmarks/ForwardedVariableBlitBenchmark.cs +++ b/src/HotChocolate/Fusion/benchmarks/Fusion.Execution.Benchmarks/ForwardedVariableBlitBenchmark.cs @@ -736,7 +736,8 @@ private static ImmutableArray FinalizeVariableValueSets( return []; } - additionalPaths.ApplyTo(variableValueSets, nextIndex); + // The accumulator only holds paths here, so it never reads the store or the document. + additionalPaths.ApplyTo(variableValueSets, nextIndex, store: null!, document: null!); additionalPaths.Dispose(); var span = variableValueSets.AsSpan(0, nextIndex); diff --git a/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Results/AdditionalPathAccumulator.cs b/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Results/AdditionalPathAccumulator.cs index f9d94fb4d9a..fd901ad3d2a 100644 --- a/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Results/AdditionalPathAccumulator.cs +++ b/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Results/AdditionalPathAccumulator.cs @@ -4,34 +4,36 @@ namespace HotChocolate.Fusion.Execution.Results; /// -/// A flat, allocation-free accumulator for additional CompactPath entries -/// that replaces per-slot List<CompactPath> with ArrayPool-rented buffers. -/// Stores (slotIndex, path) pairs and produces -/// per slot via counting sort in ApplyTo. +/// A flat, allocation-free accumulator for additional targets that replaces per-slot +/// List<CompactPath> with ArrayPool-rented buffers. +/// Stores (slotIndex, target) pairs and produces +/// per slot via counting sort in ApplyTo. A target is either a path or a result element; when +/// every target is a result element, the segments keep cursors and create paths on first access. /// internal ref struct AdditionalPathAccumulator { + private const int NoCursor = -1; + private CompactPath[]? _paths; + private int[]? _cursors; private int[]? _slotIndices; private int _count; + private int _pathCount; public readonly bool HasEntries => _count > 0; public void Add(int slotIndex, CompactPath path) { - if (_paths is null) - { - _paths = ArrayPool.Shared.Rent(16); - _slotIndices = ArrayPool.Shared.Rent(16); - } - else if (_count == _paths.Length) - { - Grow(); - } + var index = Reserve(slotIndex); + _paths![index] = path; + _cursors![index] = NoCursor; + _pathCount++; + } - _paths[_count] = path; - _slotIndices![_count] = slotIndex; - _count++; + public void Add(int slotIndex, CompositeResultElement target) + { + var index = Reserve(slotIndex); + _cursors![index] = target.Cursor.Value; } public void AddRange(int slotIndex, ReadOnlySpan paths) @@ -42,14 +44,70 @@ public void AddRange(int slotIndex, ReadOnlySpan paths) } } - public void ApplyTo(VariableValues[] variableValueSets, int slotCount) + public void ApplyTo( + VariableValues[] variableValueSets, + int slotCount, + FetchResultStore store, + CompositeResultDocument document) { if (_count == 0) { return; } - // Count paths per slot. + var segmentBuffer = ArrayPool.Shared.Rent(slotCount); + var segments = segmentBuffer.AsSpan(0, slotCount); + segments.Clear(); + CreateSegments(segments, store, document); + + for (var slot = 0; slot < slotCount; slot++) + { + if (!segments[slot].IsDefaultOrEmpty) + { + variableValueSets[slot] = variableValueSets[slot] with { AdditionalPaths = segments[slot] }; + } + } + + segments.Clear(); + ArrayPool.Shared.Return(segmentBuffer); + } + + public void ApplyTo( + EntityResultPath[] resultPaths, + int slotCount, + FetchResultStore store, + CompositeResultDocument document) + { + if (_count == 0) + { + return; + } + + var segmentBuffer = ArrayPool.Shared.Rent(slotCount); + var segments = segmentBuffer.AsSpan(0, slotCount); + segments.Clear(); + CreateSegments(segments, store, document); + + for (var slot = 0; slot < slotCount; slot++) + { + if (!segments[slot].IsDefaultOrEmpty) + { + resultPaths[slot] = resultPaths[slot] with { AdditionalPaths = segments[slot] }; + } + } + + segments.Clear(); + ArrayPool.Shared.Return(segmentBuffer); + } + + private readonly void CreateSegments( + Span segments, + FetchResultStore store, + CompositeResultDocument document) + { + var slotCount = segments.Length; + + // Count targets per slot. var counts = slotCount <= 256 ? stackalloc int[slotCount] : new int[slotCount]; @@ -70,33 +128,70 @@ public void ApplyTo(VariableValues[] variableValueSets, int slotCount) offsets[i] = offsets[i - 1] + counts[i - 1]; } - // Scatter paths into a single shared array in sorted order. + // Scatter targets into a single shared array in sorted order. var writePos = slotCount <= 256 ? stackalloc int[slotCount] : new int[slotCount]; offsets.CopyTo(writePos); + if (_pathCount == 0) + { + var cursors = new int[_count]; + + for (var i = 0; i < _count; i++) + { + cursors[writePos[_slotIndices![i]]++] = _cursors![i]; + } + + var source = new TargetCursorPaths(store, document, cursors); + + for (var slot = 0; slot < slotCount; slot++) + { + if (counts[slot] > 0) + { + segments[slot] = new CompactPathSegment(source, offsets[slot], counts[slot]); + } + } + + return; + } + var shared = new CompactPath[_count]; for (var i = 0; i < _count; i++) { - var idx = _slotIndices![i]; - shared[writePos[idx]++] = _paths![i]; + shared[writePos[_slotIndices![i]]++] = GetPath(i, document); } - // Build CompactPathSegment for each non-empty slot from contiguous slices. for (var slot = 0; slot < slotCount; slot++) { - if (counts[slot] == 0) + if (counts[slot] > 0) { - continue; + segments[slot] = new CompactPathSegment(shared, offsets[slot], counts[slot]); } + } + } - variableValueSets[slot] = variableValueSets[slot] with - { - AdditionalPaths = new CompactPathSegment(shared, offsets[slot], counts[slot]) - }; + private readonly CompactPath GetPath(int index, CompositeResultDocument document) + => _cursors![index] == NoCursor + ? _paths![index] + : document.CreateCompactPath(new CompositeResultDocument.Cursor(_cursors[index])); + + private int Reserve(int slotIndex) + { + if (_paths is null) + { + _paths = ArrayPool.Shared.Rent(16); + _cursors = ArrayPool.Shared.Rent(16); + _slotIndices = ArrayPool.Shared.Rent(16); } + else if (_count == _paths.Length) + { + Grow(); + } + + _slotIndices![_count] = slotIndex; + return _count++; } private void Grow() @@ -109,6 +204,11 @@ private void Grow() ArrayPool.Shared.Return(_paths); _paths = newPaths; + var newCursors = ArrayPool.Shared.Rent(newSize); + _cursors.AsSpan(0, _count).CopyTo(newCursors); + ArrayPool.Shared.Return(_cursors!); + _cursors = newCursors; + var newIndices = ArrayPool.Shared.Rent(newSize); _slotIndices.AsSpan(0, _count).CopyTo(newIndices); ArrayPool.Shared.Return(_slotIndices!); @@ -124,6 +224,12 @@ public void Dispose() _paths = null; } + if (_cursors is not null) + { + ArrayPool.Shared.Return(_cursors); + _cursors = null; + } + if (_slotIndices is not null) { ArrayPool.Shared.Return(_slotIndices); @@ -131,5 +237,6 @@ public void Dispose() } _count = 0; + _pathCount = 0; } } diff --git a/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Results/FetchResultStore.RepresentationValue.cs b/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Results/FetchResultStore.RepresentationValue.cs index b70225f7348..6da94ab1cef 100644 --- a/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Results/FetchResultStore.RepresentationValue.cs +++ b/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Results/FetchResultStore.RepresentationValue.cs @@ -205,7 +205,7 @@ private RepresentationValue BuildRepresentationValueFromSnapshot( representationWriter, new JsonWriterOptions { Indented = false }); var resultPaths = s_entityResultPathPool.Rent(importedEntries.Length); - var additionalPaths = new RepresentationPathAccumulator(importedEntries.Length); + var additionalPaths = new AdditionalPathAccumulator(); var completed = false; var variableStartPosition = StartRepresentationVariableValue(); var nextIndex = 0; @@ -288,7 +288,7 @@ private RepresentationValue BuildRepresentationValue( representationWriter, new JsonWriterOptions { Indented = false }); var resultPaths = s_entityResultPathPool.Rent(elements.Length); - var additionalPaths = new RepresentationPathAccumulator(elements.Length); + var additionalPaths = new AdditionalPathAccumulator(); var completed = false; var variableStartPosition = StartRepresentationVariableValue(); var nextIndex = 0; @@ -315,7 +315,7 @@ private RepresentationValue BuildRepresentationValue( break; case RepresentationWriteResult.Duplicate: - additionalPaths.Add(index, result.CompactPath); + additionalPaths.Add(index, result); break; case RepresentationWriteResult.Written: @@ -374,7 +374,7 @@ private RepresentationValue CompleteRepresentationVariableValue( int startPosition, IReadOnlyList requestVariables, EntityResultPath[] resultPaths, - ref RepresentationPathAccumulator additionalPaths, + ref AdditionalPathAccumulator additionalPaths, int count) { if (count == 0) @@ -389,7 +389,7 @@ private RepresentationValue CompleteRepresentationVariableValue( WriteRequestVariableProperties(requestVariables); _jsonWriter.WriteEndObject(); - additionalPaths.ApplyTo(resultPaths, count); + additionalPaths.ApplyTo(resultPaths, count, this, _result); additionalPaths.Dispose(); var length = _variableWriter.Position - startPosition; @@ -1460,7 +1460,7 @@ private bool SaveRepresentationResult( if (!SaveSafeResult( resultData, resultPath.Path, - resultPath.AdditionalPaths.AsSpan(), + resultPath.AdditionalPaths, entity, errorTrie: null, resultSelectionSet)) @@ -1482,7 +1482,7 @@ private bool SaveRepresentationResult( if (!SaveSafeResult( resultData, resultPath.Path, - resultPath.AdditionalPaths.AsSpan(), + resultPath.AdditionalPaths, entity, entityErrorTrie, resultSelectionSet)) @@ -1827,6 +1827,11 @@ private void ReturnRepresentationPathSegments(RepresentationValue representation { ReturnPathSegments(resultPath.Path, _seenPaths); + if (resultPath.AdditionalPaths.Source is not null) + { + continue; + } + foreach (var additionalPath in resultPath.AdditionalPaths) { ReturnPathSegments(additionalPath, _seenPaths); @@ -1842,133 +1847,4 @@ private enum RepresentationWriteResult Duplicate, Written } - - private ref struct RepresentationPathAccumulator - { - private readonly int _capacityHint; - private CompactPath[]? _paths; - private int[]? _slotIndices; - private int _count; - - public RepresentationPathAccumulator(int capacityHint) - { - _capacityHint = capacityHint; - _paths = null; - _slotIndices = null; - _count = 0; - } - - public void Add(int slotIndex, CompactPath path) - { - if (_paths is null) - { - var capacity = Math.Max(16, _capacityHint); - _paths = ArrayPool.Shared.Rent(capacity); - _slotIndices = ArrayPool.Shared.Rent(capacity); - } - else if (_count == _paths.Length) - { - Grow(); - } - - _paths[_count] = path; - _slotIndices![_count] = slotIndex; - _count++; - } - - public void AddRange(int slotIndex, ReadOnlySpan paths) - { - foreach (var path in paths) - { - Add(slotIndex, path); - } - } - - public void ApplyTo(EntityResultPath[] resultPaths, int slotCount) - { - if (_count == 0) - { - return; - } - - var counts = slotCount <= 256 - ? stackalloc int[slotCount] - : new int[slotCount]; - - for (var i = 0; i < _count; i++) - { - counts[_slotIndices![i]]++; - } - - var offsets = slotCount <= 256 - ? stackalloc int[slotCount] - : new int[slotCount]; - - offsets[0] = 0; - for (var i = 1; i < slotCount; i++) - { - offsets[i] = offsets[i - 1] + counts[i - 1]; - } - - var writePos = slotCount <= 256 - ? stackalloc int[slotCount] - : new int[slotCount]; - offsets.CopyTo(writePos); - - var shared = new CompactPath[_count]; - - for (var i = 0; i < _count; i++) - { - var idx = _slotIndices![i]; - shared[writePos[idx]++] = _paths![i]; - } - - for (var slot = 0; slot < slotCount; slot++) - { - if (counts[slot] == 0) - { - continue; - } - - resultPaths[slot] = resultPaths[slot] with - { - AdditionalPaths = new CompactPathSegment(shared, offsets[slot], counts[slot]) - }; - } - } - - private void Grow() - { - var newSize = _paths!.Length * 2; - - var newPaths = ArrayPool.Shared.Rent(newSize); - _paths.AsSpan(0, _count).CopyTo(newPaths); - _paths.AsSpan(0, _count).Clear(); - ArrayPool.Shared.Return(_paths); - _paths = newPaths; - - var newIndices = ArrayPool.Shared.Rent(newSize); - _slotIndices.AsSpan(0, _count).CopyTo(newIndices); - ArrayPool.Shared.Return(_slotIndices!); - _slotIndices = newIndices; - } - - public void Dispose() - { - if (_paths is not null) - { - _paths.AsSpan(0, _count).Clear(); - ArrayPool.Shared.Return(_paths); - _paths = null; - } - - if (_slotIndices is not null) - { - ArrayPool.Shared.Return(_slotIndices); - _slotIndices = null; - } - - _count = 0; - } - } } 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 44c3fe6feda..f74dc0816c1 100644 --- a/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Results/FetchResultStore.cs +++ b/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Results/FetchResultStore.cs @@ -194,7 +194,7 @@ public bool AddPartialResults( if (!SaveSafeResult( resultData, result.Path, - result.AdditionalPaths.AsSpan(), + result.AdditionalPaths, dataElementsSpan[i], errorTriesSpan[i], resultSelectionSet)) @@ -323,7 +323,7 @@ private bool AddPartialResultsNoErrors( if (!SaveSafeResult( resultData, result.Path, - result.AdditionalPaths.AsSpan(), + result.AdditionalPaths, dataElementsSpan[i], errorTrie: null, resultSelectionSet)) @@ -421,7 +421,7 @@ private bool AddSinglePartialResult( return SaveSafeResult( _result.Data, result.Path, - result.AdditionalPaths.AsSpan(), + result.AdditionalPaths, dataElement, errorTrie, resultSelectionSet); @@ -449,7 +449,7 @@ private bool AddSinglePartialResultNoErrors( return SaveSafeResult( _result.Data, result.Path, - result.AdditionalPaths.AsSpan(), + result.AdditionalPaths, dataElement, errorTrie: null, resultSelectionSet); @@ -702,17 +702,19 @@ public void FinalizePocketedErrors() private bool SaveSafeResult( CompositeResultElement resultData, CompactPath path, - ReadOnlySpan additionalPaths, + CompactPathSegment additionalPaths, SourceResultElement dataElement, ErrorTrie? errorTrie, ResultSelectionSet resultSelectionSet) { - if (additionalPaths.Length > 0 && errorTrie is null && !HasPocketedErrors) + var additionalTargets = new AdditionalTargets(this, additionalPaths); + + if (additionalTargets.Length > 0 && errorTrie is null && !HasPocketedErrors) { return SaveSafeResultWithLeafReplay( resultData, path, - additionalPaths, + additionalTargets, dataElement, resultSelectionSet); } @@ -722,9 +724,9 @@ private bool SaveSafeResult( return false; } - for (var i = 0; i < additionalPaths.Length; i++) + for (var i = 0; i < additionalTargets.Length; i++) { - if (!SaveSafeResult(resultData, additionalPaths[i], dataElement, errorTrie, resultSelectionSet)) + if (!SaveSafeResult(resultData, additionalTargets, i, dataElement, errorTrie, resultSelectionSet)) { return false; } @@ -741,7 +743,7 @@ private bool SaveSafeResult( private bool SaveSafeResultWithLeafReplay( CompositeResultElement resultData, CompactPath path, - ReadOnlySpan additionalPaths, + AdditionalTargets additionalTargets, SourceResultElement dataElement, ResultSelectionSet resultSelectionSet) { @@ -764,9 +766,9 @@ private bool SaveSafeResultWithLeafReplay( || HasPocketedErrors || !TryGetLeafRowOffsets(primary, dataElement, resultSelectionSet, rowOffsets, out var primaryObject)) { - for (var i = 0; i < additionalPaths.Length; i++) + for (var i = 0; i < additionalTargets.Length; i++) { - if (!SaveSafeResult(resultData, additionalPaths[i], dataElement, errorTrie: null, resultSelectionSet)) + if (!SaveSafeResult(resultData, additionalTargets, i, dataElement, errorTrie: null, resultSelectionSet)) { return false; } @@ -778,9 +780,9 @@ private bool SaveSafeResultWithLeafReplay( rowOffsets = rowOffsets[..primaryObject.RowOffsetCount]; var canReplay = true; - for (var i = 0; i < additionalPaths.Length; i++) + for (var i = 0; i < additionalTargets.Length; i++) { - var target = GetStartResult(additionalPaths[i]); + var target = additionalTargets[i]; Debug.Assert(target.ValueKind is JsonValueKind.Object or JsonValueKind.Null or JsonValueKind.Undefined); if (target.IsNullOrInvalidated) @@ -910,8 +912,53 @@ private bool SaveSafeResult( return false; } + private bool SaveSafeResult( + CompositeResultElement resultData, + AdditionalTargets additionalTargets, + int index, + SourceResultElement dataElement, + ErrorTrie? errorTrie, + ResultSelectionSet resultSelectionSet) + { + if (resultData.IsNullOrInvalidated) + { + return false; + } + + return SaveSafeResult(resultData, additionalTargets[index], dataElement, errorTrie, resultSelectionSet); + } + private readonly record struct LeafReplayObject(CompositeObjectContext Context, int RowOffsetCount); + /// + /// Resolves the additional targets of a source schema result. Targets collected as cursors + /// are used directly while the result document keeps the structure they were collected in, + /// otherwise each target is resolved from the root by its path. + /// + private readonly ref struct AdditionalTargets + { + private readonly FetchResultStore _store; + private readonly CompactPathSegment _segment; + private readonly TargetCursorPaths? _cursors; + + public AdditionalTargets(FetchResultStore store, CompactPathSegment segment) + { + _store = store; + _segment = segment; + _cursors = segment.Source is TargetCursorPaths cursors + && ReferenceEquals(cursors.Document, store._result) + ? cursors + : null; + } + + public int Length => _segment.Length; + + public CompositeResultElement this[int index] + => _cursors is { IsCurrent: true } cursors + ? cursors.GetElement(_segment.Offset + index) + : _store.GetStartResult(_segment[index]); + } + public ImmutableArray CreateVariableValueSets( SelectionPath selectionSet, IReadOnlyList requestVariables, @@ -1302,7 +1349,7 @@ private ImmutableArray BuildVariableValueSets( _jsonWriter.WriteEndObject(); var entry = TryCreateVariableValues( - result.CompactPath, startPosition, ref additionalPaths, nextIndex); + result, startPosition, ref additionalPaths, nextIndex); if (entry is null) { @@ -1385,7 +1432,7 @@ private ImmutableArray BuildVariableValueSetsSingleRequirementFa // we try to create a VariableValues object, // if that fails the variables already were created and we move on. - var entry = TryCreateVariableValues(result.CompactPath, startPosition, ref additionalPaths, nextIndex); + var entry = TryCreateVariableValues(result, startPosition, ref additionalPaths, nextIndex); if (entry is null) { @@ -1431,7 +1478,7 @@ private ImmutableArray BuildVariableValueSetsSingleRequirementSl _jsonWriter.WriteEndObject(); var entry = TryCreateVariableValues( - result.CompactPath, startPosition, ref additionalPaths, nextIndex); + result, startPosition, ref additionalPaths, nextIndex); if (entry is null) { @@ -1516,7 +1563,7 @@ private ImmutableArray BuildVariableValueSetsTwoRequirementsFast _jsonWriter.WriteEndObject(); var entry = TryCreateVariableValues( - result.CompactPath, startPosition, ref additionalPaths, nextIndex); + result, startPosition, ref additionalPaths, nextIndex); if (entry is null) { @@ -1578,7 +1625,7 @@ private ImmutableArray BuildVariableValueSetsTwoRequirementsSlow _jsonWriter.WriteEndObject(); - var entry = TryCreateVariableValues(result.CompactPath, startPosition, ref additionalPaths, nextIndex); + var entry = TryCreateVariableValues(result, startPosition, ref additionalPaths, nextIndex); if (entry is null) { @@ -1682,7 +1729,7 @@ private ImmutableArray BuildVariableValueSetsThreeRequirementsFa WriteCompositeResultValue(value3); _jsonWriter.WriteEndObject(); - var entry = TryCreateVariableValues(result.CompactPath, startPosition, ref additionalPaths, nextIndex); + var entry = TryCreateVariableValues(result, startPosition, ref additionalPaths, nextIndex); if (entry is null) { @@ -1760,7 +1807,7 @@ private ImmutableArray BuildVariableValueSetsThreeRequirementsSl _jsonWriter.WriteEndObject(); - var entry = TryCreateVariableValues(result.CompactPath, startPosition, ref additionalPaths, nextIndex); + var entry = TryCreateVariableValues(result, startPosition, ref additionalPaths, nextIndex); if (entry is null) { @@ -1795,12 +1842,55 @@ private static bool TryGetSimpleRequirementFieldName( return false; } + /// + /// Creates the paths of the elements at the given cursors of . + /// + internal CompactPath[] CreateCompactPaths(CompositeResultDocument document, ReadOnlySpan cursors) + { + lock (_lock) + { + var paths = new CompactPath[cursors.Length]; + + for (var i = 0; i < cursors.Length; i++) + { + paths[i] = document.CreateCompactPath(new CompositeResultDocument.Cursor(cursors[i])); + } + + return paths; + } + } + private VariableValues? TryCreateVariableValues( - CompactPath path, + CompositeResultElement target, int startPosition, ref AdditionalPathAccumulator additionalPaths, int nextIndex) - => TryCreateVariableValues(path, startPosition, ref additionalPaths, nextIndex, out _); + => TryCreateVariableValues(target, startPosition, ref additionalPaths, nextIndex, out _); + + private VariableValues? TryCreateVariableValues( + CompositeResultElement target, + int startPosition, + ref AdditionalPathAccumulator additionalPaths, + int nextIndex, + out int dedupIndex) + { + var length = _variableWriter.Position - startPosition; + var hash = _variableWriter.GetHashCode(startPosition, length); + + // A target whose variables were already written for an earlier target is merged from + // the same fetch, so it is tracked as an additional target of the existing entry. + if (_variableDedupTable.TryGet(hash, startPosition, length, out var existingIndex)) + { + dedupIndex = existingIndex; + additionalPaths.Add(existingIndex, target); + _variableWriter.ResetTo(startPosition); + return null; + } + + dedupIndex = nextIndex; + _variableDedupTable.Add(hash, nextIndex, startPosition, length); + return new VariableValues(target.CompactPath, JsonSegment.Create(_variableWriter, startPosition, length)); + } private VariableValues? TryCreateVariableValues( CompactPath path, @@ -2715,6 +2805,13 @@ private void ReturnPathSegments(SourceSchemaResult result, HashSet seen) { ReturnPathSegments(result.Path, seen); + // Paths that are created on first access are not returned early; + // the path pool reclaims them when it is reset. + if (result.AdditionalPaths.Source is not null) + { + return; + } + foreach (var additionalPath in result.AdditionalPaths) { ReturnPathSegments(additionalPath, seen); @@ -2731,7 +2828,7 @@ private void ReturnPathSegments(CompactPath path, HashSet seen) } } - private static ImmutableArray FinalizeVariableValueSets( + private ImmutableArray FinalizeVariableValueSets( VariableValues[]? variableValueSets, ref AdditionalPathAccumulator additionalPaths, int nextIndex) @@ -2747,7 +2844,7 @@ private static ImmutableArray FinalizeVariableValueSets( return []; } - additionalPaths.ApplyTo(variableValueSets, nextIndex); + additionalPaths.ApplyTo(variableValueSets, nextIndex, this, _result); additionalPaths.Dispose(); var span = variableValueSets.AsSpan(0, nextIndex); diff --git a/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Results/TargetCursorPaths.cs b/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Results/TargetCursorPaths.cs new file mode 100644 index 00000000000..bc0bfa25a92 --- /dev/null +++ b/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Results/TargetCursorPaths.cs @@ -0,0 +1,51 @@ +using HotChocolate.Fusion.Text.Json; + +namespace HotChocolate.Fusion.Execution.Results; + +/// +/// The additional targets of lookup variable value sets, kept as cursors into the result document +/// until their paths are requested. +/// +internal sealed class TargetCursorPaths : CompactPathSource +{ + private readonly FetchResultStore _store; + private readonly CompositeResultDocument _document; + private readonly int[] _cursors; + private readonly int _structureVersion; + private CompactPath[]? _paths; + + public TargetCursorPaths(FetchResultStore store, CompositeResultDocument document, int[] cursors) + { + _store = store; + _document = document; + _cursors = cursors; + _structureVersion = document.StructureVersion; + } + + /// + /// Gets the result document the cursors point into. + /// + public CompositeResultDocument Document => _document; + + /// + /// Gets true while every cursor still points to the element at its path. + /// + public bool IsCurrent => _document.StructureVersion == _structureVersion; + + /// + /// Gets the element at the cursor with the given index. + /// + public CompositeResultElement GetElement(int index) + => new(_document, new CompositeResultDocument.Cursor(_cursors[index])); + + public override CompactPath[] GetPaths() + { + if (Volatile.Read(ref _paths) is { } paths) + { + return paths; + } + + paths = _store.CreateCompactPaths(_document, _cursors); + return Interlocked.CompareExchange(ref _paths, paths, null) ?? paths; + } +} diff --git a/src/HotChocolate/Fusion/src/Fusion.Execution/Text/Json/CompactPathSegment.cs b/src/HotChocolate/Fusion/src/Fusion.Execution/Text/Json/CompactPathSegment.cs index ca1f0957669..bd214554381 100644 --- a/src/HotChocolate/Fusion/src/Fusion.Execution/Text/Json/CompactPathSegment.cs +++ b/src/HotChocolate/Fusion/src/Fusion.Execution/Text/Json/CompactPathSegment.cs @@ -8,6 +8,7 @@ namespace HotChocolate.Fusion.Text.Json; public readonly struct CompactPathSegment { private readonly CompactPath[]? _array; + private readonly CompactPathSource? _source; private readonly int _offset; private readonly int _count; @@ -18,6 +19,24 @@ internal CompactPathSegment(CompactPath[] array, int offset, int count) _count = count; } + internal CompactPathSegment(CompactPathSource source, int offset, int count) + { + _source = source; + _offset = offset; + _count = count; + } + + /// + /// Gets the source that creates the paths of this segment on first access, + /// or null if the paths already exist. + /// + internal CompactPathSource? Source => _source; + + /// + /// Gets the offset of this segment within its backing array or source. + /// + internal int Offset => _offset; + /// /// Gets the number of paths in this segment. /// @@ -32,9 +51,9 @@ internal CompactPathSegment(CompactPath[] array, int offset, int count) /// Gets the paths as a read-only span. /// public ReadOnlySpan AsSpan() - => _array is null - ? ReadOnlySpan.Empty - : _array.AsSpan(_offset, _count); + => GetArray() is { } array + ? array.AsSpan(_offset, _count) + : ReadOnlySpan.Empty; /// /// Gets the path at the specified index. @@ -48,7 +67,7 @@ public CompactPath this[int index] throw new ArgumentOutOfRangeException(nameof(index)); } - return _array![_offset + index]; + return GetArray()![_offset + index]; } } @@ -63,11 +82,12 @@ internal CompactPathSegment Detach() return default; } + var array = GetArray()!; var paths = new CompactPath[_count]; for (var i = 0; i < _count; i++) { - paths[i] = _array![_offset + i].Detach(); + paths[i] = array[_offset + i].Detach(); } return new CompactPathSegment(paths, 0, _count); @@ -76,7 +96,10 @@ internal CompactPathSegment Detach() /// /// Returns an enumerator that iterates through the paths. /// - public Enumerator GetEnumerator() => new(_array, _offset, _count); + public Enumerator GetEnumerator() => new(GetArray(), _offset, _count); + + private CompactPath[]? GetArray() + => _array ?? (_count == 0 ? null : _source?.GetPaths()); /// /// Enumerates the paths in a . diff --git a/src/HotChocolate/Fusion/src/Fusion.Execution/Text/Json/CompactPathSource.cs b/src/HotChocolate/Fusion/src/Fusion.Execution/Text/Json/CompactPathSource.cs new file mode 100644 index 00000000000..0aee50bdd59 --- /dev/null +++ b/src/HotChocolate/Fusion/src/Fusion.Execution/Text/Json/CompactPathSource.cs @@ -0,0 +1,12 @@ +namespace HotChocolate.Fusion.Text.Json; + +/// +/// Creates the paths of a when they are first accessed. +/// +internal abstract class CompactPathSource +{ + /// + /// Gets the paths of every segment that shares this source, in segment order. + /// + public abstract CompactPath[] GetPaths(); +} diff --git a/src/HotChocolate/Fusion/src/Fusion.Execution/Text/Json/CompositeResultDocument.cs b/src/HotChocolate/Fusion/src/Fusion.Execution/Text/Json/CompositeResultDocument.cs index 922b419d24b..2f12ce31443 100644 --- a/src/HotChocolate/Fusion/src/Fusion.Execution/Text/Json/CompositeResultDocument.cs +++ b/src/HotChocolate/Fusion/src/Fusion.Execution/Text/Json/CompositeResultDocument.cs @@ -20,6 +20,7 @@ public sealed partial class CompositeResultDocument : IDisposable private PathSegmentLocalPool? _pathPool; internal MetaDb _metaDb; private NullMarkerState _nullMarkerState; + private int _structureVersion; private int _disposed; internal CompositeResultDocument( @@ -63,6 +64,12 @@ internal CompositeResultDocument( public CompositeResultElement Data { get; } + /// + /// Gets a version that changes whenever a value that references an object or a list is + /// replaced. While it is unchanged, every element keeps the path it had when it was read. + /// + internal int StructureVersion => _structureVersion; + /// /// Stops this document from renting path segment arrays from the request scoped pool. /// Paths created afterwards are allocated. @@ -577,6 +584,7 @@ internal CompositeResultElement CreateArray(Cursor parent, int length) [MethodImpl(MethodImplOptions.AggressiveInlining)] internal void AssignCompositeValue(CompositeResultElement target, CompositeResultElement value) { + TrackValueReplacement(target.Cursor); _metaDb.ReplacePreserveParent( cursor: target.Cursor, tokenType: ElementTokenType.Reference, @@ -606,6 +614,7 @@ internal void AssignSourceValue( Debug.Assert(_sources.Contains(parent), "Expected the source document of the source element to be registered."); var tokenType = sourceRow.TokenType.ToElementTokenType(); + TrackValueReplacement(target.Cursor); if (tokenType is ElementTokenType.StartObject or ElementTokenType.StartArray) { @@ -631,11 +640,22 @@ internal void AssignSourceValue( [MethodImpl(MethodImplOptions.AggressiveInlining)] internal void AssignNullValue(CompositeResultElement target) { + TrackValueReplacement(target.Cursor); _metaDb.ReplacePreserveParent( cursor: target.Cursor, tokenType: ElementTokenType.Null); } + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private void TrackValueReplacement(Cursor target) + { + if (_metaDb.GetElementTokenType(target, resolveReferences: false) + is ElementTokenType.Reference or ElementTokenType.StartObject or ElementTokenType.StartArray) + { + _structureVersion++; + } + } + [MethodImpl(MethodImplOptions.AggressiveInlining)] private Cursor WriteStartArray(Cursor parent, int length = 0) => _metaDb.AppendStartArray(parent.Value, length, ElementFlags.None); 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 135eb91fb6d..ca31a4e98e8 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 @@ -2301,6 +2301,49 @@ scalar JSON """); } + [Fact] + public void CreateVariableValueSets_Should_AddRepeatedTargetsAsAdditionalPaths_When_RequirementValuesRepeat() + { + // 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":"1"},{"id":"2"},{"id":"1"}]}}""", + resultArena, + sourceArena); + + // act + var result = store.CreateVariableValueSets( + SelectionPath.Root.AppendField("foos"), + [], + [Requirement(schema, "__fusion_1_id", "id", new NamedTypeNode("ID"))]); + + // assert + RenderVariableValueSets(store, result).MatchInlineSnapshot( + """ + Path: foos[0] + Additional paths: [foos[2], foos[4]] + Variables: {"__fusion_1_id":"1"} + Path: foos[1] + Additional paths: [foos[3]] + Variables: {"__fusion_1_id":"2"} + """); + } + private static FetchResultStore CreateLiveStore( FusionSchemaDefinition schema, string operationText, From fbe142484c1fd9e824948cc96e699bc87e81f155 Mon Sep 17 00:00:00 2001 From: Michael Staib Date: Wed, 21 Oct 2026 04:10:46 +0000 Subject: [PATCH 03/10] Guard and presize duplicate lookup target cursors - Creating the paths of target cursors now fails when their result document is no longer the store's result, instead of reading the rows of a released document. - The additional target accumulator is presized with the number of targets and rents its path buffer only when a path is added, so cursor-only accumulation no longer grows an unused buffer. - Tests cover merging after a structure change makes the cursors stale, the released document guard, and accumulators that mix paths and cursors. --- .../Results/AdditionalPathAccumulator.cs | 44 ++++-- .../FetchResultStore.RepresentationValue.cs | 4 +- .../Execution/Results/FetchResultStore.cs | 27 ++-- .../Fusion.Execution/Execution/ThrowHelper.cs | 3 + .../Results/FetchResultStoreTests.cs | 146 ++++++++++++++++++ 5 files changed, 201 insertions(+), 23 deletions(-) diff --git a/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Results/AdditionalPathAccumulator.cs b/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Results/AdditionalPathAccumulator.cs index fd901ad3d2a..8807bb686df 100644 --- a/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Results/AdditionalPathAccumulator.cs +++ b/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Results/AdditionalPathAccumulator.cs @@ -13,19 +13,32 @@ namespace HotChocolate.Fusion.Execution.Results; internal ref struct AdditionalPathAccumulator { private const int NoCursor = -1; + private const int MinimumCapacity = 16; + private readonly int _capacityHint; private CompactPath[]? _paths; private int[]? _cursors; private int[]? _slotIndices; + private int _capacity; private int _count; private int _pathCount; + /// + /// Creates an accumulator that sizes its buffers for targets + /// when the first target is added. + /// + public AdditionalPathAccumulator(int capacityHint) + { + _capacityHint = capacityHint; + } + public readonly bool HasEntries => _count > 0; public void Add(int slotIndex, CompactPath path) { var index = Reserve(slotIndex); - _paths![index] = path; + _paths ??= ArrayPool.Shared.Rent(_capacity); + _paths[index] = path; _cursors![index] = NoCursor; _pathCount++; } @@ -179,13 +192,14 @@ private readonly CompactPath GetPath(int index, CompositeResultDocument document private int Reserve(int slotIndex) { - if (_paths is null) + if (_cursors is null) { - _paths = ArrayPool.Shared.Rent(16); - _cursors = ArrayPool.Shared.Rent(16); - _slotIndices = ArrayPool.Shared.Rent(16); + var capacity = Math.Max(MinimumCapacity, _capacityHint); + _cursors = ArrayPool.Shared.Rent(capacity); + _slotIndices = ArrayPool.Shared.Rent(capacity); + _capacity = Math.Min(_cursors.Length, _slotIndices.Length); } - else if (_count == _paths.Length) + else if (_count == _capacity) { Grow(); } @@ -196,13 +210,16 @@ private int Reserve(int slotIndex) private void Grow() { - var newSize = _paths!.Length * 2; + var newSize = _capacity * 2; - var newPaths = ArrayPool.Shared.Rent(newSize); - _paths.AsSpan(0, _count).CopyTo(newPaths); - _paths.AsSpan(0, _count).Clear(); - ArrayPool.Shared.Return(_paths); - _paths = newPaths; + if (_paths is not null) + { + var newPaths = ArrayPool.Shared.Rent(newSize); + _paths.AsSpan(0, _count).CopyTo(newPaths); + _paths.AsSpan(0, _count).Clear(); + ArrayPool.Shared.Return(_paths); + _paths = newPaths; + } var newCursors = ArrayPool.Shared.Rent(newSize); _cursors.AsSpan(0, _count).CopyTo(newCursors); @@ -213,6 +230,8 @@ private void Grow() _slotIndices.AsSpan(0, _count).CopyTo(newIndices); ArrayPool.Shared.Return(_slotIndices!); _slotIndices = newIndices; + + _capacity = Math.Min(_cursors.Length, _slotIndices.Length); } public void Dispose() @@ -236,6 +255,7 @@ public void Dispose() _slotIndices = null; } + _capacity = 0; _count = 0; _pathCount = 0; } diff --git a/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Results/FetchResultStore.RepresentationValue.cs b/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Results/FetchResultStore.RepresentationValue.cs index 9210e0101ec..dd7f4b0ed59 100644 --- a/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Results/FetchResultStore.RepresentationValue.cs +++ b/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Results/FetchResultStore.RepresentationValue.cs @@ -256,7 +256,7 @@ private RepresentationValue BuildRepresentationValueFromSnapshot( representationWriter, new JsonWriterOptions { Indented = false }); var resultPaths = s_entityResultPathPool.Rent(importedEntries.Length); - var additionalPaths = new AdditionalPathAccumulator(); + var additionalPaths = new AdditionalPathAccumulator(importedEntries.Length); var completed = false; var variableStartPosition = StartRepresentationVariableValue(); var nextIndex = 0; @@ -339,7 +339,7 @@ private RepresentationValue BuildRepresentationValue( representationWriter, new JsonWriterOptions { Indented = false }); var resultPaths = s_entityResultPathPool.Rent(elements.Length); - var additionalPaths = new AdditionalPathAccumulator(); + var additionalPaths = new AdditionalPathAccumulator(elements.Length); var completed = false; var variableStartPosition = StartRepresentationVariableValue(); var nextIndex = 0; 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 7f0f5722cca..88daaa07338 100644 --- a/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Results/FetchResultStore.cs +++ b/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Results/FetchResultStore.cs @@ -1503,7 +1503,7 @@ private ImmutableArray BuildVariableValueSetsFromSnapshot( _variableDedupTable.Initialize(importedEntries.Length); VariableValues[]? variableValueSets = null; - var additionalPaths = new AdditionalPathAccumulator(); + var additionalPaths = new AdditionalPathAccumulator(importedEntries.Length); var nextIndex = 0; var forwardedVariables = SerializeForwardedVariables(requestVariables, importedEntries.Length); @@ -1593,7 +1593,7 @@ private ImmutableArray BuildVariableValueSets( } VariableValues[]? variableValueSets = null; - var additionalPaths = new AdditionalPathAccumulator(); + var additionalPaths = new AdditionalPathAccumulator(elements.Length); var nextIndex = 0; var forwardedVariables = SerializeForwardedVariables(requestVariables, elements.Length); @@ -1679,7 +1679,7 @@ private ImmutableArray BuildVariableValueSetsSingleRequirementFa string fieldName) { VariableValues[]? variableValueSets = null; - var additionalPaths = new AdditionalPathAccumulator(); + var additionalPaths = new AdditionalPathAccumulator(elements.Length); var nextIndex = 0; var isNonNullRequirement = requirement.Type.Kind is SyntaxKind.NonNullType; var lookupName = requirement.InternalAlias ?? fieldName; @@ -1737,7 +1737,7 @@ private ImmutableArray BuildVariableValueSetsSingleRequirementSl OperationRequirement requirement) { VariableValues[]? variableValueSets = null; - var additionalPaths = new AdditionalPathAccumulator(); + var additionalPaths = new AdditionalPathAccumulator(elements.Length); var nextIndex = 0; var requirementType = requirement.Type; @@ -1813,7 +1813,7 @@ private ImmutableArray BuildVariableValueSetsTwoRequirementsFast string fieldName2) { VariableValues[]? variableValueSets = null; - var additionalPaths = new AdditionalPathAccumulator(); + var additionalPaths = new AdditionalPathAccumulator(elements.Length); var nextIndex = 0; var lookupName1 = requirement1.InternalAlias ?? fieldName1; var lookupName2 = requirement2.InternalAlias ?? fieldName2; @@ -1869,7 +1869,7 @@ private ImmutableArray BuildVariableValueSetsTwoRequirementsSlow OperationRequirement requirement2) { VariableValues[]? variableValueSets = null; - var additionalPaths = new AdditionalPathAccumulator(); + var additionalPaths = new AdditionalPathAccumulator(elements.Length); var nextIndex = 0; var requirementType1 = requirement1.Type; var requirementType2 = requirement2.Type; @@ -1968,7 +1968,7 @@ private ImmutableArray BuildVariableValueSetsThreeRequirementsFa string fieldName3) { VariableValues[]? variableValueSets = null; - var additionalPaths = new AdditionalPathAccumulator(); + var additionalPaths = new AdditionalPathAccumulator(elements.Length); var nextIndex = 0; var lookupName1 = requirement1.InternalAlias ?? fieldName1; var lookupName2 = requirement2.InternalAlias ?? fieldName2; @@ -2036,7 +2036,7 @@ private ImmutableArray BuildVariableValueSetsThreeRequirementsSl OperationRequirement requirement3) { VariableValues[]? variableValueSets = null; - var additionalPaths = new AdditionalPathAccumulator(); + var additionalPaths = new AdditionalPathAccumulator(elements.Length); var nextIndex = 0; var requirementType1 = requirement1.Type; var requirementType2 = requirement2.Type; @@ -2130,12 +2130,21 @@ internal static bool TryGetSimpleRequirementFieldName( } /// - /// Creates the paths of the elements at the given cursors of . + /// Creates the paths of the elements at the given cursors of , + /// which must be the result document of the current request. /// + /// + /// is no longer the result document of this store. + /// internal CompactPath[] CreateCompactPaths(CompositeResultDocument document, ReadOnlySpan cursors) { lock (_lock) { + if (!ReferenceEquals(_result, document)) + { + throw ThrowHelper.LookupTargetPathsReleased(); + } + var paths = new CompactPath[cursors.Length]; for (var i = 0; i < cursors.Length; i++) diff --git a/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/ThrowHelper.cs b/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/ThrowHelper.cs index 5d8182d413b..619402ba531 100644 --- a/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/ThrowHelper.cs +++ b/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/ThrowHelper.cs @@ -26,6 +26,9 @@ public static InvalidOperationException IncrementalPlanParentNotFound(SelectionP public static InvalidOperationException DeferredPlanNotFound(SelectionPath path) => new($"No plan was found for the @defer fragment at path '{path}'."); + public static InvalidOperationException LookupTargetPathsReleased() + => new("The paths of lookup targets can only be created while their result document is in use."); + public static OperationPlannerGuardrailException PlanningGuardrailExceeded( string operationId, OperationPlannerGuardrailReason reason, 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 722037c90d8..2bca86fd454 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 @@ -2344,6 +2344,152 @@ type Foo { """); } + [Fact] + public void AddPartialResult_Should_CompleteTargetsFromRoot_When_StructureChangedAfterCollection() + { + // arrange + var schema = ComposeSchema( + """ + # name: test + type Query { + foos: [Foo] + } + + type Foo { + id: ID! + name: String + } + """); + + using var resultArena = new MemoryArena(); + using var sourceArena = new MemoryArena(); + using var store = CreateLiveStore( + schema, + "{ foos { id name } }", + """{"data":{"foos":[{"id":"1"},{"id":"1"},{"id":"1"}]}}""", + resultArena, + sourceArena); + var entry = Assert.Single( + store.CreateVariableValueSets( + SelectionPath.Root.AppendField("foos"), + [], + [Requirement(schema, "__fusion_1_id", "id", new NamedTypeNode("ID"))])); + + // replacing the list moves every collected target into a new block, so the cursors are stale + var foos = store.Result.Data.GetProperty("foos"); + Assert.True(foos[0].TryGetObjectContext(out var context)); + foos.SetArrayValue(3); + foos = store.Result.Data.GetProperty("foos"); + foos[0].SetObjectValue(context.SelectionSet!); + foos[1].SetObjectValue(context.SelectionSet!); + foos[2].SetObjectValue(context.SelectionSet!); + + var resultSelectionSet = ResultSelectionSet.Create( + Utf8GraphQLParser.Parse("{ name }") + .Definitions + .OfType() + .Single() + .SelectionSet, + schema); + var payload = """{"data":{"name":"One"}}"""u8.ToArray(); + var document = SourceResultDocument.Parse(sourceArena, payload, payload.Length); + var result = new SourceSchemaResult(entry.Path, document, additionalPaths: entry.AdditionalPaths); + + // act + store.AddPartialResult(SelectionPath.Root, result, resultSelectionSet, containsErrors: false); + + // assert + RenderData(store).MatchInlineSnapshot( + """ + {"foos":[{"id":null,"name":"One"},{"id":null,"name":"One"},{"id":null,"name":"One"}]} + """); + } + + [Fact] + public void AdditionalPaths_Should_Throw_When_StoreWasCleaned() + { + // arrange + var schema = ComposeSchema( + """ + # name: test + type Query { + foos: [Foo] + } + + type Foo { + id: ID! + name: String + } + """); + + using var resultArena = new MemoryArena(); + using var sourceArena = new MemoryArena(); + using var store = CreateLiveStore( + schema, + "{ foos { id name } }", + """{"data":{"foos":[{"id":"1"},{"id":"1"}]}}""", + resultArena, + sourceArena); + var entry = Assert.Single( + store.CreateVariableValueSets( + SelectionPath.Root.AppendField("foos"), + [], + [Requirement(schema, "__fusion_1_id", "id", new NamedTypeNode("ID"))])); + store.Clean(maxCollectTargetRetainLength: 256, maxDictionaryRetainCapacity: 256); + + // act + var exception = Assert.Throws(() => entry.AdditionalPaths[0]); + + // assert + Assert.Equal( + "The paths of lookup targets can only be created while their result document is in use.", + exception.Message); + } + + [Fact] + public void ApplyTo_Should_KeepTargetOrder_When_PathsAndCursorsAreMixed() + { + // arrange + var schema = ComposeSchema( + """ + # name: test + type Query { + foos: [Foo] + } + + type Foo { + id: ID! + name: String + } + """); + + using var resultArena = new MemoryArena(); + using var sourceArena = new MemoryArena(); + using var store = CreateLiveStore( + schema, + "{ foos { id name } }", + """{"data":{"foos":[{"id":"1"},{"id":"2"},{"id":"3"}]}}""", + resultArena, + sourceArena); + var foos = store.Result.Data.GetProperty("foos"); + var variableValueSets = new[] { CreateVariableValues(store, foos[0].CompactPath) }; + var accumulator = new AdditionalPathAccumulator(capacityHint: 2); + + // act + accumulator.Add(0, foos[1].CompactPath); + accumulator.Add(0, foos[2]); + accumulator.ApplyTo(variableValueSets, 1, store, store.Result); + accumulator.Dispose(); + + // assert + RenderVariableValueSets(store, variableValueSets).MatchInlineSnapshot( + """ + Path: foos[0] + Additional paths: [foos[1], foos[2]] + Variables: {} + """); + } + [Theory] [InlineData( "price", From 874deed29541e875c883da2b9822ef2f5cf2b54f Mon Sep 17 00:00:00 2001 From: Michael Staib Date: Wed, 21 Oct 2026 04:53:22 +0000 Subject: [PATCH 04/10] Create lazy lookup target paths outside the request path pool - Paths created from target cursors on first access are allocated instead of rented from the request path pool, which never got them back and grew for every event of a subscription. - The benchmark copy passes the capacity hint like production. - Tests cover the cursor fast path for later variable sets, duplicate targets of a source result with errors, and accumulator growth with paths. - Documentation states the contracts of the accumulator, the cursor paths and the target resolver. --- .../ForwardedVariableBlitBenchmark.cs | 4 +- .../Results/AdditionalPathAccumulator.cs | 11 +- .../Execution/Results/FetchResultStore.cs | 17 +- .../Execution/Results/TargetCursorPaths.cs | 4 +- .../Text/Json/CompositeResultDocument.cs | 14 +- .../Results/FetchResultStoreTests.cs | 146 +++++++++++++++++- 6 files changed, 166 insertions(+), 30 deletions(-) diff --git a/src/HotChocolate/Fusion/benchmarks/Fusion.Execution.Benchmarks/ForwardedVariableBlitBenchmark.cs b/src/HotChocolate/Fusion/benchmarks/Fusion.Execution.Benchmarks/ForwardedVariableBlitBenchmark.cs index 2b1d0a9ef37..6c83749c4f7 100644 --- a/src/HotChocolate/Fusion/benchmarks/Fusion.Execution.Benchmarks/ForwardedVariableBlitBenchmark.cs +++ b/src/HotChocolate/Fusion/benchmarks/Fusion.Execution.Benchmarks/ForwardedVariableBlitBenchmark.cs @@ -278,7 +278,7 @@ private static ImmutableArray BuildFromSnapshotCurrent( dedupTable.Initialize(importedEntries.Length); VariableValues[]? variableValueSets = null; - var additionalPaths = new AdditionalPathAccumulator(); + var additionalPaths = new AdditionalPathAccumulator(importedEntries.Length); var nextIndex = 0; foreach (var importedEntry in importedEntries) @@ -350,7 +350,7 @@ private static ImmutableArray BuildFromSnapshotPrefixBlit( dedupTable.Initialize(importedEntries.Length); VariableValues[]? variableValueSets = null; - var additionalPaths = new AdditionalPathAccumulator(); + var additionalPaths = new AdditionalPathAccumulator(importedEntries.Length); var nextIndex = 0; // Rider: requirement keys are GraphQL names (always ASCII), so they are diff --git a/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Results/AdditionalPathAccumulator.cs b/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Results/AdditionalPathAccumulator.cs index 8807bb686df..64f3c2753d6 100644 --- a/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Results/AdditionalPathAccumulator.cs +++ b/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Results/AdditionalPathAccumulator.cs @@ -4,11 +4,9 @@ namespace HotChocolate.Fusion.Execution.Results; /// -/// A flat, allocation-free accumulator for additional targets that replaces per-slot -/// List<CompactPath> with ArrayPool-rented buffers. -/// Stores (slotIndex, target) pairs and produces -/// per slot via counting sort in ApplyTo. A target is either a path or a result element; when -/// every target is a result element, the segments keep cursors and create paths on first access. +/// Collects the additional targets of variable value set slots, as paths or result elements, and +/// assigns them to their slots in the order they were added. When every target is a result element, +/// the assigned segments create their paths on first access. /// internal ref struct AdditionalPathAccumulator { @@ -24,8 +22,7 @@ internal ref struct AdditionalPathAccumulator private int _pathCount; /// - /// Creates an accumulator that sizes its buffers for targets - /// when the first target is added. + /// Creates an accumulator that expects about targets. /// public AdditionalPathAccumulator(int capacityHint) { 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 88daaa07338..91c866c4de2 100644 --- a/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Results/FetchResultStore.cs +++ b/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Results/FetchResultStore.cs @@ -958,9 +958,8 @@ private bool SaveSafeResult( private readonly record struct LeafValue(Selection Selection, CompositeResultElement Value); /// - /// Resolves the additional targets of a source schema result. Targets collected as cursors - /// are used directly while the result document keeps the structure they were collected in, - /// otherwise each target is resolved from the root by its path. + /// Resolves the additional targets of a source schema result to the elements currently at their + /// paths. /// private readonly ref struct AdditionalTargets { @@ -2149,7 +2148,7 @@ internal CompactPath[] CreateCompactPaths(CompositeResultDocument document, Read for (var i = 0; i < cursors.Length; i++) { - paths[i] = document.CreateCompactPath(new CompositeResultDocument.Cursor(cursors[i])); + paths[i] = document.CreateUnpooledCompactPath(new CompositeResultDocument.Cursor(cursors[i])); } return paths; @@ -2161,14 +2160,6 @@ internal CompactPath[] CreateCompactPaths(CompositeResultDocument document, Read int startPosition, ref AdditionalPathAccumulator additionalPaths, int nextIndex) - => TryCreateVariableValues(target, startPosition, ref additionalPaths, nextIndex, out _); - - private VariableValues? TryCreateVariableValues( - CompositeResultElement target, - int startPosition, - ref AdditionalPathAccumulator additionalPaths, - int nextIndex, - out int dedupIndex) { var length = _variableWriter.Position - startPosition; var hash = _variableWriter.GetHashCode(startPosition, length); @@ -2177,13 +2168,11 @@ internal CompactPath[] CreateCompactPaths(CompositeResultDocument document, Read // the same fetch, so it is tracked as an additional target of the existing entry. if (_variableDedupTable.TryGet(hash, startPosition, length, out var existingIndex)) { - dedupIndex = existingIndex; additionalPaths.Add(existingIndex, target); _variableWriter.ResetTo(startPosition); return null; } - dedupIndex = nextIndex; _variableDedupTable.Add(hash, nextIndex, startPosition, length); return new VariableValues(target.CompactPath, JsonSegment.Create(_variableWriter, startPosition, length)); } diff --git a/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Results/TargetCursorPaths.cs b/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Results/TargetCursorPaths.cs index bc0bfa25a92..a621bba3e1c 100644 --- a/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Results/TargetCursorPaths.cs +++ b/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Results/TargetCursorPaths.cs @@ -3,8 +3,8 @@ namespace HotChocolate.Fusion.Execution.Results; /// -/// The additional targets of lookup variable value sets, kept as cursors into the result document -/// until their paths are requested. +/// The additional targets of a lookup, kept as cursors into the result document until their paths +/// are requested. /// internal sealed class TargetCursorPaths : CompactPathSource { diff --git a/src/HotChocolate/Fusion/src/Fusion.Execution/Text/Json/CompositeResultDocument.cs b/src/HotChocolate/Fusion/src/Fusion.Execution/Text/Json/CompositeResultDocument.cs index f2f416078e0..a8aeb3b57b5 100644 --- a/src/HotChocolate/Fusion/src/Fusion.Execution/Text/Json/CompositeResultDocument.cs +++ b/src/HotChocolate/Fusion/src/Fusion.Execution/Text/Json/CompositeResultDocument.cs @@ -167,8 +167,18 @@ internal int GetPropertyCount(Cursor current) return row.SizeOrLength; } - [SkipLocalsInit] internal CompactPath CreateCompactPath(Cursor current) + => CreateCompactPath(current, Volatile.Read(ref _pathPool)); + + /// + /// Creates the path of the element at in arrays that are not rented + /// from the request scoped pool. + /// + internal CompactPath CreateUnpooledCompactPath(Cursor current) + => CreateCompactPath(current, pathPool: null); + + [SkipLocalsInit] + private CompactPath CreateCompactPath(Cursor current, PathSegmentLocalPool? pathPool) { var firstRow = _metaDb.Get(current); @@ -201,7 +211,7 @@ internal CompactPath CreateCompactPath(Cursor current) } Span pathBuffer = stackalloc int[32]; - var path = new CompactPathBuilder(pathBuffer, Volatile.Read(ref _pathPool)); + var path = new CompactPathBuilder(pathBuffer, pathPool); var parentTokenType = ElementTokenType.StartObject; for (var i = written - 1; i >= 0; i--) 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 2bca86fd454..f5e1cc94fcc 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 @@ -2375,7 +2375,7 @@ type Foo { [], [Requirement(schema, "__fusion_1_id", "id", new NamedTypeNode("ID"))])); - // replacing the list moves every collected target into a new block, so the cursors are stale + // replace the list so the collected cursors point to detached elements var foos = store.Result.Data.GetProperty("foos"); Assert.True(foos[0].TryGetObjectContext(out var context)); foos.SetArrayValue(3); @@ -2474,10 +2474,10 @@ type Foo { var foos = store.Result.Data.GetProperty("foos"); var variableValueSets = new[] { CreateVariableValues(store, foos[0].CompactPath) }; var accumulator = new AdditionalPathAccumulator(capacityHint: 2); - - // act accumulator.Add(0, foos[1].CompactPath); accumulator.Add(0, foos[2]); + + // act accumulator.ApplyTo(variableValueSets, 1, store, store.Result); accumulator.Dispose(); @@ -2490,6 +2490,146 @@ type Foo { """); } + [Fact] + public void AddPartialResults_Should_CompleteRepeatedTargetsOfEachEntry_When_TargetsAreCurrent() + { + // arrange + var schema = ComposeSchema( + """ + # name: test + type Query { + foos: [Foo] + } + + type Foo { + id: ID! + name: String + } + """); + + using var resultArena = new MemoryArena(); + using var sourceArena = new MemoryArena(); + using var store = CreateLiveStore( + schema, + "{ foos { id name } }", + """{"data":{"foos":[{"id":"1"},{"id":"2"},{"id":"1"},{"id":"2"}]}}""", + resultArena, + sourceArena); + var entries = store.CreateVariableValueSets( + SelectionPath.Root.AppendField("foos"), + [], + [Requirement(schema, "__fusion_1_id", "id", new NamedTypeNode("ID"))]); + var resultSelectionSet = ResultSelectionSet.Create( + Utf8GraphQLParser.Parse("{ name }") + .Definitions + .OfType() + .Single() + .SelectionSet, + schema); + var one = """{"data":{"name":"One"}}"""u8.ToArray(); + var two = """{"data":{"name":"Two"}}"""u8.ToArray(); + SourceSchemaResult[] results = + [ + new( + entries[0].Path, + SourceResultDocument.Parse(sourceArena, one, one.Length), + additionalPaths: entries[0].AdditionalPaths), + new( + entries[1].Path, + SourceResultDocument.Parse(sourceArena, two, two.Length), + additionalPaths: entries[1].AdditionalPaths) + ]; + var structureVersion = store.Result.StructureVersion; + + // act + store.AddPartialResults(SelectionPath.Root, results, resultSelectionSet, containsErrors: false); + + // assert + Assert.Equal(structureVersion, store.Result.StructureVersion); + RenderData(store).MatchInlineSnapshot( + """ + {"foos":[{"id":"1","name":"One"},{"id":"2","name":"Two"},{"id":"1","name":"One"},{"id":"2","name":"Two"}]} + """); + } + + [Fact] + public void AddPartialResult_Should_CompleteRepeatedTargets_When_SourceResultHasErrors() + { + // arrange + var schema = ComposeSchema( + """ + # name: test + type Query { + foos: [Foo] + } + + type Foo { + id: ID! + name: String + code: String + } + """); + + using var resultArena = new MemoryArena(); + using var sourceArena = new MemoryArena(); + using var store = CreateLiveStore( + schema, + "{ foos { id name code } }", + """{"data":{"foos":[{"id":"1"},{"id":"1"}]}}""", + resultArena, + sourceArena); + var entry = Assert.Single( + store.CreateVariableValueSets( + SelectionPath.Root.AppendField("foos"), + [], + [Requirement(schema, "__fusion_1_id", "id", new NamedTypeNode("ID"))])); + var resultSelectionSet = ResultSelectionSet.Create( + Utf8GraphQLParser.Parse("{ name code }") + .Definitions + .OfType() + .Single() + .SelectionSet, + schema); + var payload = + """{"data":{"name":"One","code":null},"errors":[{"message":"boom","path":["code"]}]}"""u8.ToArray(); + var document = SourceResultDocument.Parse(sourceArena, payload, payload.Length); + var result = new SourceSchemaResult(entry.Path, document, additionalPaths: entry.AdditionalPaths); + + // act + store.AddPartialResult(SelectionPath.Root, result, resultSelectionSet, containsErrors: true); + + // assert + Assert.Equal( + ["foos[0].code", "foos[1].code"], + store.Errors!.Select(error => error.Path!.Print())); + RenderData(store).MatchInlineSnapshot( + """ + {"foos":[{"id":"1","name":"One","code":null},{"id":"1","name":"One","code":null}]} + """); + } + + [Fact] + public void CreateVariableValueSetsFromSnapshot_Should_PreserveAdditionalPaths_When_PathsExceedInitialCapacity() + { + // arrange + using var source = new FetchResultStore(); + using var target = new FetchResultStore(); + var additionalPaths = Enumerable.Range(1, 20).Select(i => Path(i)).ToArray(); + var entry = WithAdditionalPaths( + CreateVariableValues(source, Path(0), Field("__fusion_1_id", new StringValueNode("1"))), + additionalPaths); + + // act + var result = target.CreateVariableValueSetsFromSnapshot( + [entry], + ImportedKeys("__fusion_1_id"), + [], + [Requirement("__fusion_1_id")]); + + // assert + Assert.Equal(additionalPaths, Assert.Single(result).AdditionalPaths.AsSpan().ToArray()); + } + [Theory] [InlineData( "price", From fcded767a6a1598cfbedac4df0b6c070b5700a27 Mon Sep 17 00:00:00 2001 From: Michael Staib Date: Wed, 21 Oct 2026 04:59:08 +0000 Subject: [PATCH 05/10] Move the nested types of FetchResultStore to the end of the class --- .../Execution/Results/FetchResultStore.cs | 60 +++++++++---------- 1 file changed, 30 insertions(+), 30 deletions(-) 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 91c866c4de2..e49dec0d50b 100644 --- a/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Results/FetchResultStore.cs +++ b/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Results/FetchResultStore.cs @@ -955,36 +955,6 @@ private bool SaveSafeResult( return SaveSafeResult(resultData, additionalTargets[index], dataElement, errorTrie, resultSelectionSet); } - private readonly record struct LeafValue(Selection Selection, CompositeResultElement Value); - - /// - /// Resolves the additional targets of a source schema result to the elements currently at their - /// paths. - /// - private readonly ref struct AdditionalTargets - { - private readonly FetchResultStore _store; - private readonly CompactPathSegment _segment; - private readonly TargetCursorPaths? _cursors; - - public AdditionalTargets(FetchResultStore store, CompactPathSegment segment) - { - _store = store; - _segment = segment; - _cursors = segment.Source is TargetCursorPaths cursors - && ReferenceEquals(cursors.Document, store._result) - ? cursors - : null; - } - - public int Length => _segment.Length; - - public CompositeResultElement this[int index] - => _cursors is { IsCurrent: true } cursors - ? cursors.GetElement(_segment.Offset + index) - : _store.GetStartResult(_segment[index]); - } - public ImmutableArray CreateVariableValueSets( SelectionPath selectionSet, IReadOnlyList requestVariables, @@ -3140,6 +3110,36 @@ private ImmutableArray FinalizeVariableValueSets( return ImmutableCollectionsMarshal.AsImmutableArray(result); } + private readonly record struct LeafValue(Selection Selection, CompositeResultElement Value); + + /// + /// Resolves the additional targets of a source schema result to the elements currently at their + /// paths. + /// + private readonly ref struct AdditionalTargets + { + private readonly FetchResultStore _store; + private readonly CompactPathSegment _segment; + private readonly TargetCursorPaths? _cursors; + + public AdditionalTargets(FetchResultStore store, CompactPathSegment segment) + { + _store = store; + _segment = segment; + _cursors = segment.Source is TargetCursorPaths cursors + && ReferenceEquals(cursors.Document, store._result) + ? cursors + : null; + } + + public int Length => _segment.Length; + + public CompositeResultElement this[int index] + => _cursors is { IsCurrent: true } cursors + ? cursors.GetElement(_segment.Offset + index) + : _store.GetStartResult(_segment[index]); + } + private sealed class VariableDedupTable(ChunkedArrayWriter writer) : IDisposable { private const int DefaultBucketSize = 4; From dc596568b520d87eca1eab6b4b0c05a567e11d7e Mon Sep 17 00:00:00 2001 From: Michael Staib Date: Wed, 21 Oct 2026 05:05:26 +0000 Subject: [PATCH 06/10] Create compact paths through one method with a pooled flag --- .../Results/AdditionalPathAccumulator.cs | 2 +- .../Execution/Results/FetchResultStore.cs | 2 +- .../Text/Json/CompositeResultDocument.cs | 16 +++++----------- .../Text/Json/CompositeResultElement.cs | 4 ++-- 4 files changed, 9 insertions(+), 15 deletions(-) diff --git a/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Results/AdditionalPathAccumulator.cs b/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Results/AdditionalPathAccumulator.cs index 64f3c2753d6..c46caa8e61c 100644 --- a/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Results/AdditionalPathAccumulator.cs +++ b/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Results/AdditionalPathAccumulator.cs @@ -185,7 +185,7 @@ private readonly void CreateSegments( private readonly CompactPath GetPath(int index, CompositeResultDocument document) => _cursors![index] == NoCursor ? _paths![index] - : document.CreateCompactPath(new CompositeResultDocument.Cursor(_cursors[index])); + : document.CreateCompactPath(new CompositeResultDocument.Cursor(_cursors[index]), pooled: true); private int Reserve(int slotIndex) { 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 e49dec0d50b..2b991a1f269 100644 --- a/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Results/FetchResultStore.cs +++ b/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Results/FetchResultStore.cs @@ -2118,7 +2118,7 @@ internal CompactPath[] CreateCompactPaths(CompositeResultDocument document, Read for (var i = 0; i < cursors.Length; i++) { - paths[i] = document.CreateUnpooledCompactPath(new CompositeResultDocument.Cursor(cursors[i])); + paths[i] = document.CreateCompactPath(new CompositeResultDocument.Cursor(cursors[i]), pooled: false); } return paths; diff --git a/src/HotChocolate/Fusion/src/Fusion.Execution/Text/Json/CompositeResultDocument.cs b/src/HotChocolate/Fusion/src/Fusion.Execution/Text/Json/CompositeResultDocument.cs index a8aeb3b57b5..16de902a4d0 100644 --- a/src/HotChocolate/Fusion/src/Fusion.Execution/Text/Json/CompositeResultDocument.cs +++ b/src/HotChocolate/Fusion/src/Fusion.Execution/Text/Json/CompositeResultDocument.cs @@ -167,18 +167,12 @@ internal int GetPropertyCount(Cursor current) return row.SizeOrLength; } - internal CompactPath CreateCompactPath(Cursor current) - => CreateCompactPath(current, Volatile.Read(ref _pathPool)); - /// - /// Creates the path of the element at in arrays that are not rented - /// from the request scoped pool. + /// Creates the path of the element at . When + /// is false, the path arrays are allocated instead of rented from the request scoped pool. /// - internal CompactPath CreateUnpooledCompactPath(Cursor current) - => CreateCompactPath(current, pathPool: null); - [SkipLocalsInit] - private CompactPath CreateCompactPath(Cursor current, PathSegmentLocalPool? pathPool) + internal CompactPath CreateCompactPath(Cursor current, bool pooled) { var firstRow = _metaDb.Get(current); @@ -211,7 +205,7 @@ private CompactPath CreateCompactPath(Cursor current, PathSegmentLocalPool? path } Span pathBuffer = stackalloc int[32]; - var path = new CompactPathBuilder(pathBuffer, pathPool); + var path = new CompactPathBuilder(pathBuffer, pooled ? Volatile.Read(ref _pathPool) : null); var parentTokenType = ElementTokenType.StartObject; for (var i = written - 1; i >= 0; i--) @@ -244,7 +238,7 @@ private CompactPath CreateCompactPath(Cursor current, PathSegmentLocalPool? path } internal Path CreatePath(Cursor current) - => CreateCompactPath(current).ToPath(_operation); + => CreateCompactPath(current, pooled: true).ToPath(_operation); internal CompositeResultElement GetParent(Cursor current) { diff --git a/src/HotChocolate/Fusion/src/Fusion.Execution/Text/Json/CompositeResultElement.cs b/src/HotChocolate/Fusion/src/Fusion.Execution/Text/Json/CompositeResultElement.cs index 674d5b0632d..cd4f5b8528f 100644 --- a/src/HotChocolate/Fusion/src/Fusion.Execution/Text/Json/CompositeResultElement.cs +++ b/src/HotChocolate/Fusion/src/Fusion.Execution/Text/Json/CompositeResultElement.cs @@ -220,7 +220,7 @@ public CompactPath CompactPath { CheckValidInstance(); - return _parent.CreateCompactPath(_cursor); + return _parent.CreateCompactPath(_cursor, pooled: true); } } @@ -233,7 +233,7 @@ public Path Path { CheckValidInstance(); - var path = _parent.CreateCompactPath(_cursor); + var path = _parent.CreateCompactPath(_cursor, pooled: true); return path.ToPath(_parent.GetOperation()); } } From ed7417c788303e9f71e8acb133e947e8aacdac52 Mon Sep 17 00:00:00 2001 From: Michael Staib Date: Wed, 21 Oct 2026 05:10:18 +0000 Subject: [PATCH 07/10] Default compact paths to pooled and opt out only for lazy target paths --- .../Execution/Results/AdditionalPathAccumulator.cs | 2 +- .../src/Fusion.Execution/Text/Json/CompositeResultDocument.cs | 4 ++-- .../src/Fusion.Execution/Text/Json/CompositeResultElement.cs | 4 ++-- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Results/AdditionalPathAccumulator.cs b/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Results/AdditionalPathAccumulator.cs index c46caa8e61c..64f3c2753d6 100644 --- a/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Results/AdditionalPathAccumulator.cs +++ b/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Results/AdditionalPathAccumulator.cs @@ -185,7 +185,7 @@ private readonly void CreateSegments( private readonly CompactPath GetPath(int index, CompositeResultDocument document) => _cursors![index] == NoCursor ? _paths![index] - : document.CreateCompactPath(new CompositeResultDocument.Cursor(_cursors[index]), pooled: true); + : document.CreateCompactPath(new CompositeResultDocument.Cursor(_cursors[index])); private int Reserve(int slotIndex) { diff --git a/src/HotChocolate/Fusion/src/Fusion.Execution/Text/Json/CompositeResultDocument.cs b/src/HotChocolate/Fusion/src/Fusion.Execution/Text/Json/CompositeResultDocument.cs index 16de902a4d0..28bf944dd51 100644 --- a/src/HotChocolate/Fusion/src/Fusion.Execution/Text/Json/CompositeResultDocument.cs +++ b/src/HotChocolate/Fusion/src/Fusion.Execution/Text/Json/CompositeResultDocument.cs @@ -172,7 +172,7 @@ internal int GetPropertyCount(Cursor current) /// is false, the path arrays are allocated instead of rented from the request scoped pool. /// [SkipLocalsInit] - internal CompactPath CreateCompactPath(Cursor current, bool pooled) + internal CompactPath CreateCompactPath(Cursor current, bool pooled = true) { var firstRow = _metaDb.Get(current); @@ -238,7 +238,7 @@ internal CompactPath CreateCompactPath(Cursor current, bool pooled) } internal Path CreatePath(Cursor current) - => CreateCompactPath(current, pooled: true).ToPath(_operation); + => CreateCompactPath(current).ToPath(_operation); internal CompositeResultElement GetParent(Cursor current) { diff --git a/src/HotChocolate/Fusion/src/Fusion.Execution/Text/Json/CompositeResultElement.cs b/src/HotChocolate/Fusion/src/Fusion.Execution/Text/Json/CompositeResultElement.cs index cd4f5b8528f..674d5b0632d 100644 --- a/src/HotChocolate/Fusion/src/Fusion.Execution/Text/Json/CompositeResultElement.cs +++ b/src/HotChocolate/Fusion/src/Fusion.Execution/Text/Json/CompositeResultElement.cs @@ -220,7 +220,7 @@ public CompactPath CompactPath { CheckValidInstance(); - return _parent.CreateCompactPath(_cursor, pooled: true); + return _parent.CreateCompactPath(_cursor); } } @@ -233,7 +233,7 @@ public Path Path { CheckValidInstance(); - var path = _parent.CreateCompactPath(_cursor, pooled: true); + var path = _parent.CreateCompactPath(_cursor); return path.ToPath(_parent.GetOperation()); } } From f99b9f56d3c140c21c289aa40ad0787325d92eb6 Mon Sep 17 00:00:00 2001 From: Michael Staib Date: Wed, 21 Oct 2026 05:13:20 +0000 Subject: [PATCH 08/10] Rename the result document's StructureVersion to LayoutVersion --- .../Fusion.Execution/Execution/Results/TargetCursorPaths.cs | 6 +++--- .../Fusion.Execution/Text/Json/CompositeResultDocument.cs | 6 +++--- .../Execution/Results/FetchResultStoreTests.cs | 6 +++--- 3 files changed, 9 insertions(+), 9 deletions(-) diff --git a/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Results/TargetCursorPaths.cs b/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Results/TargetCursorPaths.cs index a621bba3e1c..76898bff024 100644 --- a/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Results/TargetCursorPaths.cs +++ b/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Results/TargetCursorPaths.cs @@ -11,7 +11,7 @@ internal sealed class TargetCursorPaths : CompactPathSource private readonly FetchResultStore _store; private readonly CompositeResultDocument _document; private readonly int[] _cursors; - private readonly int _structureVersion; + private readonly int _layoutVersion; private CompactPath[]? _paths; public TargetCursorPaths(FetchResultStore store, CompositeResultDocument document, int[] cursors) @@ -19,7 +19,7 @@ public TargetCursorPaths(FetchResultStore store, CompositeResultDocument documen _store = store; _document = document; _cursors = cursors; - _structureVersion = document.StructureVersion; + _layoutVersion = document.LayoutVersion; } /// @@ -30,7 +30,7 @@ public TargetCursorPaths(FetchResultStore store, CompositeResultDocument documen /// /// Gets true while every cursor still points to the element at its path. /// - public bool IsCurrent => _document.StructureVersion == _structureVersion; + public bool IsCurrent => _document.LayoutVersion == _layoutVersion; /// /// Gets the element at the cursor with the given index. diff --git a/src/HotChocolate/Fusion/src/Fusion.Execution/Text/Json/CompositeResultDocument.cs b/src/HotChocolate/Fusion/src/Fusion.Execution/Text/Json/CompositeResultDocument.cs index 28bf944dd51..a62af5b9b52 100644 --- a/src/HotChocolate/Fusion/src/Fusion.Execution/Text/Json/CompositeResultDocument.cs +++ b/src/HotChocolate/Fusion/src/Fusion.Execution/Text/Json/CompositeResultDocument.cs @@ -20,7 +20,7 @@ public sealed partial class CompositeResultDocument : IDisposable private PathSegmentLocalPool? _pathPool; internal MetaDb _metaDb; private NullMarkerState _nullMarkerState; - private int _structureVersion; + private int _layoutVersion; private int _disposed; internal CompositeResultDocument( @@ -68,7 +68,7 @@ internal CompositeResultDocument( /// Gets a version that changes whenever a value that references an object or a list is /// replaced. While it is unchanged, every element keeps the path it had when it was read. /// - internal int StructureVersion => _structureVersion; + internal int LayoutVersion => _layoutVersion; /// /// Stops this document from renting path segment arrays from the request scoped pool. @@ -676,7 +676,7 @@ private void TrackValueReplacement(Cursor target) if (_metaDb.GetElementTokenType(target, resolveReferences: false) is ElementTokenType.Reference or ElementTokenType.StartObject or ElementTokenType.StartArray) { - _structureVersion++; + _layoutVersion++; } } 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 f5e1cc94fcc..c3e0afc1be9 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 @@ -2345,7 +2345,7 @@ type Foo { } [Fact] - public void AddPartialResult_Should_CompleteTargetsFromRoot_When_StructureChangedAfterCollection() + public void AddPartialResult_Should_CompleteTargetsFromRoot_When_LayoutChangedAfterCollection() { // arrange var schema = ComposeSchema( @@ -2539,13 +2539,13 @@ type Foo { SourceResultDocument.Parse(sourceArena, two, two.Length), additionalPaths: entries[1].AdditionalPaths) ]; - var structureVersion = store.Result.StructureVersion; + var layoutVersion = store.Result.LayoutVersion; // act store.AddPartialResults(SelectionPath.Root, results, resultSelectionSet, containsErrors: false); // assert - Assert.Equal(structureVersion, store.Result.StructureVersion); + Assert.Equal(layoutVersion, store.Result.LayoutVersion); RenderData(store).MatchInlineSnapshot( """ {"foos":[{"id":"1","name":"One"},{"id":"2","name":"Two"},{"id":"1","name":"One"},{"id":"2","name":"Two"}]} From 07200cc02f28dc06a3c33055d68eadb9f50b6894 Mon Sep 17 00:00:00 2001 From: Michael Staib Date: Wed, 21 Oct 2026 05:28:50 +0000 Subject: [PATCH 09/10] Rename TargetCursorPaths to DuplicateTargetCompactPathSource --- .../Execution/Results/AdditionalPathAccumulator.cs | 2 +- ...CursorPaths.cs => DuplicateTargetCompactPathSource.cs} | 8 ++++---- .../Execution/Results/FetchResultStore.cs | 4 ++-- 3 files changed, 7 insertions(+), 7 deletions(-) rename src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Results/{TargetCursorPaths.cs => DuplicateTargetCompactPathSource.cs} (79%) diff --git a/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Results/AdditionalPathAccumulator.cs b/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Results/AdditionalPathAccumulator.cs index 64f3c2753d6..679800994a5 100644 --- a/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Results/AdditionalPathAccumulator.cs +++ b/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Results/AdditionalPathAccumulator.cs @@ -153,7 +153,7 @@ private readonly void CreateSegments( cursors[writePos[_slotIndices![i]]++] = _cursors![i]; } - var source = new TargetCursorPaths(store, document, cursors); + var source = new DuplicateTargetCompactPathSource(store, document, cursors); for (var slot = 0; slot < slotCount; slot++) { diff --git a/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Results/TargetCursorPaths.cs b/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Results/DuplicateTargetCompactPathSource.cs similarity index 79% rename from src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Results/TargetCursorPaths.cs rename to src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Results/DuplicateTargetCompactPathSource.cs index 76898bff024..f8c912bfa16 100644 --- a/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Results/TargetCursorPaths.cs +++ b/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Results/DuplicateTargetCompactPathSource.cs @@ -3,10 +3,10 @@ namespace HotChocolate.Fusion.Execution.Results; /// -/// The additional targets of a lookup, kept as cursors into the result document until their paths -/// are requested. +/// A compact path source for the duplicate targets of a lookup, which keeps the targets as cursors +/// into the result document until their paths are requested. /// -internal sealed class TargetCursorPaths : CompactPathSource +internal sealed class DuplicateTargetCompactPathSource : CompactPathSource { private readonly FetchResultStore _store; private readonly CompositeResultDocument _document; @@ -14,7 +14,7 @@ internal sealed class TargetCursorPaths : CompactPathSource private readonly int _layoutVersion; private CompactPath[]? _paths; - public TargetCursorPaths(FetchResultStore store, CompositeResultDocument document, int[] cursors) + public DuplicateTargetCompactPathSource(FetchResultStore store, CompositeResultDocument document, int[] cursors) { _store = store; _document = document; 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 2b991a1f269..62d534b45f6 100644 --- a/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Results/FetchResultStore.cs +++ b/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Results/FetchResultStore.cs @@ -3120,13 +3120,13 @@ private readonly ref struct AdditionalTargets { private readonly FetchResultStore _store; private readonly CompactPathSegment _segment; - private readonly TargetCursorPaths? _cursors; + private readonly DuplicateTargetCompactPathSource? _cursors; public AdditionalTargets(FetchResultStore store, CompactPathSegment segment) { _store = store; _segment = segment; - _cursors = segment.Source is TargetCursorPaths cursors + _cursors = segment.Source is DuplicateTargetCompactPathSource cursors && ReferenceEquals(cursors.Document, store._result) ? cursors : null; From 4545e757df0aea247b06512f788b6fe0e288c70d Mon Sep 17 00:00:00 2001 From: Michael Staib Date: Wed, 21 Oct 2026 05:44:30 +0000 Subject: [PATCH 10/10] Correct the comment on skipping lazily created target paths --- .../src/Fusion.Execution/Execution/Results/FetchResultStore.cs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) 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 62d534b45f6..e2f9ae29a0f 100644 --- a/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Results/FetchResultStore.cs +++ b/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Results/FetchResultStore.cs @@ -3060,8 +3060,7 @@ private void ReturnPathSegments(SourceSchemaResult result, HashSet seen) { ReturnPathSegments(result.Path, seen); - // Paths that are created on first access are not returned early; - // the path pool reclaims them when it is reset. + // Paths created on first access are not rented from the path pool, so they are not returned. if (result.AdditionalPaths.Source is not null) { return;