diff --git a/src/HotChocolate/AspNetCore/src/AspNetCore/Extensions/HotChocolateAspNetCoreServiceCollectionExtensions.Warmup.cs b/src/HotChocolate/AspNetCore/src/AspNetCore/Extensions/HotChocolateAspNetCoreServiceCollectionExtensions.Warmup.cs index 83ea680cda1..61d0430c0e4 100644 --- a/src/HotChocolate/AspNetCore/src/AspNetCore/Extensions/HotChocolateAspNetCoreServiceCollectionExtensions.Warmup.cs +++ b/src/HotChocolate/AspNetCore/src/AspNetCore/Extensions/HotChocolateAspNetCoreServiceCollectionExtensions.Warmup.cs @@ -34,7 +34,7 @@ public static IRequestExecutorBuilder InitializeOnStartup( throw new ArgumentNullException(nameof(builder)); } - builder.Services.AddHostedService(); + builder.Services.AddHostedService(); builder.Services.AddSingleton(new WarmupSchemaTask(builder.Name, keepWarm, warmup)); return builder; } diff --git a/src/HotChocolate/AspNetCore/src/AspNetCore/Warmup/ExecutorWarmupService.cs b/src/HotChocolate/AspNetCore/src/AspNetCore/Warmup/ExecutorWarmupService.cs deleted file mode 100644 index c518e7f8f11..00000000000 --- a/src/HotChocolate/AspNetCore/src/AspNetCore/Warmup/ExecutorWarmupService.cs +++ /dev/null @@ -1,96 +0,0 @@ -using HotChocolate.Utilities; -using Microsoft.Extensions.Hosting; - -namespace HotChocolate.AspNetCore.Warmup; - -internal class ExecutorWarmupService : BackgroundService -{ - private readonly IRequestExecutorResolver _executorResolver; - private readonly Dictionary _tasks; - private IDisposable? _eventSubscription; - private CancellationToken _stopping; - - public ExecutorWarmupService( - IRequestExecutorResolver executorResolver, - IEnumerable tasks) - { - if (tasks is null) - { - throw new ArgumentNullException(nameof(tasks)); - } - - _executorResolver = executorResolver ?? - throw new ArgumentNullException(nameof(executorResolver)); - _tasks = tasks.GroupBy(t => t.SchemaName).ToDictionary(t => t.Key, t => t.ToArray()); - } - - protected override async Task ExecuteAsync(CancellationToken stoppingToken) - { - _stopping = stoppingToken; - _eventSubscription = _executorResolver.Events.Subscribe( - new WarmupObserver(name => BeginWarmup(name))); - - foreach (var task in _tasks) - { - // initialize services - var executor = await _executorResolver.GetRequestExecutorAsync(task.Key, stoppingToken); - - // execute startup task - foreach (var warmup in task.Value) - { - await warmup.ExecuteAsync(executor, stoppingToken); - } - } - } - - private void BeginWarmup(string schemaName) - { - if (_tasks.TryGetValue(schemaName, out var value) && value.Any(t => t.KeepWarm)) - { - WarmupAsync(schemaName, value, _stopping).FireAndForget(); - } - } - - private async Task WarmupAsync( - string schemaName, - WarmupSchemaTask[] tasks, - CancellationToken ct) - { - // initialize services - var executor = await _executorResolver.GetRequestExecutorAsync(schemaName, ct); - - // execute startup task - foreach (var warmup in tasks) - { - await warmup.ExecuteAsync(executor, ct); - } - } - - public override void Dispose() - { - _eventSubscription?.Dispose(); - base.Dispose(); - } - - private sealed class WarmupObserver : IObserver - { - public WarmupObserver(Action onEvicted) - { - OnEvicted = onEvicted; - } - - public Action OnEvicted { get; } - - public void OnNext(RequestExecutorEvent value) - { - if (value.Type is RequestExecutorEventType.Evicted) - { - OnEvicted(value.Name); - } - } - - public void OnError(Exception error) { } - - public void OnCompleted() { } - } -} diff --git a/src/HotChocolate/AspNetCore/src/AspNetCore/Warmup/RequestExecutorWarmupService.cs b/src/HotChocolate/AspNetCore/src/AspNetCore/Warmup/RequestExecutorWarmupService.cs new file mode 100644 index 00000000000..76d5d1bdf04 --- /dev/null +++ b/src/HotChocolate/AspNetCore/src/AspNetCore/Warmup/RequestExecutorWarmupService.cs @@ -0,0 +1,14 @@ +using Microsoft.Extensions.Hosting; + +namespace HotChocolate.AspNetCore.Warmup; + +internal sealed class RequestExecutorWarmupService( + IRequestExecutorWarmup executorWarmup) + : IHostedService +{ + public async Task StartAsync(CancellationToken cancellationToken) + => await executorWarmup.WarmupAsync(cancellationToken).ConfigureAwait(false); + + public Task StopAsync(CancellationToken cancellationToken) + => Task.CompletedTask; +} diff --git a/src/HotChocolate/AspNetCore/src/AspNetCore/Warmup/WarmupSchemaTask.cs b/src/HotChocolate/AspNetCore/src/AspNetCore/Warmup/WarmupSchemaTask.cs deleted file mode 100644 index 7dd8822b213..00000000000 --- a/src/HotChocolate/AspNetCore/src/AspNetCore/Warmup/WarmupSchemaTask.cs +++ /dev/null @@ -1,25 +0,0 @@ -namespace HotChocolate.AspNetCore.Warmup; - -internal sealed class WarmupSchemaTask -{ - private readonly Func? _warmup; - - public WarmupSchemaTask( - string schemaName, - bool keepWarm, - Func? warmup = null) - { - _warmup = warmup; - SchemaName = schemaName; - KeepWarm = keepWarm; - } - - public string SchemaName { get; } - - public bool KeepWarm { get; } - - public Task ExecuteAsync(IRequestExecutor executor, CancellationToken cancellationToken) - => _warmup is not null - ? _warmup.Invoke(executor, cancellationToken) - : Task.CompletedTask; -} diff --git a/src/HotChocolate/AspNetCore/test/AspNetCore.Tests/EvictSchemaTests.cs b/src/HotChocolate/AspNetCore/test/AspNetCore.Tests/EvictSchemaTests.cs index e74cd33d809..5c22a89274f 100644 --- a/src/HotChocolate/AspNetCore/test/AspNetCore.Tests/EvictSchemaTests.cs +++ b/src/HotChocolate/AspNetCore/test/AspNetCore.Tests/EvictSchemaTests.cs @@ -1,26 +1,34 @@ using HotChocolate.AspNetCore.Tests.Utilities; +using HotChocolate.Execution; +using Microsoft.Extensions.DependencyInjection; namespace HotChocolate.AspNetCore; -public class EvictSchemaTests : ServerTestBase +public class EvictSchemaTests(TestServerFactory serverFactory) : ServerTestBase(serverFactory) { - public EvictSchemaTests(TestServerFactory serverFactory) - : base(serverFactory) - { - } - [Fact] public async Task Evict_Default_Schema() { // arrange + var newExecutorCreatedResetEvent = new AutoResetEvent(false); var server = CreateStarWarsServer(); var time1 = await server.GetAsync( new ClientQueryRequest { Query = "{ time }", }); + var resolver = server.Services.GetRequiredService(); + resolver.Events.Subscribe(new RequestExecutorEventObserver(@event => + { + if (@event.Type == RequestExecutorEventType.Created) + { + newExecutorCreatedResetEvent.Set(); + } + })); + // act await server.GetAsync( new ClientQueryRequest { Query = "{ evict }", }); + newExecutorCreatedResetEvent.WaitOne(5000); // assert var time2 = await server.GetAsync( @@ -32,16 +40,27 @@ await server.GetAsync( public async Task Evict_Named_Schema() { // arrange + var newExecutorCreatedResetEvent = new AutoResetEvent(false); var server = CreateStarWarsServer(); var time1 = await server.GetAsync( new ClientQueryRequest { Query = "{ time }", }, "/evict"); + var resolver = server.Services.GetRequiredService(); + resolver.Events.Subscribe(new RequestExecutorEventObserver(@event => + { + if (@event.Type == RequestExecutorEventType.Created) + { + newExecutorCreatedResetEvent.Set(); + } + })); + // act await server.GetAsync( new ClientQueryRequest { Query = "{ evict }", }, "/evict"); + newExecutorCreatedResetEvent.WaitOne(5000); // assert var time2 = await server.GetAsync( diff --git a/src/HotChocolate/Core/src/Execution/AutoUpdateRequestExecutorProxy.cs b/src/HotChocolate/Core/src/Execution/AutoUpdateRequestExecutorProxy.cs index a7c2af5ee57..f4300cc8d8f 100644 --- a/src/HotChocolate/Core/src/Execution/AutoUpdateRequestExecutorProxy.cs +++ b/src/HotChocolate/Core/src/Execution/AutoUpdateRequestExecutorProxy.cs @@ -21,9 +21,7 @@ private AutoUpdateRequestExecutorProxy( _executorProxy = requestExecutorProxy; _executor = initialExecutor; - _executorProxy.ExecutorEvicted += (_, _) => BeginUpdateExecutor(); - - BeginUpdateExecutor(); + _executorProxy.ExecutorUpdated += (_, args) => _executor = args.Executor; } /// @@ -144,26 +142,6 @@ public Task ExecuteBatchAsync( CancellationToken cancellationToken = default) => _executor.ExecuteBatchAsync(requestBatch, cancellationToken); - private void BeginUpdateExecutor() - => UpdateExecutorAsync().FireAndForget(); - - private async ValueTask UpdateExecutorAsync() - { - await _semaphore.WaitAsync().ConfigureAwait(false); - - try - { - var executor = await _executorProxy - .GetRequestExecutorAsync(CancellationToken.None) - .ConfigureAwait(false); - _executor = executor; - } - finally - { - _semaphore.Release(); - } - } - /// public void Dispose() { diff --git a/src/HotChocolate/Core/src/Execution/DependencyInjection/InternalServiceCollectionExtensions.cs b/src/HotChocolate/Core/src/Execution/DependencyInjection/InternalServiceCollectionExtensions.cs index f7d463d43dd..f6f4dee078f 100644 --- a/src/HotChocolate/Core/src/Execution/DependencyInjection/InternalServiceCollectionExtensions.cs +++ b/src/HotChocolate/Core/src/Execution/DependencyInjection/InternalServiceCollectionExtensions.cs @@ -154,10 +154,8 @@ internal static IServiceCollection TryAddRequestExecutorResolver( this IServiceCollection services) { services.TryAddSingleton(); - services.TryAddSingleton( - sp => sp.GetRequiredService()); - services.TryAddSingleton( - sp => sp.GetRequiredService()); + services.TryAddSingleton(sp => sp.GetRequiredService()); + services.TryAddSingleton(sp => sp.GetRequiredService()); return services; } diff --git a/src/HotChocolate/Core/src/Execution/HotChocolate.Execution.csproj b/src/HotChocolate/Core/src/Execution/HotChocolate.Execution.csproj index 0980e2d578e..1942abf89c8 100644 --- a/src/HotChocolate/Core/src/Execution/HotChocolate.Execution.csproj +++ b/src/HotChocolate/Core/src/Execution/HotChocolate.Execution.csproj @@ -12,6 +12,7 @@ + diff --git a/src/HotChocolate/Core/src/Execution/IRequestExecutorWarmup.cs b/src/HotChocolate/Core/src/Execution/IRequestExecutorWarmup.cs new file mode 100644 index 00000000000..00fd7f9d68a --- /dev/null +++ b/src/HotChocolate/Core/src/Execution/IRequestExecutorWarmup.cs @@ -0,0 +1,18 @@ +namespace HotChocolate.Execution; + +/// +/// Allows to run the initial warmup for registered s. +/// +internal interface IRequestExecutorWarmup +{ + /// + /// Runs the initial warmup tasks. + /// + /// + /// The cancellation token. + /// + /// + /// Returns a task that completes once the warmup is done. + /// + Task WarmupAsync(CancellationToken cancellationToken); +} diff --git a/src/HotChocolate/Core/src/Execution/Internal/IInternalRequestExecutorResolver.cs b/src/HotChocolate/Core/src/Execution/Internal/IInternalRequestExecutorResolver.cs deleted file mode 100644 index 9be9dd64138..00000000000 --- a/src/HotChocolate/Core/src/Execution/Internal/IInternalRequestExecutorResolver.cs +++ /dev/null @@ -1,25 +0,0 @@ -namespace HotChocolate.Execution.Internal; - -/// -/// The is an internal request executor resolver that is not meant for public usage. -/// -public interface IInternalRequestExecutorResolver -{ - /// - /// Gets or creates the request executor that is associated with the - /// given configuration . - /// - /// - /// The schema name. - /// - /// - /// The cancellation token. - /// - /// - /// Returns a request executor that is associated with the - /// given configuration . - /// - ValueTask GetRequestExecutorNoLockAsync( - string? schemaName = default, - CancellationToken cancellationToken = default); -} diff --git a/src/HotChocolate/Core/src/Execution/RequestExecutorProxy.cs b/src/HotChocolate/Core/src/Execution/RequestExecutorProxy.cs index 1f467f680fa..dab1e9a5cb6 100644 --- a/src/HotChocolate/Core/src/Execution/RequestExecutorProxy.cs +++ b/src/HotChocolate/Core/src/Execution/RequestExecutorProxy.cs @@ -29,7 +29,7 @@ public RequestExecutorProxy(IRequestExecutorResolver executorResolver, string sc _schemaName = schemaName; _eventSubscription = _executorResolver.Events.Subscribe( - new ExecutorObserver(EvictRequestExecutor)); + new RequestExecutorEventObserver(OnRequestExecutorEvent)); } public IRequestExecutor? CurrentExecutor => _executor; @@ -178,15 +178,19 @@ public async ValueTask GetRequestExecutorAsync( return executor; } - private void EvictRequestExecutor(string schemaName) + private void OnRequestExecutorEvent(RequestExecutorEvent @event) { - if (!_disposed && schemaName.Equals(_schemaName)) + if (_disposed || !@event.Name.Equals(_schemaName) || _executor is null) + { + return; + } + + if (@event.Type is RequestExecutorEventType.Evicted) { _semaphore.Wait(); try { - _executor = null; ExecutorEvicted?.Invoke(this, EventArgs.Empty); } finally @@ -194,6 +198,20 @@ private void EvictRequestExecutor(string schemaName) _semaphore.Release(); } } + else if (@event.Type is RequestExecutorEventType.Created) + { + _semaphore.Wait(); + + try + { + _executor = @event.Executor; + ExecutorUpdated?.Invoke(this, new RequestExecutorUpdatedEventArgs(@event.Executor)); + } + finally + { + _semaphore.Release(); + } + } } public void Dispose() @@ -206,19 +224,4 @@ public void Dispose() _disposed = true; } } - - private sealed class ExecutorObserver(Action evicted) : IObserver - { - public void OnNext(RequestExecutorEvent value) - { - if (value.Type is RequestExecutorEventType.Evicted) - { - evicted(value.Name); - } - } - - public void OnError(Exception error) { } - - public void OnCompleted() { } - } } diff --git a/src/HotChocolate/Core/src/Execution/RequestExecutorResolver.Warmup.cs b/src/HotChocolate/Core/src/Execution/RequestExecutorResolver.Warmup.cs new file mode 100644 index 00000000000..9e441a1449f --- /dev/null +++ b/src/HotChocolate/Core/src/Execution/RequestExecutorResolver.Warmup.cs @@ -0,0 +1,35 @@ +namespace HotChocolate.Execution; + +internal sealed partial class RequestExecutorResolver +{ + private bool _initialWarmupDone; + + public async Task WarmupAsync(CancellationToken cancellationToken) + { + if (_initialWarmupDone) + { + return; + } + _initialWarmupDone = true; + + // we get the schema names for schemas that have warmup tasks. + var schemasToWarmup = _warmupTasksBySchema.Keys; + var tasks = new Task[schemasToWarmup.Length]; + + for (var i = 0; i < schemasToWarmup.Length; i++) + { + // next we create an initial warmup for each schema + tasks[i] = WarmupSchemaAsync(schemasToWarmup[i], cancellationToken); + } + + // last we wait for all warmup tasks to complete. + await Task.WhenAll(tasks).ConfigureAwait(false); + + async Task WarmupSchemaAsync(string schemaName, CancellationToken cancellationToken) + { + // the actual warmup tasks are executed inlined into the executor creation. + await GetRequestExecutorAsync(schemaName, cancellationToken) + .ConfigureAwait(false); + } + } +} diff --git a/src/HotChocolate/Core/src/Execution/RequestExecutorResolver.cs b/src/HotChocolate/Core/src/Execution/RequestExecutorResolver.cs index 5b1bb06d433..1cdd73c7105 100644 --- a/src/HotChocolate/Core/src/Execution/RequestExecutorResolver.cs +++ b/src/HotChocolate/Core/src/Execution/RequestExecutorResolver.cs @@ -1,13 +1,14 @@ using System.Collections.Concurrent; +using System.Collections.Frozen; using System.Collections.Immutable; using System.Reflection.Metadata; +using System.Threading.Channels; using HotChocolate.Configuration; using HotChocolate.Execution; using HotChocolate.Execution.Caching; using HotChocolate.Execution.Configuration; using HotChocolate.Execution.Errors; using HotChocolate.Execution.Instrumentation; -using HotChocolate.Execution.Internal; using HotChocolate.Execution.Options; using HotChocolate.Execution.Processing; using HotChocolate.Types; @@ -25,14 +26,17 @@ namespace HotChocolate.Execution; internal sealed partial class RequestExecutorResolver : IRequestExecutorResolver - , IInternalRequestExecutorResolver + , IRequestExecutorWarmup , IDisposable { - private readonly SemaphoreSlim _semaphore = new(1, 1); + private readonly CancellationTokenSource _cts = new(); + private readonly ConcurrentDictionary _semaphoreBySchema = new(); private readonly ConcurrentDictionary _executors = new(); + private readonly FrozenDictionary _warmupTasksBySchema; private readonly IRequestExecutorOptionsMonitor _optionsMonitor; private readonly IServiceProvider _applicationServices; private readonly EventObservable _events = new(); + private readonly ChannelWriter _executorEvictionChannelWriter; private ulong _version; private bool _disposed; @@ -41,17 +45,26 @@ internal sealed partial class RequestExecutorResolver public RequestExecutorResolver( IRequestExecutorOptionsMonitor optionsMonitor, + IEnumerable warmupSchemaTasks, IServiceProvider serviceProvider) { _optionsMonitor = optionsMonitor ?? throw new ArgumentNullException(nameof(optionsMonitor)); _applicationServices = serviceProvider ?? throw new ArgumentNullException(nameof(serviceProvider)); + _warmupTasksBySchema = warmupSchemaTasks.GroupBy(t => t.SchemaName) + .ToFrozenDictionary(g => g.Key, g => g.ToArray()); + + var executorEvictionChannel = Channel.CreateUnbounded(); + _executorEvictionChannelWriter = executorEvictionChannel.Writer; + + ConsumeExecutorEvictionsAsync(executorEvictionChannel.Reader, _cts.Token).FireAndForget(); + _optionsMonitor.OnChange(EvictRequestExecutor); // we register the schema eviction for application updates when hot reload is used. // Whenever a hot reload update is triggered we will evict all executors. - ApplicationUpdateHandler.RegisterForApplicationUpdate(() => EvictAllRequestExecutors()); + ApplicationUpdateHandler.RegisterForApplicationUpdate(EvictAllRequestExecutors); } public IObservable Events => _events; @@ -62,109 +75,171 @@ public async ValueTask GetRequestExecutorAsync( { schemaName ??= Schema.DefaultName; - if (!_executors.TryGetValue(schemaName, out var re)) + if (_executors.TryGetValue(schemaName, out var re)) { - await _semaphore.WaitAsync(cancellationToken).ConfigureAwait(false); + return re.Executor; + } + + var semaphore = GetSemaphoreForSchema(schemaName); + await semaphore.WaitAsync(cancellationToken).ConfigureAwait(false); - try + try + { + // We check the cache again for the case that GetRequestExecutorAsync has been + // called multiple times. This should only happen, if someone calls GetRequestExecutorAsync + // themselves. Normally the RequestExecutorProxy takes care of only calling this method once. + if (_executors.TryGetValue(schemaName, out re)) { - return await GetRequestExecutorNoLockAsync(schemaName, cancellationToken) - .ConfigureAwait(false); + return re.Executor; } - finally + + var registeredExecutor = await CreateRequestExecutorAsync(schemaName, true, cancellationToken) + .ConfigureAwait(false); + + return registeredExecutor.Executor; + } + finally + { + semaphore.Release(); + } + } + + public void EvictRequestExecutor(string? schemaName = default) + { + schemaName ??= Schema.DefaultName; + + _executorEvictionChannelWriter.TryWrite(schemaName); + } + + private async ValueTask ConsumeExecutorEvictionsAsync( + ChannelReader reader, + CancellationToken cancellationToken) + { + while (await reader.WaitToReadAsync(cancellationToken).ConfigureAwait(false)) + { + while (reader.TryRead(out var schemaName)) { - _semaphore.Release(); + var semaphore = GetSemaphoreForSchema(schemaName); + await semaphore.WaitAsync(cancellationToken); + + try + { + if (_executors.TryGetValue(schemaName, out var previousExecutor)) + { + await UpdateRequestExecutorAsync(schemaName, previousExecutor); + } + } + catch + { + // Ignore + } + finally + { + semaphore.Release(); + } } } - - return re.Executor; } - public async ValueTask GetRequestExecutorNoLockAsync( - string? schemaName = default, - CancellationToken cancellationToken = default) + private SemaphoreSlim GetSemaphoreForSchema(string schemaName) + => _semaphoreBySchema.GetOrAdd(schemaName, _ => new SemaphoreSlim(1, 1)); + + private async Task CreateRequestExecutorAsync( + string schemaName, + bool isInitialCreation, + CancellationToken cancellationToken) { - schemaName ??= Schema.DefaultName; + var setup = + await _optionsMonitor.GetAsync(schemaName, cancellationToken) + .ConfigureAwait(false); + + var context = new ConfigurationContext( + schemaName, + setup.SchemaBuilder ?? new SchemaBuilder(), + _applicationServices); - if (!_executors.TryGetValue(schemaName, out var registeredExecutor)) + var typeModuleChangeMonitor = new TypeModuleChangeMonitor(this, context.SchemaName); + + // if there are any type modules we will register them with the + // type module change monitor. + // The module will track if type modules signal changes to the schema and + // start a schema eviction. + foreach (var typeModule in setup.TypeModules) { - var setup = - await _optionsMonitor.GetAsync(schemaName, cancellationToken) - .ConfigureAwait(false); + typeModuleChangeMonitor.Register(typeModule); + } - var context = new ConfigurationContext( - schemaName, - setup.SchemaBuilder ?? new SchemaBuilder(), - _applicationServices); + var schemaServices = + await CreateSchemaServicesAsync(context, setup, typeModuleChangeMonitor, cancellationToken) + .ConfigureAwait(false); + + var registeredExecutor = new RegisteredExecutor( + schemaServices.GetRequiredService(), + schemaServices, + schemaServices.GetRequiredService(), + setup, + typeModuleChangeMonitor); + var executor = registeredExecutor.Executor; - var typeModuleChangeMonitor = new TypeModuleChangeMonitor(this, context.SchemaName); + await OnRequestExecutorCreatedAsync(context, executor, setup, cancellationToken) + .ConfigureAwait(false); - // if there are any type modules we will register them with the - // type module change monitor. - // The module will track if type modules signal changes to the schema and - // start a schema eviction. - foreach (var typeModule in setup.TypeModules) + if (_warmupTasksBySchema.TryGetValue(schemaName, out var warmupTasks)) + { + if (!isInitialCreation) { - typeModuleChangeMonitor.Register(typeModule); + warmupTasks = [.. warmupTasks.Where(t => t.KeepWarm)]; } - var schemaServices = - await CreateSchemaServicesAsync(context, setup, typeModuleChangeMonitor, cancellationToken) - .ConfigureAwait(false); - - registeredExecutor = new RegisteredExecutor( - schemaServices.GetRequiredService(), - schemaServices, - schemaServices.GetRequiredService(), - setup, - typeModuleChangeMonitor); + foreach (var warmupTask in warmupTasks) + { + await warmupTask.ExecuteAsync(executor, cancellationToken).ConfigureAwait(false); + } + } - var executor = registeredExecutor.Executor; + _executors[schemaName] = registeredExecutor; - await OnRequestExecutorCreatedAsync(context, executor, setup, cancellationToken) - .ConfigureAwait(false); + registeredExecutor.DiagnosticEvents.ExecutorCreated( + schemaName, + registeredExecutor.Executor); - registeredExecutor.DiagnosticEvents.ExecutorCreated( + _events.RaiseEvent( + new RequestExecutorEvent( + RequestExecutorEventType.Created, schemaName, - registeredExecutor.Executor); - _executors.TryAdd(schemaName, registeredExecutor); + registeredExecutor.Executor)); - _events.RaiseEvent( - new RequestExecutorEvent( - RequestExecutorEventType.Created, - schemaName, - registeredExecutor.Executor)); - } - - return registeredExecutor.Executor; + return registeredExecutor; } - public void EvictRequestExecutor(string? schemaName = default) + private async Task UpdateRequestExecutorAsync(string schemaName, RegisteredExecutor previousExecutor) { - schemaName ??= Schema.DefaultName; + // We dispose the subscription to type updates so there will be no updates + // during the phase-out of the previous executor. + previousExecutor.TypeModuleChangeMonitor.Dispose(); + + // This will hot swap the request executor. + await CreateRequestExecutorAsync(schemaName, false, CancellationToken.None) + .ConfigureAwait(false); - if (_executors.TryRemove(schemaName, out var executor)) + previousExecutor.DiagnosticEvents.ExecutorEvicted(schemaName, previousExecutor.Executor); + + try { - executor.DiagnosticEvents.ExecutorEvicted(schemaName, executor.Executor); + RequestExecutorEvicted?.Invoke( + this, + new RequestExecutorEvictedEventArgs(schemaName, previousExecutor.Executor)); - try - { - executor.TypeModuleChangeMonitor.Dispose(); - - RequestExecutorEvicted?.Invoke( - this, - new RequestExecutorEvictedEventArgs(schemaName, executor.Executor)); - _events.RaiseEvent( - new RequestExecutorEvent( - RequestExecutorEventType.Evicted, - schemaName, - executor.Executor)); - } - finally - { - BeginRunEvictionEvents(executor); - } + _events.RaiseEvent( + new RequestExecutorEvent( + RequestExecutorEventType.Evicted, + schemaName, + previousExecutor.Executor)); + } + finally + { + RunEvictionEvents(previousExecutor).FireAndForget(); } } @@ -176,9 +251,6 @@ private void EvictAllRequestExecutors() } } - private static void BeginRunEvictionEvents(RegisteredExecutor registeredExecutor) - => RunEvictionEvents(registeredExecutor).FireAndForget(); - private static async Task RunEvictionEvents(RegisteredExecutor registeredExecutor) { try @@ -188,7 +260,7 @@ private static async Task RunEvictionEvents(RegisteredExecutor registeredExecuto finally { // we will give the request executor some grace period to finish all request - // in the pipeline + // in the pipeline. await Task.Delay(TimeSpan.FromMinutes(5)); registeredExecutor.Dispose(); } @@ -439,9 +511,23 @@ public void Dispose() { if (!_disposed) { + // this will stop the eviction processor. + _cts.Cancel(); + + foreach (var executor in _executors.Values) + { + executor.Dispose(); + } + + foreach (var semaphore in _semaphoreBySchema.Values) + { + semaphore.Dispose(); + } + _events.Dispose(); _executors.Clear(); - _semaphore.Dispose(); + _semaphoreBySchema.Clear(); + _cts.Dispose(); _disposed = true; } } @@ -494,20 +580,11 @@ public override void OnBeforeCompleteName( } } - private sealed class TypeModuleChangeMonitor : IDisposable + private sealed class TypeModuleChangeMonitor(RequestExecutorResolver resolver, string schemaName) : IDisposable { private readonly List _typeModules = []; - private readonly RequestExecutorResolver _resolver; private bool _disposed; - public TypeModuleChangeMonitor(RequestExecutorResolver resolver, string schemaName) - { - _resolver = resolver; - SchemaName = schemaName; - } - - public string SchemaName { get; } - public void Register(ITypeModule typeModule) { typeModule.TypesChanged += EvictRequestExecutor; @@ -532,7 +609,7 @@ public IAsyncEnumerable CreateTypesAsync(IDescriptorContext c => new TypeModuleEnumerable(_typeModules, context); private void EvictRequestExecutor(object? sender, EventArgs args) - => _resolver.EvictRequestExecutor(SchemaName); + => resolver.EvictRequestExecutor(schemaName); public void Dispose() { diff --git a/src/HotChocolate/Core/src/Execution/WarmupSchemaTask.cs b/src/HotChocolate/Core/src/Execution/WarmupSchemaTask.cs new file mode 100644 index 00000000000..ae8c8d8c990 --- /dev/null +++ b/src/HotChocolate/Core/src/Execution/WarmupSchemaTask.cs @@ -0,0 +1,16 @@ +namespace HotChocolate.Execution; + +internal sealed class WarmupSchemaTask( + string schemaName, + bool keepWarm, + Func? warmup = null) +{ + public string SchemaName { get; } = schemaName; + + public bool KeepWarm { get; } = keepWarm; + + public Task ExecuteAsync(IRequestExecutor executor, CancellationToken cancellationToken) + => warmup is not null + ? warmup.Invoke(executor, cancellationToken) + : Task.CompletedTask; +} diff --git a/src/HotChocolate/Core/test/Execution.Tests/AutoUpdateRequestExecutorProxyTests.cs b/src/HotChocolate/Core/test/Execution.Tests/AutoUpdateRequestExecutorProxyTests.cs index 59ace2dfe58..1a421f844e7 100644 --- a/src/HotChocolate/Core/test/Execution.Tests/AutoUpdateRequestExecutorProxyTests.cs +++ b/src/HotChocolate/Core/test/Execution.Tests/AutoUpdateRequestExecutorProxyTests.cs @@ -33,6 +33,7 @@ public async Task Ensure_Executor_Is_Cached() public async Task Ensure_Executor_Is_Correctly_Swapped_When_Evicted() { // arrange + var executorUpdatedResetEvent = new AutoResetEvent(false); var resolver = new ServiceCollection() .AddGraphQL() @@ -45,30 +46,21 @@ public async Task Ensure_Executor_Is_Correctly_Swapped_When_Evicted() var updated = false; var innerProxy = new RequestExecutorProxy(resolver, Schema.DefaultName); + + var proxy = await AutoUpdateRequestExecutorProxy.CreateAsync(innerProxy); innerProxy.ExecutorEvicted += (_, _) => { evicted = true; - updated = false; + executorUpdatedResetEvent.Set(); }; innerProxy.ExecutorUpdated += (_, _) => updated = true; - var proxy = await AutoUpdateRequestExecutorProxy.CreateAsync(innerProxy); - // act var a = proxy.InnerExecutor; resolver.EvictRequestExecutor(); + executorUpdatedResetEvent.WaitOne(1000); - var i = 0; var b = proxy.InnerExecutor; - while (ReferenceEquals(a, b)) - { - await Task.Delay(100); - b = proxy.InnerExecutor; - if (i++ > 10) - { - break; - } - } // assert Assert.NotSame(a, b); diff --git a/src/HotChocolate/Core/test/Execution.Tests/RequestExecutorProxyTests.cs b/src/HotChocolate/Core/test/Execution.Tests/RequestExecutorProxyTests.cs index 62a5fa30663..0bf05913747 100644 --- a/src/HotChocolate/Core/test/Execution.Tests/RequestExecutorProxyTests.cs +++ b/src/HotChocolate/Core/test/Execution.Tests/RequestExecutorProxyTests.cs @@ -31,6 +31,7 @@ public async Task Ensure_Executor_Is_Cached() public async Task Ensure_Executor_Is_Correctly_Swapped_When_Evicted() { // arrange + var executorUpdatedResetEvent = new AutoResetEvent(false); var resolver = new ServiceCollection() .AddGraphQL() @@ -46,13 +47,14 @@ public async Task Ensure_Executor_Is_Correctly_Swapped_When_Evicted() proxy.ExecutorEvicted += (sender, args) => { evicted = true; - updated = false; + executorUpdatedResetEvent.Set(); }; proxy.ExecutorUpdated += (sender, args) => updated = true; // act var a = await proxy.GetRequestExecutorAsync(CancellationToken.None); resolver.EvictRequestExecutor(); + executorUpdatedResetEvent.WaitOne(1000); var b = await proxy.GetRequestExecutorAsync(CancellationToken.None); // assert diff --git a/src/HotChocolate/Core/test/Execution.Tests/RequestExecutorResolverTests.cs b/src/HotChocolate/Core/test/Execution.Tests/RequestExecutorResolverTests.cs index 683574d45be..8a98bfe527f 100644 --- a/src/HotChocolate/Core/test/Execution.Tests/RequestExecutorResolverTests.cs +++ b/src/HotChocolate/Core/test/Execution.Tests/RequestExecutorResolverTests.cs @@ -10,12 +10,21 @@ public class RequestExecutorResolverTests public async Task Operation_Cache_Should_Be_Scoped_To_Executor() { // arrange - var services = new ServiceCollection(); - services + var executorEvictedResetEvent = new AutoResetEvent(false); + + var resolver = new ServiceCollection() .AddGraphQL() - .AddQueryType(d => d.Field("foo").Resolve("")); - var provider = services.BuildServiceProvider(); - var resolver = provider.GetRequiredService(); + .AddQueryType(d => d.Field("foo").Resolve("")) + .Services.BuildServiceProvider() + .GetRequiredService(); + + resolver.Events.Subscribe(new RequestExecutorEventObserver(@event => + { + if (@event.Type == RequestExecutorEventType.Evicted) + { + executorEvictedResetEvent.Set(); + } + })); // act var firstExecutor = await resolver.GetRequestExecutorAsync(); @@ -23,12 +32,163 @@ public async Task Operation_Cache_Should_Be_Scoped_To_Executor() .GetRequiredService(); resolver.EvictRequestExecutor(); + executorEvictedResetEvent.WaitOne(1000); var secondExecutor = await resolver.GetRequestExecutorAsync(); var secondOperationCache = secondExecutor.Services.GetCombinedServices() .GetRequiredService(); // assert - Assert.NotEqual(secondOperationCache, firstOperationCache); + Assert.NotSame(secondOperationCache, firstOperationCache); + } + + [Fact] + public async Task Executor_Should_Only_Be_Switched_Once_It_Is_Warmed_Up() + { + // arrange + var warmupResetEvent = new AutoResetEvent(true); + var executorEvictedResetEvent = new AutoResetEvent(false); + + var resolver = new ServiceCollection() + .AddGraphQL() + .InitializeOnStartup( + keepWarm: true, + warmup: (_, _) => + { + warmupResetEvent.WaitOne(1000); + + return Task.CompletedTask; + }) + .AddQueryType(d => d.Field("foo").Resolve("")) + .Services.BuildServiceProvider() + .GetRequiredService(); + + resolver.Events.Subscribe(new RequestExecutorEventObserver(@event => + { + if (@event.Type == RequestExecutorEventType.Evicted) + { + executorEvictedResetEvent.Set(); + } + })); + + // act + // assert + var initialExecutor = await resolver.GetRequestExecutorAsync(); + warmupResetEvent.Reset(); + + resolver.EvictRequestExecutor(); + + var executorAfterEviction = await resolver.GetRequestExecutorAsync(); + + Assert.Same(initialExecutor, executorAfterEviction); + + warmupResetEvent.Set(); + executorEvictedResetEvent.WaitOne(1000); + var executorAfterWarmup = await resolver.GetRequestExecutorAsync(); + + Assert.NotSame(initialExecutor, executorAfterWarmup); + } + + [Theory] + [InlineData(false, 1)] + [InlineData(true, 2)] + public async Task WarmupSchemaTasks_Are_Applied_Correct_Number_Of_Times( + bool keepWarm, int expectedWarmups) + { + // arrange + var warmups = 0; + var executorEvictedResetEvent = new AutoResetEvent(false); + + var resolver = new ServiceCollection() + .AddGraphQL() + .InitializeOnStartup( + keepWarm: keepWarm, + warmup: (_, _) => + { + warmups++; + return Task.CompletedTask; + }) + .AddQueryType(d => d.Field("foo").Resolve("")) + .Services.BuildServiceProvider() + .GetRequiredService(); + + resolver.Events.Subscribe(new RequestExecutorEventObserver(@event => + { + if (@event.Type == RequestExecutorEventType.Evicted) + { + executorEvictedResetEvent.Set(); + } + })); + + // act + // assert + var initialExecutor = await resolver.GetRequestExecutorAsync(); + + resolver.EvictRequestExecutor(); + executorEvictedResetEvent.WaitOne(1000); + + var executorAfterEviction = await resolver.GetRequestExecutorAsync(); + + Assert.NotSame(initialExecutor, executorAfterEviction); + Assert.Equal(expectedWarmups, warmups); + } + + [Fact] + public async Task Calling_GetExecutorAsync_Multiple_Times_Only_Creates_One_Executor() + { + // arrange + var resolver = new ServiceCollection() + .AddGraphQL() + .AddQueryType(d => + { + d.Field("foo").Resolve(""); + }) + .Services.BuildServiceProvider() + .GetRequiredService(); + + // act + var executor1Task = Task.Run(async () => await resolver.GetRequestExecutorAsync()); + var executor2Task = Task.Run(async () => await resolver.GetRequestExecutorAsync()); + + var executor1 = await executor1Task; + var executor2 = await executor2Task; + + // assert + Assert.Same(executor1, executor2); + } + + [Fact] + public async Task Executor_Resolution_Should_Be_Parallel() + { + // arrange + var schema1CreationResetEvent = new AutoResetEvent(false); + + var services = new ServiceCollection(); + services + .AddGraphQL("schema1") + .AddQueryType(d => + { + schema1CreationResetEvent.WaitOne(1000); + d.Field("foo").Resolve(""); + }); + services + .AddGraphQL("schema2") + .AddQueryType(d => + { + d.Field("foo").Resolve(""); + }); + var provider = services.BuildServiceProvider(); + var resolver = provider.GetRequiredService(); + + // act + var executor1Task = Task.Run(async () => await resolver.GetRequestExecutorAsync("schema1")); + var executor2Task = Task.Run(async () => await resolver.GetRequestExecutorAsync("schema2")); + + // assert + await executor2Task; + + schema1CreationResetEvent.Set(); + + await executor1Task; } } diff --git a/website/src/docs/hotchocolate/v14/migrating/migrate-from-13-to-14.md b/website/src/docs/hotchocolate/v14/migrating/migrate-from-13-to-14.md index 1bc24bacedb..66c8462fc28 100644 --- a/website/src/docs/hotchocolate/v14/migrating/migrate-from-13-to-14.md +++ b/website/src/docs/hotchocolate/v14/migrating/migrate-from-13-to-14.md @@ -252,6 +252,16 @@ ModifyRequestOptions(o => o.OnlyAllowPersistedOperations = true); ModifyRequestOptions(o => o.PersistedOperations.OnlyAllowPersistedDocuments = true); ``` +# Other changes + +## Change to `SingleOrDefaultMiddleware` + +As a side-effect of fixing [a bug](https://github.com/ChilliCream/graphql-platform/issues/5566) in the `SingleOrDefaultMiddleware`, usage of this middleware along with EF Core may result in a warning being logged, as follows: + +> The query uses a row limiting operator ('Skip'/'Take') without an 'OrderBy' operator. This may lead to unpredictable results. If the 'Distinct' operator is used after 'OrderBy', then make sure to use the 'OrderBy' operator after 'Distinct' as the ordering would otherwise get erased. + +We are looking at fixing this in a different way in the future (see [#8070](https://github.com/ChilliCream/graphql-platform/issues/8070)), but for now you can work around this by returning an `IExecutable` from your resolver by calling `AsDbContextExecutable()` on your `IQueryable` or `DbSet`, or by using `Executable.From(...)`. + # Deprecations Things that will continue to function this release, but we encourage you to move away from. diff --git a/website/src/docs/hotchocolate/v15/fetching-data/fetching-from-databases.md b/website/src/docs/hotchocolate/v15/fetching-data/fetching-from-databases.md index b6bf3601402..85bf9a554d8 100644 --- a/website/src/docs/hotchocolate/v15/fetching-data/fetching-from-databases.md +++ b/website/src/docs/hotchocolate/v15/fetching-data/fetching-from-databases.md @@ -9,6 +9,8 @@ In this section, you find a simple example on how you can fetch data from a data You can couple your business logic close to the GraphQL server, or cleanly decouple your domain layer from the GraphQL layer over abstractions. The GraphQL server only knows its schema, types and resolvers, what you do in these resolvers and what types you expose, is up to you. +