Skip to content

Commit 915b630

Browse files
authored
[Fusion] Pool the request cancellation source across operation executions (#10080)
1 parent bc659e4 commit 915b630

2 files changed

Lines changed: 68 additions & 21 deletions

File tree

src/HotChocolate/Fusion/src/Fusion.Execution/Execution/OperationPlanContext.Pooling.cs

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
using System.Collections.Immutable;
22
using System.Diagnostics;
3+
using System.Runtime.CompilerServices;
34
using HotChocolate.Buffers;
45
using HotChocolate.Execution;
56
using HotChocolate.Fusion.Diagnostics;
@@ -10,6 +11,8 @@ namespace HotChocolate.Fusion.Execution;
1011

1112
public sealed partial class OperationPlanContext
1213
{
14+
private CancellationTokenSource _engineCancellationSource = new();
15+
1316
internal OperationPlanContext(
1417
INodeIdParser nodeIdParser,
1518
IFusionExecutionDiagnosticEvents diagnosticEvents,
@@ -66,6 +69,36 @@ internal void Initialize(
6669
EnsureNodeArrayCapacity(operationPlan.MaxNodeId);
6770
}
6871

72+
/// <summary>
73+
/// Arms the pooled engine cancellation source against the request token and returns it for
74+
/// this operation. The engine source halts the execution engine without cancelling the request
75+
/// pipeline. The returned registration links the request token into the engine source so that
76+
/// client-abort and server-shutdown still propagate, and it must be disposed before the source
77+
/// is returned for reuse.
78+
/// </summary>
79+
internal (CancellationTokenSource Source, CancellationTokenRegistration Registration) RentEngineCancellation(
80+
CancellationToken cancellationToken)
81+
{
82+
var registration = cancellationToken.UnsafeRegister(
83+
static state => Unsafe.As<CancellationTokenSource>(state)!.Cancel(),
84+
_engineCancellationSource);
85+
return (_engineCancellationSource, registration);
86+
}
87+
88+
/// <summary>
89+
/// Returns the engine cancellation source for reuse. If it was cancelled and cannot be reset,
90+
/// it is disposed and replaced with a fresh source. The caller must dispose the registration
91+
/// from <see cref="RentEngineCancellation"/> before calling this.
92+
/// </summary>
93+
internal void ReturnEngineCancellation()
94+
{
95+
if (!_engineCancellationSource.TryReset())
96+
{
97+
_engineCancellationSource.Dispose();
98+
_engineCancellationSource = new CancellationTokenSource();
99+
}
100+
}
101+
69102
/// <summary>
70103
/// Cleans the context for return to the pool.
71104
/// Releases per-request state while retaining reusable buffers.
@@ -117,6 +150,7 @@ internal void Destroy()
117150
{
118151
_resultStore.Dispose();
119152
_executionState.Destroy();
153+
_engineCancellationSource.Dispose();
120154
}
121155

122156
public async ValueTask DisposeAsync()

src/HotChocolate/Fusion/src/Fusion.Execution/Execution/OperationPlanExecutor.cs

Lines changed: 34 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -20,35 +20,48 @@ public static async Task<IExecutionResult> ExecuteAsync(
2020
OperationPlan operationPlan,
2121
CancellationToken cancellationToken)
2222
{
23-
// We create a new CancellationTokenSource that can be used to halt the execution engine,
24-
// without also cancelling the entire request pipeline.
25-
using var executionCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
26-
2723
await using var context = requestContext.Schema.Services.GetRequiredService<OperationPlanContextPool>().Rent();
28-
context.Initialize(requestContext, variables, operationPlan, executionCts);
2924

30-
context.Begin();
25+
// We reuse a pooled CancellationTokenSource that can halt the execution engine without
26+
// cancelling the entire request pipeline. The request token is linked in so that
27+
// client-abort / server-shutdown still propagates.
28+
var (executionCts, cancellationRegistration) = context.RentEngineCancellation(cancellationToken);
3129

32-
switch (operationPlan.Operation.Definition.Operation)
30+
try
3331
{
34-
case OperationType.Query:
35-
await ExecuteQueryAsync(context, operationPlan, executionCts.Token);
36-
break;
32+
context.Initialize(requestContext, variables, operationPlan, executionCts);
3733

38-
case OperationType.Mutation:
39-
await ExecuteMutationAsync(context, operationPlan, executionCts.Token);
40-
break;
34+
context.Begin();
4135

42-
default:
43-
throw new InvalidOperationException("Only queries and mutations can be executed.");
44-
}
36+
switch (operationPlan.Operation.Definition.Operation)
37+
{
38+
case OperationType.Query:
39+
await ExecuteQueryAsync(context, operationPlan, executionCts.Token);
40+
break;
4541

46-
// If the original CancellationToken of the request was cancelled,
47-
// the Execution nodes and the PlanExecutor should have been gracefully cancelled,
48-
// so we throw here to properly cancel the request execution.
49-
cancellationToken.ThrowIfCancellationRequested();
42+
case OperationType.Mutation:
43+
await ExecuteMutationAsync(context, operationPlan, executionCts.Token);
44+
break;
45+
46+
default:
47+
throw new InvalidOperationException("Only queries and mutations can be executed.");
48+
}
5049

51-
return context.Complete();
50+
// If the original CancellationToken of the request was cancelled,
51+
// the Execution nodes and the PlanExecutor should have been gracefully cancelled,
52+
// so we throw here to properly cancel the request execution.
53+
cancellationToken.ThrowIfCancellationRequested();
54+
55+
return context.Complete();
56+
}
57+
finally
58+
{
59+
// Dispose the parent-token registration BEFORE returning the source for reuse so a
60+
// stale registration can never fire into a reset source. DisposeAsync waits for an
61+
// in-flight cancel callback to finish.
62+
await cancellationRegistration.DisposeAsync();
63+
context.ReturnEngineCancellation();
64+
}
5265
}
5366

5467
public static async Task<IExecutionResult> ExecuteWithDeferAsync(

0 commit comments

Comments
 (0)