Skip to content

Commit 8997e28

Browse files
authored
Added hooks for better schema phase outs (#8748)
1 parent eecdeb5 commit 8997e28

File tree

10 files changed

+247
-29
lines changed

10 files changed

+247
-29
lines changed

src/HotChocolate/Core/src/Execution/Configuration/RequestExecutorSetup.cs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,11 @@ public sealed class RequestExecutorSetup
3333
/// </summary>
3434
public RequestExecutorOptions? RequestExecutorOptions { get; set; }
3535

36+
/// <summary>
37+
/// Gets or sets the time that the executor manager waits to dispose the schema services.
38+
/// </summary>
39+
public TimeSpan EvictionTimeout { get; set; } = TimeSpan.FromSeconds(30);
40+
3641
/// <summary>
3742
/// Gets the request executor options actions.
3843
/// This hook is invoke first in the schema creation process.
@@ -117,6 +122,7 @@ public void CopyTo(RequestExecutorSetup options)
117122
options._onBuildDocumentValidatorHooks.AddRange(_onBuildDocumentValidatorHooks);
118123
options._pipelineModifiers.AddRange(_pipelineModifiers);
119124
options._typeModules.AddRange(_typeModules);
125+
options.EvictionTimeout = EvictionTimeout;
120126

121127
if (DefaultPipelineFactory is not null)
122128
{

src/HotChocolate/Core/src/Execution/HotChocolate.Execution.csproj

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424
<InternalsVisibleTo Include="HotChocolate.Execution.Projections" />
2525
<InternalsVisibleTo Include="HotChocolate.Execution.Tests" />
2626
<InternalsVisibleTo Include="HotChocolate.Fusion.Tests" />
27+
<InternalsVisibleTo Include="HotChocolate.Types.Tests" />
2728
<InternalsVisibleTo Include="HotChocolate.Fusion" />
2829
<InternalsVisibleTo Include="HotChocolate.Types.Mutations" />
2930
<InternalsVisibleTo Include="StrawberryShake.CodeGeneration" />
@@ -219,6 +220,15 @@
219220
<Compile Update="DependencyInjection\RequestExecutorBuilderExtensions.Services.cs">
220221
<DependentUpon>RequestExecutorBuilderExtensions.cs</DependentUpon>
221222
</Compile>
223+
<Compile Update="RequestExecutorManager.Events.cs">
224+
<DependentUpon>RequestExecutorManager.cs</DependentUpon>
225+
</Compile>
226+
<Compile Update="RequestExecutorManager.Hooks.cs">
227+
<DependentUpon>RequestExecutorManager.cs</DependentUpon>
228+
</Compile>
229+
<Compile Update="RequestExecutorManager.Warmup.cs">
230+
<DependentUpon>RequestExecutorManager.cs</DependentUpon>
231+
</Compile>
222232
</ItemGroup>
223233

224234
<ItemGroup>

src/HotChocolate/Core/src/Execution/RequestExecutorManager.cs

Lines changed: 19 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ internal sealed partial class RequestExecutorManager
2727
: IRequestExecutorManager
2828
, IRequestExecutorEvents
2929
, IRequestExecutorWarmup
30-
, IDisposable
30+
, IAsyncDisposable
3131
{
3232
private readonly CancellationTokenSource _cts = new();
3333
private readonly ConcurrentDictionary<string, SemaphoreSlim> _semaphoreBySchema = new();
@@ -183,7 +183,8 @@ await CreateSchemaServicesAsync(context, setup, typeModuleChangeMonitor, cancell
183183
schemaServices,
184184
schemaServices.GetRequiredService<IExecutionDiagnosticEvents>(),
185185
setup,
186-
typeModuleChangeMonitor);
186+
typeModuleChangeMonitor,
187+
setup.EvictionTimeout);
187188

188189
var executor = registeredExecutor.Executor;
189190

@@ -254,12 +255,12 @@ private static async Task RunEvictionEvents(RegisteredExecutor registeredExecuto
254255
{
255256
// we will give the request executor some grace period to finish all requests
256257
// in the pipeline.
257-
await Task.Delay(TimeSpan.FromMinutes(5));
258-
registeredExecutor.Dispose();
258+
await Task.Delay(registeredExecutor.EvictionTimeout).ConfigureAwait(false);
259+
await registeredExecutor.DisposeAsync().ConfigureAwait(false);
259260
}
260261
}
261262

262-
private async Task<IServiceProvider> CreateSchemaServicesAsync(
263+
private async Task<ServiceProvider> CreateSchemaServicesAsync(
263264
ConfigurationContext context,
264265
RequestExecutorSetup setup,
265266
TypeModuleChangeMonitor typeModuleChangeMonitor,
@@ -518,16 +519,16 @@ private static RequestDelegate CreatePipeline(
518519
return next;
519520
}
520521

521-
public void Dispose()
522+
public async ValueTask DisposeAsync()
522523
{
523524
if (!_disposed)
524525
{
525526
// this will stop the eviction processor.
526-
_cts.Cancel();
527+
await _cts.CancelAsync();
527528

528529
foreach (var executor in _executors.Values)
529530
{
530-
executor.Dispose();
531+
await executor.DisposeAsync();
531532
}
532533

533534
foreach (var semaphore in _semaphoreBySchema.Values)
@@ -545,33 +546,32 @@ public void Dispose()
545546

546547
private sealed class RegisteredExecutor(
547548
IRequestExecutor executor,
548-
IServiceProvider services,
549+
ServiceProvider services,
549550
IExecutionDiagnosticEvents diagnosticEvents,
550551
RequestExecutorSetup setup,
551-
TypeModuleChangeMonitor typeModuleChangeMonitor)
552-
: IDisposable
552+
TypeModuleChangeMonitor typeModuleChangeMonitor,
553+
TimeSpan evictionTimeout)
554+
: IAsyncDisposable
553555
{
554556
private bool _disposed;
555557

556558
public IRequestExecutor Executor { get; } = executor;
557559

558-
public IServiceProvider Services { get; } = services;
560+
public ServiceProvider Services { get; } = services;
559561

560562
public IExecutionDiagnosticEvents DiagnosticEvents { get; } = diagnosticEvents;
561563

562564
public RequestExecutorSetup Setup { get; } = setup;
563565

564566
public TypeModuleChangeMonitor TypeModuleChangeMonitor { get; } = typeModuleChangeMonitor;
565567

566-
public void Dispose()
568+
public TimeSpan EvictionTimeout { get; } = evictionTimeout;
569+
570+
public async ValueTask DisposeAsync()
567571
{
568-
if (_disposed)
572+
if (!_disposed)
569573
{
570-
if (Services is IDisposable d)
571-
{
572-
d.Dispose();
573-
}
574-
574+
await Services.DisposeAsync();
575575
TypeModuleChangeMonitor.Dispose();
576576
_disposed = true;
577577
}
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
namespace HotChocolate.Features;
2+
3+
/// <summary>
4+
/// Provides schema cancellation support for HotChocolate.
5+
/// </summary>
6+
/// <remarks>
7+
/// This feature attaches a <see cref="CancellationToken"/> to a schema, allowing
8+
/// long-lived operations such as subscriptions to be gracefully canceled when
9+
/// the schema is phased out (for example, during schema version replacement).
10+
/// </remarks>
11+
public sealed class SchemaCancellationFeature : IAsyncDisposable
12+
{
13+
private readonly CancellationTokenSource _cts = new();
14+
private bool _disposed;
15+
16+
/// <summary>
17+
/// Initializes a new instance of the <see cref="SchemaCancellationFeature"/> class.
18+
/// </summary>
19+
/// <remarks>
20+
/// The internal <see cref="CancellationTokenSource"/> is created upon construction,
21+
/// and the <see cref="CancellationToken"/> property is set to its token.
22+
/// </remarks>
23+
public SchemaCancellationFeature()
24+
{
25+
CancellationToken = _cts.Token;
26+
}
27+
28+
/// <summary>
29+
/// Gets the schema cancellation token.
30+
/// </summary>
31+
/// <remarks>
32+
/// This token will be triggered when the schema is phased out. Components such
33+
/// as subscriptions and background tasks can observe this token and terminate
34+
/// gracefully when cancellation is requested.
35+
/// </remarks>
36+
public CancellationToken CancellationToken { get; }
37+
38+
/// <summary>
39+
/// Disposes the <see cref="SchemaCancellationFeature"/> and requests cancellation.
40+
/// </summary>
41+
/// <remarks>
42+
/// This method cancels the underlying <see cref="CancellationTokenSource"/>,
43+
/// ensuring that all operations observing the token are notified. It should be
44+
/// called when the schema is being phased out.
45+
/// </remarks>
46+
/// <returns>A task representing the asynchronous dispose operation.</returns>
47+
public async ValueTask DisposeAsync()
48+
{
49+
if (!_disposed)
50+
{
51+
await _cts.CancelAsync().ConfigureAwait(false);
52+
_cts.Dispose();
53+
_disposed = true;
54+
}
55+
}
56+
}
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
namespace HotChocolate.Features;
2+
3+
/// <summary>
4+
/// Provides extension methods for accessing the schema cancellation feature
5+
/// from an <see cref="ISchemaDefinition"/>.
6+
/// </summary>
7+
public static class SchemaCancellationSchemaDefinitionExtensions
8+
{
9+
/// <summary>
10+
/// Gets the <see cref="CancellationToken"/> associated with the schema.
11+
/// </summary>
12+
/// <param name="schema">
13+
/// The <see cref="ISchemaDefinition"/> to retrieve the cancellation token from.
14+
/// </param>
15+
/// <returns>
16+
/// The schema's cancellation token if the <see cref="SchemaCancellationFeature"/> is present;
17+
/// otherwise, <see cref="CancellationToken.None"/>.
18+
/// </returns>
19+
/// <exception cref="ArgumentNullException">
20+
/// Thrown if <paramref name="schema"/> is <c>null</c>.
21+
/// </exception>
22+
public static CancellationToken GetCancellationToken(this ISchemaDefinition schema)
23+
{
24+
ArgumentNullException.ThrowIfNull(schema);
25+
return schema.Features.Get<SchemaCancellationFeature>()?.CancellationToken ?? CancellationToken.None;
26+
}
27+
}

src/HotChocolate/Core/test/Types.Tests/CodeFirstTests.cs

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -252,6 +252,42 @@ public void Disallow_Implicitly_Binding_Object()
252252
() => SchemaBuilder.New().BindRuntimeType<object, StringType>());
253253
}
254254

255+
[Fact]
256+
public async Task DisposeSchemaService()
257+
{
258+
// arrange
259+
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(60));
260+
var customService = new CustomService();
261+
var customAsyncService = new CustomAsyncService();
262+
263+
var services = new ServiceCollection()
264+
.AddGraphQLServer()
265+
.AddQueryType(c => c.Field("a").Resolve("b"))
266+
.ConfigureSchemaServices(s =>
267+
{
268+
s.AddSingleton(_ => customService);
269+
s.AddSingleton(_ => customAsyncService);
270+
})
271+
.Configure(s => s.EvictionTimeout = TimeSpan.FromSeconds(3))
272+
.Services
273+
.BuildServiceProvider();
274+
275+
var manager = services.GetRequiredService<IRequestExecutorManager>();
276+
var executor = await manager.GetExecutorAsync(cancellationToken: cts.Token);
277+
Assert.NotNull(executor.Schema.Services.GetService<CustomService>());
278+
Assert.NotNull(executor.Schema.Services.GetService<CustomAsyncService>());
279+
280+
// act
281+
manager.EvictExecutor();
282+
283+
// assert
284+
await customService.WaitAsync(cts.Token);
285+
await customAsyncService.WaitAsync(cts.Token);
286+
287+
Assert.True(customService.Triggered);
288+
Assert.True(customAsyncService.Triggered);
289+
}
290+
255291
public class Query
256292
{
257293
public string SayHello(string name) =>
@@ -438,4 +474,43 @@ public Example NestedClassNullableString()
438474
return new Example();
439475
}
440476
}
477+
478+
public class CustomService : IDisposable
479+
{
480+
private readonly TaskCompletionSource _tcs = new();
481+
482+
public bool Triggered { get; set; }
483+
484+
public async Task WaitAsync(CancellationToken cancellationToken)
485+
{
486+
cancellationToken.Register(() => _tcs.TrySetCanceled());
487+
await _tcs.Task;
488+
}
489+
490+
public void Dispose()
491+
{
492+
Triggered = true;
493+
_tcs.TrySetResult();
494+
}
495+
}
496+
497+
public class CustomAsyncService : IAsyncDisposable
498+
{
499+
private readonly TaskCompletionSource _tcs = new();
500+
501+
public bool Triggered { get; set; }
502+
503+
public async Task WaitAsync(CancellationToken cancellationToken)
504+
{
505+
cancellationToken.Register(() => _tcs.TrySetCanceled());
506+
await _tcs.Task;
507+
}
508+
509+
public ValueTask DisposeAsync()
510+
{
511+
Triggered = true;
512+
_tcs.TrySetResult();
513+
return default;
514+
}
515+
}
441516
}

src/HotChocolate/Fusion-vnext/src/Fusion.Execution.Types/FusionSchemaDefinition.cs

Lines changed: 24 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,10 +9,11 @@
99
using HotChocolate.Language.Visitors;
1010
using HotChocolate.Serialization;
1111
using HotChocolate.Types;
12+
using Microsoft.Extensions.DependencyInjection;
1213

1314
namespace HotChocolate.Fusion.Types;
1415

15-
public sealed class FusionSchemaDefinition : ISchemaDefinition
16+
public sealed class FusionSchemaDefinition : ISchemaDefinition, IAsyncDisposable
1617
{
1718
#if NET9_0_OR_GREATER
1819
private readonly Lock _lock = new();
@@ -22,9 +23,11 @@ public sealed class FusionSchemaDefinition : ISchemaDefinition
2223
private readonly ConcurrentDictionary<string, ImmutableArray<FusionObjectTypeDefinition>> _possibleTypes = new();
2324
private readonly ConcurrentDictionary<(string, string?), ImmutableArray<Lookup>> _possibleLookups = new();
2425
private readonly ConcurrentDictionary<TransitionKey, Lookup> _bestDirectLookup = new();
26+
private readonly IServiceProvider _services;
2527
private ImmutableArray<FusionUnionTypeDefinition> _unionTypes;
2628
private IFeatureCollection _features;
2729
private bool _sealed;
30+
private bool _disposed;
2831

2932
internal FusionSchemaDefinition(
3033
string name,
@@ -40,7 +43,7 @@ internal FusionSchemaDefinition(
4043
{
4144
Name = name;
4245
Description = description;
43-
Services = services;
46+
_services = services;
4447
QueryType = queryType;
4548
MutationType = mutationType;
4649
SubscriptionType = subscriptionType;
@@ -85,7 +88,7 @@ public static FusionSchemaDefinition Create(
8588
/// <summary>
8689
/// Gets the schema services.
8790
/// </summary>
88-
public IServiceProvider Services { get; }
91+
public IServiceProvider Services => _services;
8992

9093
/// <summary>
9194
/// The type that query operations will be rooted at.
@@ -488,6 +491,24 @@ public DocumentNode ToSyntaxNode()
488491
ISyntaxNode ISyntaxNodeProvider.ToSyntaxNode()
489492
=> SchemaFormatter.FormatAsDocument(this);
490493

494+
public async ValueTask DisposeAsync()
495+
{
496+
if (!_disposed)
497+
{
498+
if (Features.TryGet(out SchemaCancellationFeature? cancellation))
499+
{
500+
await cancellation.DisposeAsync().ConfigureAwait(false);
501+
}
502+
503+
if (_services is IAsyncDisposable disposableServices)
504+
{
505+
await disposableServices.DisposeAsync().ConfigureAwait(false);
506+
}
507+
508+
_disposed = true;
509+
}
510+
}
511+
491512
private readonly record struct TransitionKey(string TypeName, string From, string To);
492513
}
493514

0 commit comments

Comments
 (0)