diff --git a/src/HotChocolate/Fusion-vnext/src/Fusion.Execution/Execution/Clients/SourceSchemaRequestDispatcher.cs b/src/HotChocolate/Fusion-vnext/src/Fusion.Execution/Execution/Clients/SourceSchemaRequestDispatcher.cs index c863c9d05f1..10a17d1247d 100644 --- a/src/HotChocolate/Fusion-vnext/src/Fusion.Execution/Execution/Clients/SourceSchemaRequestDispatcher.cs +++ b/src/HotChocolate/Fusion-vnext/src/Fusion.Execution/Execution/Clients/SourceSchemaRequestDispatcher.cs @@ -1,6 +1,5 @@ using System.Collections.Immutable; using System.Runtime.InteropServices; -using HotChocolate.Fusion.Properties; using HotChocolate.Language; using static HotChocolate.Fusion.Execution.Clients.SourceSchemaClientCapabilities; using static HotChocolate.Fusion.Properties.FusionExecutionResources; @@ -21,6 +20,11 @@ internal sealed class SourceSchemaRequestDispatcher : ISourceSchemaScheduler , ISourceSchemaDispatcher { + private const int NodeStateUnregistered = -1; + private const int NodeStatePending = 0; + private const int NodeStateSubmitted = 1; + private const int NodeStateSkipped = 2; + #if NET9_0_OR_GREATER private readonly Lock _sync = new(); #else @@ -179,7 +183,7 @@ public void RegisterGroup(int groupId, IReadOnlyList nodeIds) } _groupByNodeIdSlots[nodeId] = groupId; - _nodeStateSlots[nodeId] = 0; + _nodeStateSlots[nodeId] = NodeStatePending; } } } @@ -415,7 +419,7 @@ private void RemoveGroup(GroupState group) if ((uint)nodeId < (uint)_groupByNodeIdSlots.Length) { _groupByNodeIdSlots[nodeId] = -1; - _nodeStateSlots[nodeId] = -1; + _nodeStateSlots[nodeId] = NodeStateUnregistered; } } } @@ -432,7 +436,7 @@ private void ClearNodeIdSlots() if ((uint)nodeId < (uint)_groupByNodeIdSlots.Length) { _groupByNodeIdSlots[nodeId] = -1; - _nodeStateSlots[nodeId] = -1; + _nodeStateSlots[nodeId] = NodeStateUnregistered; } } @@ -456,7 +460,7 @@ private void EnsureNodeIdSlotCapacity(int minCapacity) var groupByNodeIdSlots = new int[newCapacity]; var nodeStateSlots = new int[newCapacity]; Array.Fill(groupByNodeIdSlots, -1); - Array.Fill(nodeStateSlots, -1); + Array.Fill(nodeStateSlots, NodeStateUnregistered); if (_groupByNodeIdSlots.Length > 0) { @@ -471,7 +475,7 @@ private void EnsureNodeIdSlotCapacity(int minCapacity) private sealed class GroupState(int id, int initialCapacity) { private readonly List _nodeIds = new(initialCapacity); - private readonly Dictionary _pendingRequests = new(initialCapacity); + private readonly List _pendingRequests = new(initialCapacity); private int _remainingNodes; private bool _dispatchCreated; @@ -479,7 +483,7 @@ private sealed class GroupState(int id, int initialCapacity) public IEnumerable NodeIds => _nodeIds; - public IEnumerable PendingRequests => _pendingRequests.Values; + public IEnumerable PendingRequests => _pendingRequests; public void RegisterNode(int nodeId) { @@ -493,8 +497,12 @@ public bool TrySubmit( out PendingRequest? pendingRequest) { var nodeId = request.Node.Id; + var nodeState = + (uint)nodeId < (uint)nodeStateSlots.Length + ? nodeStateSlots[nodeId] + : NodeStateUnregistered; - if (_pendingRequests.ContainsKey(nodeId)) + if (nodeState == NodeStateSubmitted) { throw new InvalidOperationException( string.Format( @@ -502,26 +510,27 @@ public bool TrySubmit( nodeId)); } - if ((uint)nodeId >= (uint)nodeStateSlots.Length || nodeStateSlots[nodeId] != 0) + if (nodeState != NodeStatePending) { pendingRequest = null; return false; } - nodeStateSlots[nodeId] = 1; + nodeStateSlots[nodeId] = NodeStateSubmitted; _remainingNodes--; pendingRequest = new PendingRequest(request); - _pendingRequests.Add(nodeId, pendingRequest); + _pendingRequests.Add(pendingRequest); return true; } public void Skip(int nodeId, int[] nodeStateSlots) { - if ((uint)nodeId < (uint)nodeStateSlots.Length && nodeStateSlots[nodeId] == 0) + if ((uint)nodeId < (uint)nodeStateSlots.Length + && nodeStateSlots[nodeId] == NodeStatePending) { - nodeStateSlots[nodeId] = 1; + nodeStateSlots[nodeId] = NodeStateSkipped; _remainingNodes--; } } @@ -542,7 +551,7 @@ public bool TryCreateDispatch(out ImmutableArray pendingRequests return true; } - pendingRequests = [.. _pendingRequests.Values]; + pendingRequests = [.. _pendingRequests]; return true; } } diff --git a/src/HotChocolate/Fusion-vnext/src/Fusion.Execution/Execution/Nodes/OperationBatchExecutionNode.cs b/src/HotChocolate/Fusion-vnext/src/Fusion.Execution/Execution/Nodes/OperationBatchExecutionNode.cs index c1dbbba1b4e..776f8e32be4 100644 --- a/src/HotChocolate/Fusion-vnext/src/Fusion.Execution/Execution/Nodes/OperationBatchExecutionNode.cs +++ b/src/HotChocolate/Fusion-vnext/src/Fusion.Execution/Execution/Nodes/OperationBatchExecutionNode.cs @@ -1,5 +1,6 @@ using System.Buffers; using System.Collections.Immutable; +using System.Runtime.InteropServices; using HotChocolate.Fusion.Execution.Clients; namespace HotChocolate.Fusion.Execution.Nodes; @@ -127,6 +128,7 @@ protected override async ValueTask OnExecuteAsync( var index = 0; var bufferLength = 0; SourceSchemaResult[]? buffer = null; + SourceSchemaResult? singleResult = null; var hasSomeErrors = false; try @@ -145,14 +147,34 @@ protected override async ValueTask OnExecuteAsync( totalPathCount += variables[i].AdditionalPaths.Length; } - bufferLength = Math.Max(totalPathCount, 1); - buffer = ArrayPool.Shared.Rent(bufferLength); + var initialBufferLength = Math.Max(totalPathCount, 2); await foreach (var result in response.ReadAsResultStreamAsync(cancellationToken)) { - buffer[index++] = result; + // Store the first result without renting a buffer, + // since it might be the only one (e.g. a request-level error). + if (index == 0) + { + singleResult = result; + index = 1; + } + else + { + // Once we see a second result, we know there are multiple, + // so we rent a buffer and move the first result into it. + if (buffer is null) + { + bufferLength = initialBufferLength; + buffer = ArrayPool.Shared.Rent(bufferLength); + buffer[0] = singleResult!; + } - if (result.HasErrors) + buffer[index++] = result; + } + + // Parsing errors here allows the result store to reuse the cached value + // and avoids a second document lookup per result. + if (result.Errors is not null) { hasSomeErrors = true; } @@ -182,6 +204,10 @@ protected override async ValueTask OnExecuteAsync( buffer.AsSpan(0, index).Clear(); ArrayPool.Shared.Return(buffer); } + else if (singleResult is not null) + { + singleResult.Dispose(); + } AddErrors(context, exception, variables, _responseNames); return ExecutionStatus.Failed; @@ -189,7 +215,31 @@ protected override async ValueTask OnExecuteAsync( try { - context.AddPartialResults(_source, buffer.AsSpan(0, index), _responseNames); + if (buffer is not null) + { + context.AddPartialResults( + _source, + buffer.AsSpan(0, index), + _responseNames, + hasSomeErrors); + } + else if (singleResult is not null) + { + var firstResult = singleResult; + context.AddPartialResults( + _source, + MemoryMarshal.CreateReadOnlySpan(ref firstResult, 1), + _responseNames, + hasSomeErrors); + } + else + { + context.AddPartialResults( + _source, + [], + _responseNames, + hasSomeErrors); + } } catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) { @@ -206,8 +256,11 @@ protected override async ValueTask OnExecuteAsync( } finally { - buffer.AsSpan(0, index).Clear(); - ArrayPool.Shared.Return(buffer); + if (buffer is not null) + { + buffer.AsSpan(0, index).Clear(); + ArrayPool.Shared.Return(buffer); + } } return hasSomeErrors ? ExecutionStatus.PartialSuccess : ExecutionStatus.Success; diff --git a/src/HotChocolate/Fusion-vnext/src/Fusion.Execution/Execution/Nodes/OperationExecutionNode.cs b/src/HotChocolate/Fusion-vnext/src/Fusion.Execution/Execution/Nodes/OperationExecutionNode.cs index 83ee1f473bb..ddfc2aeafc9 100644 --- a/src/HotChocolate/Fusion-vnext/src/Fusion.Execution/Execution/Nodes/OperationExecutionNode.cs +++ b/src/HotChocolate/Fusion-vnext/src/Fusion.Execution/Execution/Nodes/OperationExecutionNode.cs @@ -134,6 +134,7 @@ protected override async ValueTask OnExecuteAsync( var index = 0; var bufferLength = 0; SourceSchemaResult[]? buffer = null; + SourceSchemaResult? singleResult = null; var hasSomeErrors = false; try @@ -152,14 +153,32 @@ protected override async ValueTask OnExecuteAsync( totalPathCount += variables[i].AdditionalPaths.Length; } - bufferLength = Math.Max(totalPathCount, 1); - buffer = ArrayPool.Shared.Rent(bufferLength); + var initialBufferLength = Math.Max(totalPathCount, 2); await foreach (var result in response.ReadAsResultStreamAsync(cancellationToken)) { - buffer[index++] = result; + // If there is only one response, we skip the buffer rental. + if (index == 0) + { + singleResult = result; + index = 1; + } + else + { + // If we have more than one response, we rent a buffer and move the first result into it. + if (buffer is null) + { + bufferLength = initialBufferLength; + buffer = ArrayPool.Shared.Rent(bufferLength); + buffer[0] = singleResult!; + } - if (result.HasErrors) + buffer[index++] = result; + } + + // Parsing errors here allows the result store to reuse the cached value + // and avoids a second document lookup per result. + if (result.Errors is not null) { hasSomeErrors = true; } @@ -189,6 +208,10 @@ protected override async ValueTask OnExecuteAsync( buffer.AsSpan(0, index).Clear(); ArrayPool.Shared.Return(buffer); } + else if (singleResult is not null) + { + singleResult.Dispose(); + } AddErrors(context, exception, variables, _responseNames); return ExecutionStatus.Failed; @@ -196,7 +219,31 @@ protected override async ValueTask OnExecuteAsync( try { - context.AddPartialResults(_source, buffer.AsSpan(0, index), _responseNames); + if (buffer is not null) + { + context.AddPartialResults( + _source, + buffer.AsSpan(0, index), + _responseNames, + hasSomeErrors); + } + else if (singleResult is not null) + { + var firstResult = singleResult; + context.AddPartialResults( + _source, + MemoryMarshal.CreateReadOnlySpan(ref firstResult, 1), + _responseNames, + hasSomeErrors); + } + else + { + context.AddPartialResults( + _source, + [], + _responseNames, + hasSomeErrors); + } } catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) { @@ -213,8 +260,11 @@ protected override async ValueTask OnExecuteAsync( } finally { - buffer.AsSpan(0, index).Clear(); - ArrayPool.Shared.Return(buffer); + if (buffer is not null) + { + buffer.AsSpan(0, index).Clear(); + ArrayPool.Shared.Return(buffer); + } } return hasSomeErrors ? ExecutionStatus.PartialSuccess : ExecutionStatus.Success; diff --git a/src/HotChocolate/Fusion-vnext/src/Fusion.Execution/Execution/OperationPlanContext.cs b/src/HotChocolate/Fusion-vnext/src/Fusion.Execution/Execution/OperationPlanContext.cs index 7d74b5a67c7..e7b6234b9fd 100644 --- a/src/HotChocolate/Fusion-vnext/src/Fusion.Execution/Execution/OperationPlanContext.cs +++ b/src/HotChocolate/Fusion-vnext/src/Fusion.Execution/Execution/OperationPlanContext.cs @@ -260,9 +260,11 @@ internal ImmutableArray CreateVariableValueSets( internal void AddPartialResults( SelectionPath sourcePath, ReadOnlySpan results, - ReadOnlySpan responseNames) + ReadOnlySpan responseNames, + bool containsErrors = true) { - var canExecutionContinue = _resultStore.AddPartialResults(sourcePath, results, responseNames); + var canExecutionContinue = + _resultStore.AddPartialResults(sourcePath, results, responseNames, containsErrors); if (!canExecutionContinue) { @@ -369,15 +371,15 @@ internal OperationResult Complete(bool reusable = false) return operationResult; } - private List GetPathThroughVariables( + private IReadOnlyList GetPathThroughVariables( ReadOnlySpan forwardedVariables) { if (Variables.IsEmpty || forwardedVariables.Length == 0) { - return []; + return Array.Empty(); } - var variables = new List(); + var variables = new List(forwardedVariables.Length); foreach (var variableName in forwardedVariables) { @@ -400,7 +402,9 @@ private List GetPathThroughVariables( } } - return variables; + return variables.Count == 0 + ? Array.Empty() + : variables; } public ISourceSchemaClient GetClient(string schemaName, OperationType operationType) diff --git a/src/HotChocolate/Fusion-vnext/src/Fusion.Execution/Execution/Results/FetchResultStore.cs b/src/HotChocolate/Fusion-vnext/src/Fusion.Execution/Execution/Results/FetchResultStore.cs index fe580a90a4a..566b8877ee7 100644 --- a/src/HotChocolate/Fusion-vnext/src/Fusion.Execution/Execution/Results/FetchResultStore.cs +++ b/src/HotChocolate/Fusion-vnext/src/Fusion.Execution/Execution/Results/FetchResultStore.cs @@ -93,6 +93,13 @@ public bool AddPartialResults( SelectionPath sourcePath, ReadOnlySpan results, ReadOnlySpan responseNames) + => AddPartialResults(sourcePath, results, responseNames, containsErrors: true); + + public bool AddPartialResults( + SelectionPath sourcePath, + ReadOnlySpan results, + ReadOnlySpan responseNames, + bool containsErrors) { ObjectDisposedException.ThrowIf(_disposed, this); ArgumentNullException.ThrowIfNull(sourcePath); @@ -104,6 +111,18 @@ public bool AddPartialResults( nameof(results)); } + if (!containsErrors) + { + return results.Length == 1 + ? AddSinglePartialResultNoErrors(sourcePath, results[0], responseNames) + : AddPartialResultsNoErrors(sourcePath, results, responseNames); + } + + if (results.Length == 1) + { + return AddSinglePartialResult(sourcePath, results[0], responseNames); + } + var dataElements = ArrayPool.Shared.Rent(results.Length); var errorTries = ArrayPool.Shared.Rent(results.Length); var dataElementsSpan = dataElements.AsSpan(0, results.Length); @@ -169,6 +188,102 @@ public bool AddPartialResults( } } + private bool AddPartialResultsNoErrors( + SelectionPath sourcePath, + ReadOnlySpan results, + ReadOnlySpan responseNames) + { + var dataElements = ArrayPool.Shared.Rent(results.Length); + var dataElementsSpan = dataElements.AsSpan(0, results.Length); + + try + { + for (var i = 0; i < results.Length; i++) + { + var result = results[i]; + _memory.Push(result); + dataElementsSpan[i] = GetDataElement(sourcePath, result.Data); + } + + lock (_lock) + { + var resultData = _result.Data; + + for (var i = 0; i < results.Length; i++) + { + var result = results[i]; + + if (!SaveSafeResult( + resultData, + result.Path, + result.AdditionalPaths.AsSpan(), + dataElementsSpan[i], + errorTrie: null, + responseNames)) + { + return false; + } + } + } + + return true; + } + finally + { + dataElementsSpan.Clear(); + ArrayPool.Shared.Return(dataElements); + } + } + + private bool AddSinglePartialResult( + SelectionPath sourcePath, + SourceSchemaResult result, + ReadOnlySpan responseNames) + { + _memory.Push(result); + + var errors = result.Errors; + var dataElement = GetDataElement(sourcePath, result.Data); + var errorTrie = GetErrorTrie(sourcePath, errors?.Trie); + + lock (_lock) + { + if (errors?.RootErrors is { Length: > 0 } rootErrors) + { + _errors ??= []; + _errors.AddRange(rootErrors); + } + + return SaveSafeResult( + _result.Data, + result.Path, + result.AdditionalPaths.AsSpan(), + dataElement, + errorTrie, + responseNames); + } + } + + private bool AddSinglePartialResultNoErrors( + SelectionPath sourcePath, + SourceSchemaResult result, + ReadOnlySpan responseNames) + { + _memory.Push(result); + var dataElement = GetDataElement(sourcePath, result.Data); + + lock (_lock) + { + return SaveSafeResult( + _result.Data, + result.Path, + result.AdditionalPaths.AsSpan(), + dataElement, + errorTrie: null, + responseNames); + } + } + /// /// Adds partial root data to the result document. /// @@ -559,6 +674,7 @@ private ImmutableArray BuildVariableValueSetsSingleRequirementFa { VariableValues[]? variableValueSets = null; Dictionary? seen = null; + Dictionary? seenStrings = null; List?[]? additionalPaths = null; var nextIndex = 0; @@ -575,23 +691,38 @@ private ImmutableArray BuildVariableValueSetsSingleRequirementFa continue; } - var mappedValue = ResultDataMapper.MapLeafValue(value, ref buffer); variableValueSets ??= new VariableValues[elements.Count]; + IValueNode mappedValue; - if (nextIndex > 0) + if (value.ValueKind is JsonValueKind.String) { - seen ??= new Dictionary(SingleValueNodeComparer.Instance) + var stringValue = value.AssertString(); + + if (seenStrings is not null + && seenStrings.TryGetValue(stringValue, out var existingIndex)) { - [variableValueSets[0].Values.Fields[0].Value] = 0 - }; + additionalPaths ??= new List?[elements.Count]; + (additionalPaths[existingIndex] ??= []).Add(result.Path); + continue; + } + + mappedValue = ResultDataMapper.GetStringValueNode(stringValue); + seenStrings ??= new Dictionary(StringComparer.Ordinal); + seenStrings[stringValue] = nextIndex; + } + else + { + mappedValue = ResultDataMapper.MapLeafValue(value, ref buffer); - if (seen.TryGetValue(mappedValue, out var existingIndex)) + if (seen is not null + && seen.TryGetValue(mappedValue, out var existingIndex)) { additionalPaths ??= new List?[elements.Count]; (additionalPaths[existingIndex] ??= []).Add(result.Path); continue; } + seen ??= new Dictionary(SingleValueNodeComparer.Instance); seen[mappedValue] = nextIndex; } diff --git a/src/HotChocolate/Fusion-vnext/src/Fusion.Execution/Execution/Results/ResultDataMapper.cs b/src/HotChocolate/Fusion-vnext/src/Fusion.Execution/Execution/Results/ResultDataMapper.cs index b3e101ec40d..4d1f1a4519d 100644 --- a/src/HotChocolate/Fusion-vnext/src/Fusion.Execution/Execution/Results/ResultDataMapper.cs +++ b/src/HotChocolate/Fusion-vnext/src/Fusion.Execution/Execution/Results/ResultDataMapper.cs @@ -1,3 +1,5 @@ +using System.Diagnostics.CodeAnalysis; +using System.Runtime.CompilerServices; using System.Text.Json; using HotChocolate.Buffers; using HotChocolate.Fusion.Language; @@ -9,6 +11,9 @@ namespace HotChocolate.Fusion.Execution.Results; internal static class ResultDataMapper { + private const int CachedNumericStringMax = 4096; + private static readonly StringValueNode[] s_cachedNumericStrings = CreateCachedNumericStrings(); + public static IValueNode? Map( CompositeResultElement result, IValueSelectionNode valueSelection, @@ -126,7 +131,15 @@ private static IValueNode ParseLeafValue( return BooleanValueNode.False; case JsonValueKind.String: - return new StringValueNode(value.AssertString()); + return GetStringValueNode(value.AssertString()); + + case JsonValueKind.Number: + if (value.TryGetInt64(out var intValue)) + { + return new IntValueNode(intValue); + } + + goto default; default: writer ??= new PooledArrayWriter(); @@ -140,6 +153,77 @@ private static IValueNode ParseLeafValue( } } + internal static StringValueNode GetStringValueNode(string value) + { + if (TryGetCachedNumericString(value, out var cached)) + { + return cached; + } + + return new StringValueNode(value); + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static bool TryGetCachedNumericString( + string value, + [NotNullWhen(true)] out StringValueNode? cached) + { + cached = null; + + var length = value.Length; + + if ((uint)(length - 1) > 3) + { + return false; + } + + var c0 = value[0]; + + if ((uint)(c0 - '0') > 9) + { + return false; + } + + if (length > 1 && c0 == '0') + { + return false; + } + + var parsed = c0 - '0'; + + for (var i = 1; i < length; i++) + { + var c = value[i]; + + if ((uint)(c - '0') > 9) + { + return false; + } + + parsed = (parsed * 10) + (c - '0'); + } + + if ((uint)parsed > CachedNumericStringMax) + { + return false; + } + + cached = s_cachedNumericStrings[parsed]; + return true; + } + + private static StringValueNode[] CreateCachedNumericStrings() + { + var values = new StringValueNode[CachedNumericStringMax + 1]; + + for (var i = 0; i < values.Length; i++) + { + values[i] = new StringValueNode(i.ToString()); + } + + return values; + } + private static IValueNode? Visit(ObjectValueSelectionNode node, Context context) { var result = context.Result;