diff --git a/src/HotChocolate/Core/src/Execution.Abstractions/Execution/ExecutionResult.cs b/src/HotChocolate/Core/src/Execution.Abstractions/Execution/ExecutionResult.cs index a3d5f869992..0f8b52f91c3 100644 --- a/src/HotChocolate/Core/src/Execution.Abstractions/Execution/ExecutionResult.cs +++ b/src/HotChocolate/Core/src/Execution.Abstractions/Execution/ExecutionResult.cs @@ -1,5 +1,6 @@ using System.Buffers; using System.Collections.Immutable; +using System.Runtime.CompilerServices; using HotChocolate.Features; namespace HotChocolate.Execution; @@ -9,8 +10,8 @@ namespace HotChocolate.Execution; /// public abstract class ExecutionResult : IExecutionResult { - private static readonly ArrayPool> s_cleanUpTaskPool = ArrayPool>.Shared; - private Func[] _cleanUpTasks = []; + private static readonly ArrayPool s_cleanUpTaskPool = ArrayPool.Shared; + private CleanupEntry[] _cleanUpTasks = []; private int _cleanupTasksLength; private bool _disposed; @@ -27,29 +28,47 @@ public abstract class ExecutionResult : IExecutionResult /// public IFeatureCollection Features { get; } = new FeatureCollection(); - /// - public void RegisterForCleanup(Func clean) + /// + /// Registers a resource that needs to be disposed when the result is being disposed. + /// + /// + /// The resource that needs to be disposed. + /// + public void RegisterForCleanup(IDisposable disposable) { - ArgumentNullException.ThrowIfNull(clean); - - if (_cleanUpTasks.Length == 0) - { - _cleanUpTasks = s_cleanUpTaskPool.Rent(8); - _cleanupTasksLength = 0; - } - else if (_cleanupTasksLength >= _cleanUpTasks.Length) - { - var buffer = s_cleanUpTaskPool.Rent(_cleanupTasksLength * 2); - var currentBuffer = _cleanUpTasks.AsSpan(); + ArgumentNullException.ThrowIfNull(disposable); + AddCleanupEntry(new CleanupEntry { Target = disposable, Kind = CleanupKind.Disposable }); + } - currentBuffer.CopyTo(buffer); - currentBuffer.Clear(); - s_cleanUpTaskPool.Return(_cleanUpTasks); + /// + /// Registers a cleanup action to be executed when the result is disposed. + /// + /// + /// A cleanup action that will be executed when this result is disposed. + /// + public void RegisterForCleanup(Action clean) + { + ArgumentNullException.ThrowIfNull(clean); + AddCleanupEntry(new CleanupEntry { Target = clean, Kind = CleanupKind.Action }); + } - _cleanUpTasks = buffer; - } + /// + /// Registers a resource that needs to be disposed asynchronously when the result is being disposed. + /// + /// + /// The resource that needs to be disposed. + /// + public void RegisterForCleanup(IAsyncDisposable disposable) + { + ArgumentNullException.ThrowIfNull(disposable); + AddCleanupEntry(new CleanupEntry { Target = disposable, Kind = CleanupKind.AsyncDisposable }); + } - _cleanUpTasks[_cleanupTasksLength++] = clean; + /// + public void RegisterForCleanup(Func clean) + { + ArgumentNullException.ThrowIfNull(clean); + AddCleanupEntry(new CleanupEntry { Target = clean, Kind = CleanupKind.FuncValueTask }); } /// @@ -64,18 +83,73 @@ public async ValueTask DisposeAsync() { if (_cleanupTasksLength > 0) { - var tasks = _cleanUpTasks; + var entries = _cleanUpTasks; for (var i = 0; i < _cleanupTasksLength; i++) { - await tasks[i]().ConfigureAwait(false); + switch (entries[i].Kind) + { + case CleanupKind.FuncValueTask: + await Unsafe.As>(entries[i].Target).Invoke().ConfigureAwait(false); + break; + + case CleanupKind.Disposable: + Unsafe.As(entries[i].Target).Dispose(); + break; + + case CleanupKind.AsyncDisposable: + await Unsafe.As(entries[i].Target).DisposeAsync().ConfigureAwait(false); + break; + + case CleanupKind.Action: + Unsafe.As(entries[i].Target).Invoke(); + break; + } } - tasks.AsSpan(0, _cleanupTasksLength).Clear(); - s_cleanUpTaskPool.Return(tasks); + entries.AsSpan(0, _cleanupTasksLength).Clear(); + s_cleanUpTaskPool.Return(entries); } _disposed = true; } + + GC.SuppressFinalize(this); + } + + private void AddCleanupEntry(CleanupEntry entry) + { + if (_cleanUpTasks.Length == 0) + { + _cleanUpTasks = s_cleanUpTaskPool.Rent(8); + _cleanupTasksLength = 0; + } + else if (_cleanupTasksLength >= _cleanUpTasks.Length) + { + var buffer = s_cleanUpTaskPool.Rent(_cleanupTasksLength * 2); + var currentBuffer = _cleanUpTasks.AsSpan(); + + currentBuffer.CopyTo(buffer); + currentBuffer.Clear(); + s_cleanUpTaskPool.Return(_cleanUpTasks); + + _cleanUpTasks = buffer; + } + + _cleanUpTasks[_cleanupTasksLength++] = entry; + } + + private enum CleanupKind + { + FuncValueTask = 0, + Disposable = 1, + AsyncDisposable = 2, + Action = 3 + } + + private struct CleanupEntry + { + public object Target; + public CleanupKind Kind; } } diff --git a/src/HotChocolate/Core/src/Execution.Abstractions/Execution/Extensions/CoreExecutionResultExtensions.cs b/src/HotChocolate/Core/src/Execution.Abstractions/Execution/Extensions/CoreExecutionResultExtensions.cs index dd6aa672b08..6f837a1d708 100644 --- a/src/HotChocolate/Core/src/Execution.Abstractions/Execution/Extensions/CoreExecutionResultExtensions.cs +++ b/src/HotChocolate/Core/src/Execution.Abstractions/Execution/Extensions/CoreExecutionResultExtensions.cs @@ -52,49 +52,6 @@ public ResponseStream ExpectResponseStream() extension(IExecutionResult result) { - /// - /// Registers a cleanup task for execution resources bound to this execution result. - /// - /// - /// A cleanup task that will be executed when this result is disposed. - /// - public void RegisterForCleanup(Action clean) - { - ArgumentNullException.ThrowIfNull(clean); - - result.RegisterForCleanup(() => - { - clean(); - return default; - }); - } - - /// - /// Registers a resource that needs to be disposed when the result is being disposed. - /// - /// - /// The resource that needs to be disposed. - /// - public void RegisterForCleanup(IDisposable disposable) - { - ArgumentNullException.ThrowIfNull(disposable); - - result.RegisterForCleanup(disposable.Dispose); - } - - /// - /// Registers a resource that needs to be disposed when the result is being disposed. - /// - /// - /// The resource that needs to be disposed. - /// - public void RegisterForCleanup(IAsyncDisposable disposable) - { - ArgumentNullException.ThrowIfNull(disposable); - - result.RegisterForCleanup(disposable.DisposeAsync); - } - /// /// Defines if the specified is a response stream. /// diff --git a/src/HotChocolate/Core/src/Execution.Abstractions/Execution/IExecutionResult.cs b/src/HotChocolate/Core/src/Execution.Abstractions/Execution/IExecutionResult.cs index 1b16e2a40d3..73c72e051d6 100644 --- a/src/HotChocolate/Core/src/Execution.Abstractions/Execution/IExecutionResult.cs +++ b/src/HotChocolate/Core/src/Execution.Abstractions/Execution/IExecutionResult.cs @@ -28,6 +28,50 @@ public interface IExecutionResult : IFeatureProvider, IAsyncDisposable set => Features.Set(value); } + /// + /// Registers a resource that needs to be disposed when the result is being disposed. + /// + /// + /// The resource that needs to be disposed. + /// + void RegisterForCleanup(IDisposable disposable) + { + ArgumentNullException.ThrowIfNull(disposable); + RegisterForCleanup(() => + { + disposable.Dispose(); + return default; + }); + } + + /// + /// Registers a cleanup action to be executed when the result is disposed. + /// + /// + /// A cleanup action that will be executed when this result is disposed. + /// + void RegisterForCleanup(Action clean) + { + ArgumentNullException.ThrowIfNull(clean); + RegisterForCleanup(() => + { + clean(); + return default; + }); + } + + /// + /// Registers a resource that needs to be disposed asynchronously when the result is being disposed. + /// + /// + /// The resource that needs to be disposed. + /// + void RegisterForCleanup(IAsyncDisposable disposable) + { + ArgumentNullException.ThrowIfNull(disposable); + RegisterForCleanup(disposable.DisposeAsync); + } + /// /// Registers a cleanup task for execution resources bound to this execution result. /// diff --git a/src/HotChocolate/Core/src/Execution.Abstractions/Execution/OperationResult.cs b/src/HotChocolate/Core/src/Execution.Abstractions/Execution/OperationResult.cs index 03373d9b678..35c2488c21f 100644 --- a/src/HotChocolate/Core/src/Execution.Abstractions/Execution/OperationResult.cs +++ b/src/HotChocolate/Core/src/Execution.Abstractions/Execution/OperationResult.cs @@ -48,11 +48,7 @@ public OperationResult( if (data.MemoryHolder is { } memoryHolder) { - RegisterForCleanup(() => - { - memoryHolder.Dispose(); - return ValueTask.CompletedTask; - }); + RegisterForCleanup(memoryHolder); } } diff --git a/src/HotChocolate/Core/src/Execution.Abstractions/Path.cs b/src/HotChocolate/Core/src/Execution.Abstractions/Path.cs index e4f1644a208..6da8fc09558 100644 --- a/src/HotChocolate/Core/src/Execution.Abstractions/Path.cs +++ b/src/HotChocolate/Core/src/Execution.Abstractions/Path.cs @@ -263,12 +263,12 @@ public IReadOnlyList ToList() } /// - /// Creates a new list representing the current . + /// Copies the segments of the current into the provided span. /// - /// - /// Returns a new list representing the current . - /// - public void ToList(Span path) + /// + /// The destination span. Must be at least elements long. + /// + public void CopyTo(Span path) { if (IsRoot) { @@ -283,7 +283,7 @@ public void ToList(Span path) } var current = this; - var length = path.Length; + var length = Length; while (!current.IsRoot) { diff --git a/src/HotChocolate/Core/test/Execution.Abstractions.Tests/Execution/ResponseStreamTests.cs b/src/HotChocolate/Core/test/Execution.Abstractions.Tests/Execution/ResponseStreamTests.cs index db461d00f17..f52ae067bbb 100644 --- a/src/HotChocolate/Core/test/Execution.Abstractions.Tests/Execution/ResponseStreamTests.cs +++ b/src/HotChocolate/Core/test/Execution.Abstractions.Tests/Execution/ResponseStreamTests.cs @@ -70,7 +70,7 @@ public void Register_One_Async_Cleanup_Func_Func_is_Null() var result = new ResponseStream(() => null!); // act - void Fail() => result.RegisterForCleanup(null!); + void Fail() => result.RegisterForCleanup(default(Func)!); // assert Assert.Throws(Fail); diff --git a/src/HotChocolate/Core/test/Execution.Tests/DocumentCacheTests.cs b/src/HotChocolate/Core/test/Execution.Tests/DocumentCacheTests.cs index 270b53dde5a..20dc4d8e567 100644 --- a/src/HotChocolate/Core/test/Execution.Tests/DocumentCacheTests.cs +++ b/src/HotChocolate/Core/test/Execution.Tests/DocumentCacheTests.cs @@ -30,7 +30,7 @@ public async Task Document_Cache_Should_Not_Be_Scoped_To_Executor() { // arrange var executorEvictedResetEvent = new ManualResetEventSlim(false); - using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5)); + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(30)); var services = new ServiceCollection() diff --git a/src/HotChocolate/Fusion/src/Fusion.Connectors.InMemory/InMemorySourceSchemaClient.cs b/src/HotChocolate/Fusion/src/Fusion.Connectors.InMemory/InMemorySourceSchemaClient.cs index 462d1e7d27a..0d0d8277807 100644 --- a/src/HotChocolate/Fusion/src/Fusion.Connectors.InMemory/InMemorySourceSchemaClient.cs +++ b/src/HotChocolate/Fusion/src/Fusion.Connectors.InMemory/InMemorySourceSchemaClient.cs @@ -368,19 +368,19 @@ private static bool TryGetResultPath( SourceSchemaClientRequest request, int variableIndex, out CompactPath path, - out ImmutableArray additionalPaths) + out CompactPathSegment additionalPaths) { if (request.Variables.Length == 0) { path = CompactPath.Root; - additionalPaths = []; + additionalPaths = default; return true; } if ((uint)variableIndex >= (uint)request.Variables.Length) { path = CompactPath.Root; - additionalPaths = []; + additionalPaths = default; return false; } diff --git a/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Clients/SourceSchemaErrors.cs b/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Clients/SourceSchemaErrors.cs index 5d0eaa7fc48..a9207215c99 100644 --- a/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Clients/SourceSchemaErrors.cs +++ b/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Clients/SourceSchemaErrors.cs @@ -13,6 +13,7 @@ namespace HotChocolate.Fusion.Execution.Clients; /// public sealed class SourceSchemaErrors { + private static readonly ArrayPool s_objectPool = ArrayPool.Shared; /// /// Gets the collection of errors that are not associated with specific GraphQL field paths. /// @@ -64,9 +65,9 @@ public sealed class SourceSchemaErrors continue; } - var rented = ArrayPool.Shared.Rent(error.Path.Length); + var rented = s_objectPool.Rent(error.Path.Length); var pathSegments = rented.AsSpan(0, error.Path.Length); - error.Path.ToList(pathSegments); + error.Path.CopyTo(pathSegments); var lastPathIndex = pathSegments.Length - 1; try @@ -95,7 +96,7 @@ public sealed class SourceSchemaErrors finally { pathSegments.Clear(); - ArrayPool.Shared.Return(rented); + s_objectPool.Return(rented); } } diff --git a/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Clients/SourceSchemaHttpClient.cs b/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Clients/SourceSchemaHttpClient.cs index 67b305c7505..bcd859663cb 100644 --- a/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Clients/SourceSchemaHttpClient.cs +++ b/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Clients/SourceSchemaHttpClient.cs @@ -669,19 +669,19 @@ private static bool TryGetResultPath( SourceSchemaClientRequest request, int variableIndex, out CompactPath path, - out ImmutableArray additionalPaths) + out CompactPathSegment additionalPaths) { if (request.Variables.Length == 0) { path = CompactPath.Root; - additionalPaths = []; + additionalPaths = default; return true; } if ((uint)variableIndex >= (uint)request.Variables.Length) { path = CompactPath.Root; - additionalPaths = []; + additionalPaths = default; return false; } diff --git a/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Clients/SourceSchemaResult.cs b/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Clients/SourceSchemaResult.cs index 9da4e9be928..a4982056cb3 100644 --- a/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Clients/SourceSchemaResult.cs +++ b/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Clients/SourceSchemaResult.cs @@ -1,4 +1,3 @@ -using System.Collections.Immutable; using HotChocolate.Fusion.Text.Json; namespace HotChocolate.Fusion.Execution.Clients; @@ -29,7 +28,7 @@ public SourceSchemaResult( CompactPath path, SourceResultDocument document, FinalMessage final = FinalMessage.Undefined, - ImmutableArray additionalPaths = default) + CompactPathSegment additionalPaths = default) : this(path, document, final, ownsDocument: true, additionalPaths) { } @@ -39,13 +38,13 @@ private SourceSchemaResult( SourceResultDocument document, FinalMessage final, bool ownsDocument, - ImmutableArray additionalPaths) + CompactPathSegment additionalPaths) { ArgumentNullException.ThrowIfNull(document); _document = document; _ownsDocument = ownsDocument; - AdditionalPaths = additionalPaths.IsDefault ? [] : additionalPaths; + AdditionalPaths = additionalPaths; Path = path; Final = final; } @@ -59,7 +58,7 @@ private SourceSchemaResult( /// Additional paths where this result should also be merged, used when a single source /// schema response satisfies multiple selection sets at different locations. /// - public ImmutableArray AdditionalPaths { get; } + public CompactPathSegment AdditionalPaths { get; } /// /// The data element of the source schema response, or an empty element if the @@ -132,9 +131,9 @@ public SourceResultElement Extensions /// at a different location in the composite result. /// internal SourceSchemaResult WithPath(CompactPath path) - => new(path, _document, Final, ownsDocument: false, additionalPaths: []); + => new(path, _document, Final, ownsDocument: false, additionalPaths: default); - internal SourceSchemaResult WithPath(CompactPath path, ImmutableArray additionalPaths) + internal SourceSchemaResult WithPath(CompactPath path, CompactPathSegment additionalPaths) => new(path, _document, Final, ownsDocument: false, additionalPaths); /// diff --git a/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/ExecutionState.cs b/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/ExecutionState.cs index fd6b5ac9475..03944446a06 100644 --- a/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/ExecutionState.cs +++ b/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/ExecutionState.cs @@ -1,3 +1,4 @@ +using System.Buffers; using System.Collections.Concurrent; using System.Collections.Immutable; using System.Diagnostics.CodeAnalysis; @@ -18,7 +19,7 @@ internal sealed class ExecutionState private readonly List _trackedNodeStateSlots = []; private readonly List _trackedDependencySlots = []; private readonly ConcurrentQueue _completedResults = new(); - private readonly HashSet _failedOrSkippedNodes = []; + private ulong[] _failedOrSkippedBitset = []; private bool _collectTelemetry; private CancellationTokenSource _cts = default!; @@ -42,15 +43,40 @@ public void Clean() _cts = default!; } + public void Destroy() + { + if (_nodeStates.Length > 0) + { + ArrayPool.Shared.Return(_nodeStates); + _nodeStates = []; + } + + if (_remainingDependencies.Length > 0) + { + ArrayPool.Shared.Return(_remainingDependencies); + _remainingDependencies = []; + } + + if (_failedOrSkippedBitset.Length > 0) + { + ArrayPool.Shared.Return(_failedOrSkippedBitset); + _failedOrSkippedBitset = []; + } + } + [MethodImpl(MethodImplOptions.AggressiveInlining)] public bool IsNodeSkipped(int nodeId) - => _failedOrSkippedNodes.Contains(nodeId); + { + var index = nodeId >> 6; + return index < _failedOrSkippedBitset.Length + && (_failedOrSkippedBitset[index] & (1UL << (nodeId & 63))) != 0; + } public void FillBacklog(OperationPlan plan) { _ready.Clear(); _backlogCount = 0; - _failedOrSkippedNodes.Clear(); + ClearFailedOrSkippedBitset(); ResetNodeStates(); ResetRemainingDependencies(); @@ -104,7 +130,7 @@ public void Reset() _stack.Clear(); _ready.Clear(); _backlogCount = 0; - _failedOrSkippedNodes.Clear(); + ClearFailedOrSkippedBitset(); ResetNodeStates(); ResetRemainingDependencies(); @@ -190,7 +216,7 @@ public void CompleteNode( { foreach (var def in result.SkippedDefinitions) { - _failedOrSkippedNodes.Add(def.Id); + MarkNodeAsSkipped(def.Id); } } @@ -260,7 +286,7 @@ public void SkipNode(OperationPlan plan, ExecutionNode node) while (_stack.TryPop(out var current)) { - _failedOrSkippedNodes.Add(current.Id); + MarkNodeAsSkipped(current.Id); // When a batch node is skipped without executing, every operation // definition inside it is also skipped. We mark each of their @@ -270,7 +296,7 @@ public void SkipNode(OperationPlan plan, ExecutionNode node) { foreach (var op in batchNode.Operations) { - _failedOrSkippedNodes.Add(op.Id); + MarkNodeAsSkipped(op.Id); } } @@ -302,7 +328,7 @@ public void SkipNode(OperationPlan plan, ExecutionNode node) // whose dependencies failed. if (dependent is not ExecutionNode) { - _failedOrSkippedNodes.Add(dependent.Id); + MarkNodeAsSkipped(dependent.Id); continue; } @@ -374,7 +400,7 @@ private bool ShouldSkipDueToAllOptionalDepsFailed(ExecutionNode node) // All dependencies are optional. Check if every one of them failed or was skipped. foreach (var optDep in node.OptionalDependencies) { - if (!_failedOrSkippedNodes.Contains(optDep.Id)) + if (!IsNodeSkipped(optDep.Id)) { return false; } @@ -488,12 +514,13 @@ private void EnsureDependencyCapacity(int minCapacity) newCapacity *= 2; } - var dependencies = new int[newCapacity]; - Array.Fill(dependencies, -1); + var dependencies = ArrayPool.Shared.Rent(newCapacity); + dependencies.AsSpan().Fill(-1); if (_remainingDependencies.Length > 0) { Array.Copy(_remainingDependencies, dependencies, _remainingDependencies.Length); + ArrayPool.Shared.Return(_remainingDependencies); } _remainingDependencies = dependencies; @@ -610,13 +637,57 @@ private void EnsureNodeStateCapacity(int minCapacity) newCapacity *= 2; } - var nodeStates = new byte[newCapacity]; + var nodeStates = ArrayPool.Shared.Rent(newCapacity); + nodeStates.AsSpan().Clear(); if (_nodeStates.Length > 0) { Array.Copy(_nodeStates, nodeStates, _nodeStates.Length); + ArrayPool.Shared.Return(_nodeStates); } _nodeStates = nodeStates; } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private void MarkNodeAsSkipped(int nodeId) + { + var index = nodeId >> 6; + + if (index >= _failedOrSkippedBitset.Length) + { + EnsureFailedOrSkippedCapacity(index + 1); + } + + _failedOrSkippedBitset[index] |= 1UL << (nodeId & 63); + } + + private void EnsureFailedOrSkippedCapacity(int minWordCount) + { + var newCapacity = _failedOrSkippedBitset.Length == 0 ? 2 : _failedOrSkippedBitset.Length; + + while (newCapacity < minWordCount) + { + newCapacity *= 2; + } + + var newBitset = ArrayPool.Shared.Rent(newCapacity); + newBitset.AsSpan().Clear(); + + if (_failedOrSkippedBitset.Length > 0) + { + _failedOrSkippedBitset.AsSpan().CopyTo(newBitset); + ArrayPool.Shared.Return(_failedOrSkippedBitset); + } + + _failedOrSkippedBitset = newBitset; + } + + private void ClearFailedOrSkippedBitset() + { + if (_failedOrSkippedBitset.Length > 0) + { + _failedOrSkippedBitset.AsSpan().Clear(); + } + } } diff --git a/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/OperationPlanContext.Pooling.cs b/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/OperationPlanContext.Pooling.cs index d09e3bd98e3..d2d1c9eb509 100644 --- a/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/OperationPlanContext.Pooling.cs +++ b/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/OperationPlanContext.Pooling.cs @@ -90,7 +90,11 @@ internal void Clean() /// Permanently destroys the context and its owned resources. /// Called when the pool drops a context (pool full) or during pool disposal. /// - internal void Destroy() => _resultStore.Dispose(); + internal void Destroy() + { + _resultStore.Dispose(); + _executionState.Destroy(); + } public async ValueTask DisposeAsync() { diff --git a/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/OperationPlanContext.cs b/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/OperationPlanContext.cs index 2bc2e25c16a..be0608a228d 100644 --- a/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/OperationPlanContext.cs +++ b/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/OperationPlanContext.cs @@ -2,6 +2,7 @@ using System.Collections.Immutable; using System.Diagnostics; using System.Diagnostics.CodeAnalysis; +using System.Runtime.InteropServices; using HotChocolate.Buffers; using HotChocolate.Execution; using HotChocolate.Features; @@ -141,7 +142,20 @@ internal void TrackSkippedDefinition(ExecutionNode node, IOperationPlanNode skip internal ImmutableArray GetSkippedDefinitions(ExecutionNode node) { var list = _skippedDefinitions[node.Id]; - return list is null or { Count: 0 } ? [] : [.. list]; + + if (list is null or { Count: 0 }) + { + return []; + } + + var array = new IOperationPlanNode[list.Count]; + + for (var i = 0; i < list.Count; i++) + { + array[i] = list[i]; + } + + return ImmutableCollectionsMarshal.AsImmutableArray(array); } internal void SetDynamicSchemaName(ExecutionNode node, string schemaName) @@ -477,32 +491,28 @@ private IReadOnlyList GetPathThroughVariables( return Array.Empty(); } - var variables = new List(forwardedVariables.Length); + var buffer = new ObjectFieldNode[forwardedVariables.Length]; + var count = 0; foreach (var variableName in forwardedVariables) { - // we pass through the required pass through variables, - // if they were not omitted. - // - // it is valid for the GraphQL request to omit nullable variables. - // - // if they were not nullable we would not get here as the - // GraphQL validation would reject such a request. - // - // but even if the validation failed we do not need to - // guard against it and can just pass this to the - // source schema which would in any case validate - // any request and would reject it if a required - // variable was missing. if (Variables.TryGetValue(variableName, out var variableValue)) { - variables.Add(new ObjectFieldNode(variableName, variableValue)); + buffer[count++] = new ObjectFieldNode(variableName, variableValue); } } - return variables.Count == 0 - ? Array.Empty() - : variables; + if (count == 0) + { + return Array.Empty(); + } + + if (count == buffer.Length) + { + return buffer; + } + + return buffer.AsMemory(0, count).ToArray(); } /// diff --git a/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/OperationPlanExecutor.cs b/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/OperationPlanExecutor.cs index c788b30c214..cfbb4fbdf79 100644 --- a/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/OperationPlanExecutor.cs +++ b/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/OperationPlanExecutor.cs @@ -235,12 +235,14 @@ private static async IAsyncEnumerable CreateSubscriptionEnumera static state => Unsafe.As(state)!.TryResetToIdle(), executionState.Signal); + var schemaName = subscriptionNode.SchemaName ?? context.GetDynamicSchemaName(subscriptionNode); + await foreach (var eventArgs in stream) { using var scope = context.DiagnosticEvents.OnSubscriptionEvent( context, subscriptionNode, - subscriptionNode.SchemaName ?? context.GetDynamicSchemaName(subscriptionNode), + schemaName, subscriptionResult.Id); OperationResult result; @@ -295,7 +297,7 @@ private static async IAsyncEnumerable CreateSubscriptionEnumera context.DiagnosticEvents.SubscriptionEventError( context, subscriptionNode, - subscriptionNode.SchemaName ?? context.GetDynamicSchemaName(subscriptionNode), + schemaName, subscriptionResult.Id, ex); throw; 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 d5bef11394b..8764f56fc18 100644 --- a/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Results/AdditionalPathAccumulator.cs +++ b/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Results/AdditionalPathAccumulator.cs @@ -1,5 +1,4 @@ using System.Buffers; -using System.Runtime.InteropServices; using HotChocolate.Fusion.Text.Json; namespace HotChocolate.Fusion.Execution.Results; @@ -7,7 +6,7 @@ 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 ImmutableArray<CompactPath> +/// Stores (slotIndex, path) pairs and produces /// per slot via counting sort in ApplyTo. /// internal ref struct AdditionalPathAccumulator @@ -63,21 +62,21 @@ public void ApplyTo(VariableValues[] variableValueSets, int slotCount) offsets[i] = offsets[i - 1] + counts[i - 1]; } - // Scatter paths into sorted order. + // Scatter paths into a single shared array in sorted order. var writePos = slotCount <= 256 ? stackalloc int[slotCount] : new int[slotCount]; offsets.CopyTo(writePos); - var sorted = ArrayPool.Shared.Rent(_count); + var shared = new CompactPath[_count]; for (var i = 0; i < _count; i++) { var idx = _slotIndices![i]; - sorted[writePos[idx]++] = _paths![i]; + shared[writePos[idx]++] = _paths![i]; } - // Build ImmutableArray for each non-empty slot from contiguous slices. + // Build CompactPathSegment for each non-empty slot from contiguous slices. for (var slot = 0; slot < slotCount; slot++) { if (counts[slot] == 0) @@ -85,15 +84,11 @@ public void ApplyTo(VariableValues[] variableValueSets, int slotCount) continue; } - var array = sorted.AsSpan(offsets[slot], counts[slot]).ToArray(); variableValueSets[slot] = variableValueSets[slot] with { - AdditionalPaths = ImmutableCollectionsMarshal.AsImmutableArray(array) + AdditionalPaths = new CompactPathSegment(shared, offsets[slot], counts[slot]) }; } - - sorted.AsSpan(0, _count).Clear(); - ArrayPool.Shared.Return(sorted); } private void Grow() 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 6f7a6dc4404..055387bc0a7 100644 --- a/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Results/FetchResultStore.cs +++ b/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Results/FetchResultStore.cs @@ -19,6 +19,9 @@ namespace HotChocolate.Fusion.Execution.Results; internal sealed partial class FetchResultStore : IDisposable { + private static readonly ArrayPool s_variableValuePool = ArrayPool.Shared; + private static readonly ArrayPool s_objectPool = ArrayPool.Shared; + #if NET9_0_OR_GREATER private readonly Lock _lock = new(); #else @@ -464,38 +467,49 @@ internal bool TryGetResult(Path path, out CompositeResultElement element) return true; } - var segments = path.ToList(); + var buffer = s_objectPool.Rent(path.Length); + var segments = buffer.AsSpan(0, path.Length); - for (var i = 0; i < segments.Count; i++) + try { - switch (segments[i]) + path.CopyTo(segments); + + for (var i = 0; i < segments.Length; i++) { - case string fieldName: - if (element.ValueKind is not JsonValueKind.Object - || !element.TryGetProperty(fieldName, out element)) - { - return false; - } + switch (segments[i]) + { + case string fieldName: + if (element.ValueKind is not JsonValueKind.Object + || !element.TryGetProperty(fieldName, out element)) + { + return false; + } - break; + break; - case int index: - if (element.ValueKind is not JsonValueKind.Array - || index < 0 - || element.GetArrayLength() <= index) - { - return false; - } + case int index: + if (element.ValueKind is not JsonValueKind.Array + || index < 0 + || element.GetArrayLength() <= index) + { + return false; + } - element = element[index]; - break; + element = element[index]; + break; - default: - return false; + default: + return false; + } } - } - return true; + return true; + } + finally + { + segments.Clear(); + s_objectPool.Return(buffer); + } } public void FinalizePocketedErrors() @@ -800,7 +814,7 @@ private ImmutableArray BuildVariableValueSets( foreach (var result in elements) { - variableValueSets ??= new VariableValues[elements.Length]; + variableValueSets ??= s_variableValuePool.Rent(elements.Length); _jsonWriter.Reset(_variableWriter); var startPosition = _variableWriter.Position; @@ -902,7 +916,7 @@ private ImmutableArray BuildVariableValueSetsSingleRequirementFa continue; } - variableValueSets ??= new VariableValues[elements.Length]; + variableValueSets ??= s_variableValuePool.Rent(elements.Length); _jsonWriter.Reset(_variableWriter); var startPosition = _variableWriter.Position; @@ -939,7 +953,7 @@ private ImmutableArray BuildVariableValueSetsSingleRequirementSl foreach (var result in elements) { - variableValueSets ??= new VariableValues[elements.Length]; + variableValueSets ??= s_variableValuePool.Rent(elements.Length); _jsonWriter.Reset(_variableWriter); var startPosition = _variableWriter.Position; @@ -1020,7 +1034,7 @@ private ImmutableArray BuildVariableValueSetsTwoRequirementsFast continue; } - variableValueSets ??= new VariableValues[elements.Length]; + variableValueSets ??= s_variableValuePool.Rent(elements.Length); _jsonWriter.Reset(_variableWriter); var startPosition = _variableWriter.Position; @@ -1057,7 +1071,7 @@ private ImmutableArray BuildVariableValueSetsTwoRequirementsSlow foreach (var result in elements) { - variableValueSets ??= new VariableValues[elements.Length]; + variableValueSets ??= s_variableValuePool.Rent(elements.Length); _jsonWriter.Reset(_variableWriter); var startPosition = _variableWriter.Position; @@ -1161,7 +1175,7 @@ private ImmutableArray BuildVariableValueSetsThreeRequirementsFa continue; } - variableValueSets ??= new VariableValues[elements.Length]; + variableValueSets ??= s_variableValuePool.Rent(elements.Length); _jsonWriter.Reset(_variableWriter); var startPosition = _variableWriter.Position; @@ -1200,7 +1214,7 @@ private ImmutableArray BuildVariableValueSetsThreeRequirementsSl foreach (var result in elements) { - variableValueSets ??= new VariableValues[elements.Length]; + variableValueSets ??= s_variableValuePool.Rent(elements.Length); _jsonWriter.Reset(_variableWriter); var startPosition = _variableWriter.Position; @@ -1673,6 +1687,11 @@ private static ImmutableArray FinalizeVariableValueSets( { if (variableValueSets is null || nextIndex == 0) { + if (variableValueSets is not null) + { + s_variableValuePool.Return(variableValueSets, clearArray: true); + } + additionalPaths.Dispose(); return []; } @@ -1680,12 +1699,12 @@ private static ImmutableArray FinalizeVariableValueSets( additionalPaths.ApplyTo(variableValueSets, nextIndex); additionalPaths.Dispose(); - if (variableValueSets.Length != nextIndex) - { - Array.Resize(ref variableValueSets, nextIndex); - } + var span = variableValueSets.AsSpan(0, nextIndex); + var result = span.ToArray(); + span.Clear(); + s_variableValuePool.Return(variableValueSets); - return ImmutableCollectionsMarshal.AsImmutableArray(variableValueSets); + return ImmutableCollectionsMarshal.AsImmutableArray(result); } private sealed class VariableDedupTable(ChunkedArrayWriter writer) : IDisposable diff --git a/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Results/PathUtilities.cs b/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Results/PathUtilities.cs index 55833062804..bd1697ecde6 100644 --- a/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Results/PathUtilities.cs +++ b/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Results/PathUtilities.cs @@ -1,7 +1,11 @@ +using System.Buffers; + namespace HotChocolate.Fusion.Execution.Results; internal static class PathUtilities { + private static readonly ArrayPool _objectPool = ArrayPool.Shared; + public static bool IsPathInSubtree(Path path, Path subtreeRoot, bool includeSelf) { if (path.Length < subtreeRoot.Length @@ -15,20 +19,33 @@ public static bool IsPathInSubtree(Path path, Path subtreeRoot, bool includeSelf return includeSelf || !path.IsRoot; } - var pathSegments = path.ToList(); - var subtreeSegments = subtreeRoot.ToList(); + var pathBuffer = _objectPool.Rent(path.Length); + var subtreeBuffer = _objectPool.Rent(subtreeRoot.Length); - for (var i = 0; i < subtreeSegments.Count; i++) + try { - var left = subtreeSegments[i]; - var right = pathSegments[i]; + var pathSpan = pathBuffer.AsSpan(0, path.Length); + var subtreeSpan = subtreeBuffer.AsSpan(0, subtreeRoot.Length); - if (!Equals(left, right)) + path.CopyTo(pathSpan); + subtreeRoot.CopyTo(subtreeSpan); + + for (var i = 0; i < subtreeSpan.Length; i++) { - return false; + if (!Equals(pathSpan[i], subtreeSpan[i])) + { + return false; + } } - } - return true; + return true; + } + finally + { + pathBuffer.AsSpan(0, path.Length).Clear(); + _objectPool.Return(pathBuffer); + subtreeBuffer.AsSpan(0, subtreeRoot.Length).Clear(); + _objectPool.Return(subtreeBuffer); + } } } diff --git a/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/VariableValues.cs b/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/VariableValues.cs index 8b6954c88c8..b1ab6f700d6 100644 --- a/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/VariableValues.cs +++ b/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/VariableValues.cs @@ -1,4 +1,3 @@ -using System.Collections.Immutable; using HotChocolate.Fusion.Text.Json; namespace HotChocolate.Fusion.Execution; @@ -10,7 +9,7 @@ public readonly record struct VariableValues(CompactPath Path, JsonSegment Value /// /// Gets the additional paths that share the same variable values as the primary . /// - public ImmutableArray AdditionalPaths { get; init; } = []; + public CompactPathSegment AdditionalPaths { get; init; } public static VariableValues Empty => default; } diff --git a/src/HotChocolate/Fusion/src/Fusion.Execution/Text/Json/CompactPathSegment.cs b/src/HotChocolate/Fusion/src/Fusion.Execution/Text/Json/CompactPathSegment.cs new file mode 100644 index 00000000000..9dafbcb473c --- /dev/null +++ b/src/HotChocolate/Fusion/src/Fusion.Execution/Text/Json/CompactPathSegment.cs @@ -0,0 +1,86 @@ +namespace HotChocolate.Fusion.Text.Json; + +/// +/// A lightweight read-only view over a contiguous region of values. +/// Multiple segments can share the same backing array, avoiding per-slot array allocations +/// when distributing additional paths across variable value sets. +/// +public readonly struct CompactPathSegment +{ + private readonly CompactPath[]? _array; + private readonly int _offset; + private readonly int _count; + + internal CompactPathSegment(CompactPath[] array, int offset, int count) + { + _array = array; + _offset = offset; + _count = count; + } + + /// + /// Gets the number of paths in this segment. + /// + public int Length => _count; + + /// + /// Gets a value indicating whether this segment is empty. + /// + public bool IsDefaultOrEmpty => _count == 0; + + /// + /// Gets the paths as a read-only span. + /// + public ReadOnlySpan AsSpan() + => _array is null + ? ReadOnlySpan.Empty + : _array.AsSpan(_offset, _count); + + /// + /// Gets the path at the specified index. + /// + public CompactPath this[int index] + { + get + { + if ((uint)index >= (uint)_count) + { + throw new ArgumentOutOfRangeException(nameof(index)); + } + + return _array![_offset + index]; + } + } + + /// + /// Returns an enumerator that iterates through the paths. + /// + public Enumerator GetEnumerator() => new(_array, _offset, _count); + + /// + /// Enumerates the paths in a . + /// + public struct Enumerator + { + private readonly CompactPath[]? _array; + private readonly int _end; + private int _index; + + internal Enumerator(CompactPath[]? array, int offset, int count) + { + _array = array; + _end = offset + count; + _index = offset - 1; + } + + /// + /// Gets the current path. + /// + public CompactPath Current => _array![_index]; + + /// + /// Advances to the next path. + /// + public bool MoveNext() => ++_index < _end; + } +}