From d4a3cdd260d8e5eb74ad44710e4dedc9134452dd Mon Sep 17 00:00:00 2001 From: tobias-tengler <45513122+tobias-tengler@users.noreply.github.com> Date: Fri, 24 Oct 2025 17:03:18 +0200 Subject: [PATCH 1/3] Re-add IRequestExecutorOptionsMonitor with tests --- .../Warmup/RequestExecutorWarmupService.cs | 2 +- .../ConfigureRequestExecutorSetup.cs | 41 ----- .../DefaultRequestExecutorOptionsMonitor.cs | 20 +++ .../IConfigureRequestExecutorSetup.cs | 14 -- .../IRequestExecutorOptionsMonitor.cs | 25 ++++ .../InternalServiceCollectionExtensions.cs | 12 +- ...RequestExecutorBuilderExtensions.Caches.cs | 3 +- ...uestExecutorServiceCollectionExtensions.cs | 1 + .../src/Execution/RequestExecutorManager.cs | 7 +- .../RequestExecutorOptionsMonitorTests.cs | 141 ++++++++++++++++++ 10 files changed, 204 insertions(+), 62 deletions(-) delete mode 100644 src/HotChocolate/Core/src/Execution/Configuration/ConfigureRequestExecutorSetup.cs create mode 100644 src/HotChocolate/Core/src/Execution/Configuration/DefaultRequestExecutorOptionsMonitor.cs delete mode 100644 src/HotChocolate/Core/src/Execution/Configuration/IConfigureRequestExecutorSetup.cs create mode 100644 src/HotChocolate/Core/src/Execution/Configuration/IRequestExecutorOptionsMonitor.cs create mode 100644 src/HotChocolate/Core/test/Execution.Tests/Configuration/RequestExecutorOptionsMonitorTests.cs diff --git a/src/HotChocolate/AspNetCore/src/AspNetCore/Warmup/RequestExecutorWarmupService.cs b/src/HotChocolate/AspNetCore/src/AspNetCore/Warmup/RequestExecutorWarmupService.cs index 4b738dd9292..0c2e2668d52 100644 --- a/src/HotChocolate/AspNetCore/src/AspNetCore/Warmup/RequestExecutorWarmupService.cs +++ b/src/HotChocolate/AspNetCore/src/AspNetCore/Warmup/RequestExecutorWarmupService.cs @@ -5,7 +5,7 @@ namespace HotChocolate.AspNetCore.Warmup; internal sealed class RequestExecutorWarmupService( - IOptionsMonitor optionsMonitor, + IRequestExecutorOptionsMonitor optionsMonitor, IRequestExecutorProvider provider) : IHostedService { public async Task StartAsync(CancellationToken cancellationToken) diff --git a/src/HotChocolate/Core/src/Execution/Configuration/ConfigureRequestExecutorSetup.cs b/src/HotChocolate/Core/src/Execution/Configuration/ConfigureRequestExecutorSetup.cs deleted file mode 100644 index b4bffc86333..00000000000 --- a/src/HotChocolate/Core/src/Execution/Configuration/ConfigureRequestExecutorSetup.cs +++ /dev/null @@ -1,41 +0,0 @@ -namespace HotChocolate.Execution.Configuration; - -public sealed class ConfigureRequestExecutorSetup : IConfigureRequestExecutorSetup -{ - private readonly Action _configure; - - public ConfigureRequestExecutorSetup( - string schemaName, - Action configure) - { - if (string.IsNullOrWhiteSpace(schemaName)) - { - throw new ArgumentNullException(nameof(schemaName)); - } - - SchemaName = schemaName; - _configure = configure ?? throw new ArgumentNullException(nameof(configure)); - } - - public ConfigureRequestExecutorSetup( - string schemaName, - RequestExecutorSetup options) - { - if (string.IsNullOrWhiteSpace(schemaName)) - { - throw new ArgumentNullException(nameof(schemaName)); - } - - SchemaName = schemaName; - _configure = options.CopyTo; - } - - public string SchemaName { get; } - - public void Configure(RequestExecutorSetup options) - { - ArgumentNullException.ThrowIfNull(options); - - _configure(options); - } -} diff --git a/src/HotChocolate/Core/src/Execution/Configuration/DefaultRequestExecutorOptionsMonitor.cs b/src/HotChocolate/Core/src/Execution/Configuration/DefaultRequestExecutorOptionsMonitor.cs new file mode 100644 index 00000000000..a655aa17013 --- /dev/null +++ b/src/HotChocolate/Core/src/Execution/Configuration/DefaultRequestExecutorOptionsMonitor.cs @@ -0,0 +1,20 @@ +using Microsoft.Extensions.Options; + +namespace HotChocolate.Execution.Configuration; + +internal sealed class DefaultRequestExecutorOptionsMonitor(IOptionsMonitor optionsMonitor) + : IRequestExecutorOptionsMonitor +{ + public RequestExecutorSetup Get(string schemaName) => optionsMonitor.Get(schemaName); + + public IDisposable OnChange(Action listener) => NoOpListener.Instance; + + private sealed class NoOpListener : IDisposable + { + public void Dispose() + { + } + + public static NoOpListener Instance { get; } = new(); + } +} diff --git a/src/HotChocolate/Core/src/Execution/Configuration/IConfigureRequestExecutorSetup.cs b/src/HotChocolate/Core/src/Execution/Configuration/IConfigureRequestExecutorSetup.cs deleted file mode 100644 index 3ee8d62a108..00000000000 --- a/src/HotChocolate/Core/src/Execution/Configuration/IConfigureRequestExecutorSetup.cs +++ /dev/null @@ -1,14 +0,0 @@ -using Microsoft.Extensions.Options; - -namespace HotChocolate.Execution.Configuration; - -/// -/// Represents something that configures the . -/// -public interface IConfigureRequestExecutorSetup : IConfigureOptions -{ - /// - /// The schema name to which this instance provides configurations to. - /// - string SchemaName { get; } -} diff --git a/src/HotChocolate/Core/src/Execution/Configuration/IRequestExecutorOptionsMonitor.cs b/src/HotChocolate/Core/src/Execution/Configuration/IRequestExecutorOptionsMonitor.cs new file mode 100644 index 00000000000..bfa6d21e965 --- /dev/null +++ b/src/HotChocolate/Core/src/Execution/Configuration/IRequestExecutorOptionsMonitor.cs @@ -0,0 +1,25 @@ +namespace HotChocolate.Execution.Configuration; + +/// +/// Used for notifications when instances change. +/// +public interface IRequestExecutorOptionsMonitor +{ + /// + /// Returns a configured + /// instance with the given name. + /// + RequestExecutorSetup Get(string schemaName); + + /// + /// Registers a listener to be called whenever a named + /// changes. + /// + /// + /// The action to be invoked when has changed. + /// + /// + /// An which should be disposed to stop listening for changes. + /// + IDisposable OnChange(Action listener); +} diff --git a/src/HotChocolate/Core/src/Execution/DependencyInjection/InternalServiceCollectionExtensions.cs b/src/HotChocolate/Core/src/Execution/DependencyInjection/InternalServiceCollectionExtensions.cs index c19c1c38bef..ce99c6829a1 100644 --- a/src/HotChocolate/Core/src/Execution/DependencyInjection/InternalServiceCollectionExtensions.cs +++ b/src/HotChocolate/Core/src/Execution/DependencyInjection/InternalServiceCollectionExtensions.cs @@ -1,23 +1,33 @@ using GreenDonut; using GreenDonut.DependencyInjection; using HotChocolate.Execution; +using HotChocolate.Execution.Configuration; using HotChocolate.Execution.DependencyInjection; using HotChocolate.Execution.Options; using HotChocolate.Execution.Processing; using HotChocolate.Execution.Processing.Tasks; using HotChocolate.Fetching; using HotChocolate.Internal; -using HotChocolate.Language; using HotChocolate.Types; using HotChocolate.Utilities; using Microsoft.Extensions.DependencyInjection.Extensions; using Microsoft.Extensions.ObjectPool; +using Microsoft.Extensions.Options; // ReSharper disable once CheckNamespace namespace Microsoft.Extensions.DependencyInjection; internal static class InternalServiceCollectionExtensions { + internal static IServiceCollection TryAddRequestExecutorOptionsMonitor( + this IServiceCollection services) + { + services.TryAddSingleton( + sp => new DefaultRequestExecutorOptionsMonitor( + sp.GetRequiredService>())); + return services; + } + internal static IServiceCollection TryAddVariableCoercion( this IServiceCollection services) { diff --git a/src/HotChocolate/Core/src/Execution/DependencyInjection/RequestExecutorBuilderExtensions.Caches.cs b/src/HotChocolate/Core/src/Execution/DependencyInjection/RequestExecutorBuilderExtensions.Caches.cs index 6ea68cb7635..5f5aff90dcf 100644 --- a/src/HotChocolate/Core/src/Execution/DependencyInjection/RequestExecutorBuilderExtensions.Caches.cs +++ b/src/HotChocolate/Core/src/Execution/DependencyInjection/RequestExecutorBuilderExtensions.Caches.cs @@ -3,7 +3,6 @@ using HotChocolate.Execution.Configuration; using HotChocolate.Language; using Microsoft.Extensions.DependencyInjection.Extensions; -using Microsoft.Extensions.Options; namespace Microsoft.Extensions.DependencyInjection; @@ -15,7 +14,7 @@ internal static IRequestExecutorBuilder AddDocumentCache(this IRequestExecutorBu builder.Name, static (sp, schemaName) => { - var optionsMonitor = sp.GetRequiredService>(); + var optionsMonitor = sp.GetRequiredService(); var setup = optionsMonitor.Get((string)schemaName!); var options = setup.CreateSchemaOptions(); diff --git a/src/HotChocolate/Core/src/Execution/DependencyInjection/RequestExecutorServiceCollectionExtensions.cs b/src/HotChocolate/Core/src/Execution/DependencyInjection/RequestExecutorServiceCollectionExtensions.cs index 739b8c0df73..dc169647bbf 100644 --- a/src/HotChocolate/Core/src/Execution/DependencyInjection/RequestExecutorServiceCollectionExtensions.cs +++ b/src/HotChocolate/Core/src/Execution/DependencyInjection/RequestExecutorServiceCollectionExtensions.cs @@ -45,6 +45,7 @@ public static IServiceCollection AddGraphQLCore(this IServiceCollection services // core services services + .TryAddRequestExecutorOptionsMonitor() .TryAddTypeConverter() .TryAddInputFormatter() .TryAddInputParser() diff --git a/src/HotChocolate/Core/src/Execution/RequestExecutorManager.cs b/src/HotChocolate/Core/src/Execution/RequestExecutorManager.cs index 2dac198ab62..37155406d1b 100644 --- a/src/HotChocolate/Core/src/Execution/RequestExecutorManager.cs +++ b/src/HotChocolate/Core/src/Execution/RequestExecutorManager.cs @@ -18,7 +18,6 @@ using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection.Extensions; using Microsoft.Extensions.ObjectPool; -using Microsoft.Extensions.Options; using static HotChocolate.Execution.ThrowHelper; namespace HotChocolate.Execution; @@ -31,7 +30,7 @@ internal sealed partial class RequestExecutorManager private readonly CancellationTokenSource _cts = new(); private readonly ConcurrentDictionary _semaphoreBySchema = new(); private readonly ConcurrentDictionary _executors = new(); - private readonly IOptionsMonitor _optionsMonitor; + private readonly IRequestExecutorOptionsMonitor _optionsMonitor; private readonly IServiceProvider _applicationServices; private readonly EventObservable _events = new(); private readonly ChannelWriter _executorEvictionChannelWriter; @@ -39,7 +38,7 @@ internal sealed partial class RequestExecutorManager private bool _disposed; public RequestExecutorManager( - IOptionsMonitor optionsMonitor, + IRequestExecutorOptionsMonitor optionsMonitor, IServiceProvider serviceProvider) { ArgumentNullException.ThrowIfNull(optionsMonitor); @@ -53,6 +52,8 @@ public RequestExecutorManager( ConsumeExecutorEvictionsAsync(executorEvictionChannel.Reader, _cts.Token).FireAndForget(); + _optionsMonitor.OnChange(EvictExecutor); + var schemaNames = _applicationServices.GetService>()? .Select(x => x.Value).Distinct().Order().ToImmutableArray(); SchemaNames = schemaNames ?? []; diff --git a/src/HotChocolate/Core/test/Execution.Tests/Configuration/RequestExecutorOptionsMonitorTests.cs b/src/HotChocolate/Core/test/Execution.Tests/Configuration/RequestExecutorOptionsMonitorTests.cs new file mode 100644 index 00000000000..73dde56c1ca --- /dev/null +++ b/src/HotChocolate/Core/test/Execution.Tests/Configuration/RequestExecutorOptionsMonitorTests.cs @@ -0,0 +1,141 @@ +using HotChocolate.Types; +using Microsoft.Extensions.DependencyInjection; + +namespace HotChocolate.Execution.Configuration; + +public class RequestExecutorOptionsMonitorTests +{ + [Fact] + public async Task RequestExecutorSetup_Can_Be_Provided_Through_RequestExecutorOptionsMonitor() + { + // arrange + var monitor = new TestOptionsMonitor(); + monitor.Set(ISchemaDefinition.DefaultName, new RequestExecutorSetup + { + SchemaBuilder = SchemaBuilder.New() + .AddQueryType(d => d.Field("field").Resolve("")), + Pipeline = { new RequestMiddlewareConfiguration((_, _) => _ => ValueTask.CompletedTask) } + }); + + var services = new ServiceCollection(); + services.AddSingleton(_ => monitor); + + services.AddGraphQLServer(); + + // act + var executor = await services.BuildServiceProvider().GetRequestExecutorAsync(); + + // assert + executor.Schema.MatchInlineSnapshot( + """ + schema { + query: ObjectType + } + + type ObjectType { + field: String + } + """); + } + + [Fact] + public async Task RequestExecutor_Can_Be_Reloaded_Through_RequestExecutorOptionsMonitor() + { + // arrange + var executorEvictedResetEvent = new ManualResetEventSlim(false); + var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5)); + + var monitor = new TestOptionsMonitor(); + monitor.Set(ISchemaDefinition.DefaultName, new RequestExecutorSetup + { + SchemaBuilder = SchemaBuilder.New() + .AddQueryType(d => d.Field("field").Resolve("")), + Pipeline = { new RequestMiddlewareConfiguration((_, _) => _ => ValueTask.CompletedTask) } + }); + + var services = new ServiceCollection(); + services.AddSingleton(_ => monitor); + + services.AddGraphQLServer(); + + var manager = services.BuildServiceProvider().GetRequiredService(); + + manager.Subscribe(new RequestExecutorEventObserver(@event => + { + if (@event.Type == RequestExecutorEventType.Evicted) + { + executorEvictedResetEvent.Set(); + } + })); + + // act + // assert + var initialExecutor = await manager.GetExecutorAsync(cancellationToken: cts.Token); + + monitor.Set(ISchemaDefinition.DefaultName, new RequestExecutorSetup + { + SchemaBuilder = SchemaBuilder.New() + .AddQueryType(d => d.Field("field2").Resolve("")), + Pipeline = { new RequestMiddlewareConfiguration((_, _) => _ => ValueTask.CompletedTask) } + }); + + executorEvictedResetEvent.Wait(cts.Token); + + var executorAfterEviction = await manager.GetExecutorAsync(cancellationToken: cts.Token); + + Assert.NotSame(initialExecutor, executorAfterEviction); + + executorAfterEviction.Schema.MatchInlineSnapshot( + """ + schema { + query: ObjectType + } + + type ObjectType { + field2: String + } + """); + + cts.Dispose(); + } + + private sealed class TestOptionsMonitor : IRequestExecutorOptionsMonitor + { + private readonly Dictionary _setups = new(); + private readonly List> _listeners = new(); + + public void Set(string schemaName, RequestExecutorSetup setup) + { + _setups[schemaName] = setup; + + foreach (var listener in _listeners) + { + listener(schemaName); + } + } + + public RequestExecutorSetup Get(string schemaName) + { + return _setups[schemaName]; + } + + public IDisposable OnChange(Action listener) + { + _listeners.Add(listener); + return new Unsubscriber(this, listener); + } + + private void Unsubscribe(Action listener) + { + _listeners.Remove(listener); + } + + private sealed class Unsubscriber( + TestOptionsMonitor monitor, + Action listener) + : IDisposable + { + public void Dispose() => monitor.Unsubscribe(listener); + } + } +} From d004eda83c80feb5eb4894dadfe3e6fc0d5c4780 Mon Sep 17 00:00:00 2001 From: tobias-tengler <45513122+tobias-tengler@users.noreply.github.com> Date: Fri, 24 Oct 2025 17:11:11 +0200 Subject: [PATCH 2/3] Apply Co-Pilot suggestion --- .../DefaultRequestExecutorOptionsMonitor.cs | 15 ++++----------- .../IRequestExecutorOptionsMonitor.cs | 2 +- 2 files changed, 5 insertions(+), 12 deletions(-) diff --git a/src/HotChocolate/Core/src/Execution/Configuration/DefaultRequestExecutorOptionsMonitor.cs b/src/HotChocolate/Core/src/Execution/Configuration/DefaultRequestExecutorOptionsMonitor.cs index a655aa17013..ad207985306 100644 --- a/src/HotChocolate/Core/src/Execution/Configuration/DefaultRequestExecutorOptionsMonitor.cs +++ b/src/HotChocolate/Core/src/Execution/Configuration/DefaultRequestExecutorOptionsMonitor.cs @@ -2,19 +2,12 @@ namespace HotChocolate.Execution.Configuration; -internal sealed class DefaultRequestExecutorOptionsMonitor(IOptionsMonitor optionsMonitor) +internal sealed class DefaultRequestExecutorOptionsMonitor( + IOptionsMonitor optionsMonitor) : IRequestExecutorOptionsMonitor { public RequestExecutorSetup Get(string schemaName) => optionsMonitor.Get(schemaName); - public IDisposable OnChange(Action listener) => NoOpListener.Instance; - - private sealed class NoOpListener : IDisposable - { - public void Dispose() - { - } - - public static NoOpListener Instance { get; } = new(); - } + public IDisposable? OnChange(Action listener) + => optionsMonitor.OnChange((_, schemaName) => listener(schemaName!)); } diff --git a/src/HotChocolate/Core/src/Execution/Configuration/IRequestExecutorOptionsMonitor.cs b/src/HotChocolate/Core/src/Execution/Configuration/IRequestExecutorOptionsMonitor.cs index bfa6d21e965..5109815346f 100644 --- a/src/HotChocolate/Core/src/Execution/Configuration/IRequestExecutorOptionsMonitor.cs +++ b/src/HotChocolate/Core/src/Execution/Configuration/IRequestExecutorOptionsMonitor.cs @@ -21,5 +21,5 @@ public interface IRequestExecutorOptionsMonitor /// /// An which should be disposed to stop listening for changes. /// - IDisposable OnChange(Action listener); + IDisposable? OnChange(Action listener); } From afa27f093fbbf08d0ed52026b440e30db289d91d Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Fri, 24 Oct 2025 15:17:26 +0000 Subject: [PATCH 3/3] Update performance data [skip ci] --- .../benchmarks/k6/performance-data.json | 38 +++++++++---------- 1 file changed, 19 insertions(+), 19 deletions(-) diff --git a/src/HotChocolate/AspNetCore/benchmarks/k6/performance-data.json b/src/HotChocolate/AspNetCore/benchmarks/k6/performance-data.json index f5de22fcef0..6e99c7afd32 100644 --- a/src/HotChocolate/AspNetCore/benchmarks/k6/performance-data.json +++ b/src/HotChocolate/AspNetCore/benchmarks/k6/performance-data.json @@ -1,20 +1,20 @@ { - "timestamp": "2025-10-23T19:09:41Z", + "timestamp": "2025-10-24T15:17:25Z", "tests": { "single-fetch": { "name": "Single Fetch (50 products, names only)", "response_time": { - "min": 1.311196, - "p50": 1.763142, - "max": 50.855832, - "avg": 1.968737330531039, - "p90": 2.5424552000000005, - "p95": 2.9549014, - "p99": 5.398412600000002 + "min": 1.300229, + "p50": 1.677071, + "max": 45.722702, + "avg": 1.860241621288515, + "p90": 2.3472704, + "p95": 2.7397823999999993, + "p99": 5.095712309999996 }, "throughput": { - "requests_per_second": 78.78513545438359, - "total_iterations": 7168 + "requests_per_second": 78.81265576267907, + "total_iterations": 7171 }, "reliability": { "error_rate": 0 @@ -23,17 +23,17 @@ "dataloader": { "name": "DataLoader (50 products with brands)", "response_time": { - "min": 2.581101, - "p50": 3.39728, - "max": 19.088613, - "avg": 3.776861670740273, - "p90": 5.147565999999999, - "p95": 6.316323699999998, - "p99": 8.866344979999997 + "min": 2.555435, + "p50": 3.141933, + "max": 14.603534, + "avg": 3.471868218227772, + "p90": 4.542777, + "p95": 5.61032, + "p99": 8.034627400000009 }, "throughput": { - "requests_per_second": 78.61021441670597, - "total_iterations": 7150 + "requests_per_second": 78.59853943647569, + "total_iterations": 7152 }, "reliability": { "error_rate": 0