From b0b2d3d823ef2114416dbacf6fb7b2df56896cc7 Mon Sep 17 00:00:00 2001 From: tobias-tengler <45513122+tobias-tengler@users.noreply.github.com> Date: Thu, 2 Oct 2025 11:14:25 +0200 Subject: [PATCH 1/6] Change lazy initialization default for HC Core --- src/All.slnx | 2 +- ...tCoreServiceCollectionExtensions.Warmup.cs | 267 +++++++++++------- ...teAspNetCoreServiceCollectionExtensions.cs | 104 ++++--- .../Warmup/RequestExecutorWarmupService.cs | 34 ++- .../Execution/IRequestExecutorWarmupTask.cs | 6 + .../InternalServiceCollectionExtensions.cs | 1 - .../src/Execution/IRequestExecutorWarmup.cs | 18 -- .../Options/RequestExecutorOptions.cs | 21 +- .../RequestExecutorManager.Warmup.cs | 33 +-- .../src/Execution/RequestExecutorManager.cs | 21 +- .../Core/src/Execution/WarmupSchemaTask.cs | 16 -- .../Core/src/Types/IReadOnlySchemaOptions.cs | 6 + .../Core/src/Types/SchemaOptions.cs | 16 +- .../Configuration/TypeModuleTests.cs | 54 ---- .../RequestExecutorManagerTests.cs | 89 ++++-- ...FusionServerServiceCollectionExtensions.cs | 2 +- ... => FusionRequestExecutorWarmupService.cs} | 6 +- ...reFusionGatewayBuilderExtensions.Warmup.cs | 2 + .../Execution/FusionOptions.cs | 2 +- .../Execution/FusionRequestExecutorManager.cs | 15 +- .../Execution/FusionRequestOptions.cs | 11 +- .../FusionExecutorWarmupTests.cs | 65 +++++ .../FusionRequestExecutorManagerTests.cs | 3 +- 23 files changed, 460 insertions(+), 334 deletions(-) delete mode 100644 src/HotChocolate/Core/src/Execution/IRequestExecutorWarmup.cs delete mode 100644 src/HotChocolate/Core/src/Execution/WarmupSchemaTask.cs rename src/HotChocolate/Fusion-vnext/src/Fusion.AspNetCore/{RequestExecutorWarmupService.cs => FusionRequestExecutorWarmupService.cs} (85%) create mode 100644 src/HotChocolate/Fusion-vnext/test/Fusion.AspNetCore.Tests/FusionExecutorWarmupTests.cs diff --git a/src/All.slnx b/src/All.slnx index a9ba256274e..9914384e01f 100644 --- a/src/All.slnx +++ b/src/All.slnx @@ -194,8 +194,8 @@ - + diff --git a/src/HotChocolate/AspNetCore/src/AspNetCore/Extensions/HotChocolateAspNetCoreServiceCollectionExtensions.Warmup.cs b/src/HotChocolate/AspNetCore/src/AspNetCore/Extensions/HotChocolateAspNetCoreServiceCollectionExtensions.Warmup.cs index 9481414bcb1..ba1c1e5428d 100644 --- a/src/HotChocolate/AspNetCore/src/AspNetCore/Extensions/HotChocolateAspNetCoreServiceCollectionExtensions.Warmup.cs +++ b/src/HotChocolate/AspNetCore/src/AspNetCore/Extensions/HotChocolateAspNetCoreServiceCollectionExtensions.Warmup.cs @@ -1,6 +1,5 @@ -using HotChocolate.AspNetCore.Warmup; +using System.Diagnostics.CodeAnalysis; using HotChocolate.Execution.Configuration; -using HotChocolate.Execution.Internal; // ReSharper disable once CheckNamespace namespace Microsoft.Extensions.DependencyInjection; @@ -8,132 +7,186 @@ namespace Microsoft.Extensions.DependencyInjection; public static partial class HotChocolateAspNetCoreServiceCollectionExtensions { /// - /// Adds the current GraphQL configuration to the warmup background service. + /// Adds a warmup task that will be executed on each newly created request executor. /// - /// - /// The . - /// - /// - /// The warmup task that shall be executed on a new executor. - /// - /// - /// Apply warmup task after eviction and keep executor in-memory. - /// - /// - /// Skips the warmup task if set to true. - /// - /// - /// Returns the so that configuration can be chained. - /// - /// - /// The is null. - /// - public static IRequestExecutorBuilder InitializeOnStartup( + public static IRequestExecutorBuilder AddWarmupTask( this IRequestExecutorBuilder builder, - Func? warmup = null, - bool keepWarm = false, - bool skipIf = false) + Func warmupFunc) { ArgumentNullException.ThrowIfNull(builder); + ArgumentNullException.ThrowIfNull(warmupFunc); - if (!skipIf) - { - builder.Services.AddHostedService(); - builder.Services.AddSingleton(new WarmupSchemaTask(builder.Name, keepWarm, warmup)); - } - - return builder; + return builder.AddWarmupTask(new DelegateWarmupTask(warmupFunc)); } /// - /// Adds the current GraphQL configuration to the warmup background service. + /// Adds a warmup task that will be executed on each newly created request executor. /// - /// - /// The . - /// - /// - /// The . - /// - /// - /// Skips the warmup task if set to true. - /// - /// - /// Returns the so that configuration can be chained. - /// - /// - /// The is null. - /// - public static IRequestExecutorBuilder InitializeOnStartup( + public static IRequestExecutorBuilder AddWarmupTask( this IRequestExecutorBuilder builder, - RequestExecutorInitializationOptions options, - bool skipIf = false) + IRequestExecutorWarmupTask warmupTask) { ArgumentNullException.ThrowIfNull(builder); + ArgumentNullException.ThrowIfNull(warmupTask); - if (skipIf) - { - return builder; - } - - Func? warmup; - - if (options.WriteSchemaFile.Enable) - { - var schemaFileName = - options.WriteSchemaFile.FileName - ?? System.IO.Path.Combine(Environment.CurrentDirectory, "schema.graphqls"); + builder.ConfigureSchemaServices((_, sc) => sc.AddSingleton(warmupTask)); - if (options.Warmup is null) - { - warmup = async (executor, cancellationToken) - => await SchemaFileExporter.Export(schemaFileName, executor, cancellationToken); - } - else - { - warmup = async (executor, cancellationToken) => - { - await SchemaFileExporter.Export(schemaFileName, executor, cancellationToken); - await options.Warmup(executor, cancellationToken); - }; - } - } - else - { - warmup = options.Warmup; - } - - return InitializeOnStartup(builder, warmup, options.KeepWarm); + return builder; } /// - /// Exports the GraphQL schema to a file on startup or when the schema changes. + /// Adds a warmup task that will be executed on each newly created request executor. /// - /// - /// The . - /// - /// - /// The file name of the schema file. - /// - /// - /// Returns the so that configuration can be chained. - /// - /// - /// The is null. - /// - public static IRequestExecutorBuilder ExportSchemaOnStartup( - this IRequestExecutorBuilder builder, - string? schemaFileName = null) + public static IRequestExecutorBuilder AddWarmupTask<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors)] T>( + this IRequestExecutorBuilder builder) + where T : class, IRequestExecutorWarmupTask { ArgumentNullException.ThrowIfNull(builder); - return InitializeOnStartup(builder, new RequestExecutorInitializationOptions + builder.ConfigureSchemaServices( + static (_, sc) => sc.AddSingleton()); + + return builder; + } + + private sealed class DelegateWarmupTask(Func warmupFunc) + : IRequestExecutorWarmupTask + { + public bool ApplyOnlyOnStartup => false; + + public Task WarmupAsync(IRequestExecutor requestExecutor, CancellationToken cancellationToken) { - KeepWarm = true, - WriteSchemaFile = new SchemaFileInitializationOptions - { - Enable = true, - FileName = schemaFileName - } - }); + return warmupFunc.Invoke(requestExecutor, cancellationToken); + } } + + // /// + // /// Adds the current GraphQL configuration to the warmup background service. + // /// + // /// + // /// The . + // /// + // /// + // /// The warmup task that shall be executed on a new executor. + // /// + // /// + // /// Apply warmup task after eviction and keep executor in-memory. + // /// + // /// + // /// Skips the warmup task if set to true. + // /// + // /// + // /// Returns the so that configuration can be chained. + // /// + // /// + // /// The is null. + // /// + // public static IRequestExecutorBuilder InitializeOnStartup( + // this IRequestExecutorBuilder builder, + // Func? warmup = null, + // bool keepWarm = false, + // bool skipIf = false) + // { + // ArgumentNullException.ThrowIfNull(builder); + // + // if (!skipIf) + // { + // builder.Services.AddHostedService(); + // builder.Services.AddSingleton(new WarmupSchemaTask(builder.Name, keepWarm, warmup)); + // } + // + // return builder; + // } + // + // /// + // /// Adds the current GraphQL configuration to the warmup background service. + // /// + // /// + // /// The . + // /// + // /// + // /// The . + // /// + // /// + // /// Skips the warmup task if set to true. + // /// + // /// + // /// Returns the so that configuration can be chained. + // /// + // /// + // /// The is null. + // /// + // public static IRequestExecutorBuilder InitializeOnStartup( + // this IRequestExecutorBuilder builder, + // RequestExecutorInitializationOptions options, + // bool skipIf = false) + // { + // ArgumentNullException.ThrowIfNull(builder); + // + // if (skipIf) + // { + // return builder; + // } + // + // Func? warmup; + // + // if (options.WriteSchemaFile.Enable) + // { + // var schemaFileName = + // options.WriteSchemaFile.FileName + // ?? System.IO.Path.Combine(Environment.CurrentDirectory, "schema.graphqls"); + // + // if (options.Warmup is null) + // { + // warmup = async (executor, cancellationToken) + // => await SchemaFileExporter.Export(schemaFileName, executor, cancellationToken); + // } + // else + // { + // warmup = async (executor, cancellationToken) => + // { + // await SchemaFileExporter.Export(schemaFileName, executor, cancellationToken); + // await options.Warmup(executor, cancellationToken); + // }; + // } + // } + // else + // { + // warmup = options.Warmup; + // } + // + // return InitializeOnStartup(builder, warmup, options.KeepWarm); + // } + // + // /// + // /// Exports the GraphQL schema to a file on startup or when the schema changes. + // /// + // /// + // /// The . + // /// + // /// + // /// The file name of the schema file. + // /// + // /// + // /// Returns the so that configuration can be chained. + // /// + // /// + // /// The is null. + // /// + // public static IRequestExecutorBuilder ExportSchemaOnStartup( + // this IRequestExecutorBuilder builder, + // string? schemaFileName = null) + // { + // ArgumentNullException.ThrowIfNull(builder); + // + // return InitializeOnStartup(builder, new RequestExecutorInitializationOptions + // { + // KeepWarm = true, + // WriteSchemaFile = new SchemaFileInitializationOptions + // { + // Enable = true, + // FileName = schemaFileName + // } + // }); + // } } diff --git a/src/HotChocolate/AspNetCore/src/AspNetCore/Extensions/HotChocolateAspNetCoreServiceCollectionExtensions.cs b/src/HotChocolate/AspNetCore/src/AspNetCore/Extensions/HotChocolateAspNetCoreServiceCollectionExtensions.cs index a72fe40c7b4..f8e75f11949 100644 --- a/src/HotChocolate/AspNetCore/src/AspNetCore/Extensions/HotChocolateAspNetCoreServiceCollectionExtensions.cs +++ b/src/HotChocolate/AspNetCore/src/AspNetCore/Extensions/HotChocolateAspNetCoreServiceCollectionExtensions.cs @@ -4,6 +4,7 @@ using HotChocolate.AspNetCore.Instrumentation; using HotChocolate.AspNetCore.ParameterExpressionBuilders; using HotChocolate.AspNetCore.Parsers; +using HotChocolate.AspNetCore.Warmup; using HotChocolate.Execution.Configuration; using HotChocolate.Internal; using HotChocolate.Language; @@ -19,53 +20,6 @@ namespace Microsoft.Extensions.DependencyInjection; /// public static partial class HotChocolateAspNetCoreServiceCollectionExtensions { - private static IRequestExecutorBuilder AddGraphQLServerCore( - this IRequestExecutorBuilder builder, - int maxAllowedRequestSize = MaxAllowedRequestSize) - { - builder.ConfigureSchemaServices(s => - { - s.TryAddSingleton( - sp => DefaultHttpResponseFormatter.Create( - new HttpResponseFormatterOptions { HttpTransportVersion = HttpTransportVersion.Latest }, - sp.GetRequiredService())); - s.TryAddSingleton( - sp => new DefaultHttpRequestParser( - sp.GetRequiredService(), - sp.GetRequiredService(), - maxAllowedRequestSize, - sp.GetRequiredService())); - - s.TryAddSingleton(sp => - { - var listeners = sp.GetServices().ToArray(); - return listeners.Length switch - { - 0 => new NoopServerDiagnosticEventListener(), - 1 => listeners[0], - _ => new AggregateServerDiagnosticEventListener(listeners) - }; - }); - }); - - if (!builder.Services.IsImplementationTypeRegistered()) - { - builder.Services.AddSingleton(); - } - - if (!builder.Services.IsImplementationTypeRegistered()) - { - builder.Services.AddSingleton(); - } - - if (!builder.Services.IsImplementationTypeRegistered()) - { - builder.Services.AddSingleton(); - } - - return builder; - } - /// /// Adds a GraphQL server configuration to the DI. /// @@ -95,6 +49,7 @@ public static IRequestExecutorBuilder AddGraphQLServer( var builder = services .AddGraphQL(schemaName) .AddGraphQLServerCore(maxAllowedRequestSize) + .AddStartupInitialization() .AddDefaultHttpRequestInterceptor() .AddSubscriptionServices(); @@ -160,4 +115,59 @@ public static IRequestExecutorBuilder AddUploadType( builder.AddType(); return builder; } + + private static IRequestExecutorBuilder AddGraphQLServerCore( + this IRequestExecutorBuilder builder, + int maxAllowedRequestSize = MaxAllowedRequestSize) + { + builder.ConfigureSchemaServices(s => + { + s.TryAddSingleton( + sp => DefaultHttpResponseFormatter.Create( + new HttpResponseFormatterOptions { HttpTransportVersion = HttpTransportVersion.Latest }, + sp.GetRequiredService())); + s.TryAddSingleton( + sp => new DefaultHttpRequestParser( + sp.GetRequiredService(), + sp.GetRequiredService(), + maxAllowedRequestSize, + sp.GetRequiredService())); + + s.TryAddSingleton(sp => + { + var listeners = sp.GetServices().ToArray(); + return listeners.Length switch + { + 0 => new NoopServerDiagnosticEventListener(), + 1 => listeners[0], + _ => new AggregateServerDiagnosticEventListener(listeners) + }; + }); + }); + + if (!builder.Services.IsImplementationTypeRegistered()) + { + builder.Services.AddSingleton(); + } + + if (!builder.Services.IsImplementationTypeRegistered()) + { + builder.Services.AddSingleton(); + } + + if (!builder.Services.IsImplementationTypeRegistered()) + { + builder.Services.AddSingleton(); + } + + return builder; + } + + private static IRequestExecutorBuilder AddStartupInitialization( + this IRequestExecutorBuilder builder) + { + builder.Services.AddHostedService(); + + return builder; + } } diff --git a/src/HotChocolate/AspNetCore/src/AspNetCore/Warmup/RequestExecutorWarmupService.cs b/src/HotChocolate/AspNetCore/src/AspNetCore/Warmup/RequestExecutorWarmupService.cs index 76d5d1bdf04..6db7dd298c7 100644 --- a/src/HotChocolate/AspNetCore/src/AspNetCore/Warmup/RequestExecutorWarmupService.cs +++ b/src/HotChocolate/AspNetCore/src/AspNetCore/Warmup/RequestExecutorWarmupService.cs @@ -1,14 +1,38 @@ +using HotChocolate.Execution.Configuration; using Microsoft.Extensions.Hosting; namespace HotChocolate.AspNetCore.Warmup; internal sealed class RequestExecutorWarmupService( - IRequestExecutorWarmup executorWarmup) - : IHostedService + IRequestExecutorOptionsMonitor executorOptionsMonitor, + IRequestExecutorProvider provider) : IHostedService { public async Task StartAsync(CancellationToken cancellationToken) - => await executorWarmup.WarmupAsync(cancellationToken).ConfigureAwait(false); + { + var warmupTasks = new List(); - public Task StopAsync(CancellationToken cancellationToken) - => Task.CompletedTask; + foreach (var schemaName in provider.SchemaNames) + { + // TODO: Maybe this isn't the best approach... + var options = await executorOptionsMonitor.GetAsync(schemaName, cancellationToken); + // var setup = optionsMonitor.Get(schemaName); + // + // var requestOptions = FusionRequestExecutorManager.CreateRequestOptions(setup); + + // if (!requestOptions.LazyInitialization) + // { + // var warmupTask = WarmupAsync(schemaName, cancellationToken); + // warmupTasks.Add(warmupTask); + // } + } + + await Task.WhenAll(warmupTasks).ConfigureAwait(false); + } + + public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask; + + private async Task WarmupAsync(string schemaName, CancellationToken cancellationToken) + { + await provider.GetExecutorAsync(schemaName, cancellationToken).ConfigureAwait(false); + } } diff --git a/src/HotChocolate/Core/src/Execution.Abstractions/Execution/IRequestExecutorWarmupTask.cs b/src/HotChocolate/Core/src/Execution.Abstractions/Execution/IRequestExecutorWarmupTask.cs index 3f1c8f7dd6a..680af8f1e38 100644 --- a/src/HotChocolate/Core/src/Execution.Abstractions/Execution/IRequestExecutorWarmupTask.cs +++ b/src/HotChocolate/Core/src/Execution.Abstractions/Execution/IRequestExecutorWarmupTask.cs @@ -6,6 +6,12 @@ namespace HotChocolate.Execution; /// public interface IRequestExecutorWarmupTask { + /// + /// Specifies whether the warmup task should be only applied on startup, + /// but not subsequent request executor creations. + /// + bool ApplyOnlyOnStartup { get; } + /// /// Warms up the . /// diff --git a/src/HotChocolate/Core/src/Execution/DependencyInjection/InternalServiceCollectionExtensions.cs b/src/HotChocolate/Core/src/Execution/DependencyInjection/InternalServiceCollectionExtensions.cs index 545977840b0..967b8e660e7 100644 --- a/src/HotChocolate/Core/src/Execution/DependencyInjection/InternalServiceCollectionExtensions.cs +++ b/src/HotChocolate/Core/src/Execution/DependencyInjection/InternalServiceCollectionExtensions.cs @@ -154,7 +154,6 @@ internal static IServiceCollection TryAddRequestExecutorResolver( { 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/IRequestExecutorWarmup.cs b/src/HotChocolate/Core/src/Execution/IRequestExecutorWarmup.cs deleted file mode 100644 index 00fd7f9d68a..00000000000 --- a/src/HotChocolate/Core/src/Execution/IRequestExecutorWarmup.cs +++ /dev/null @@ -1,18 +0,0 @@ -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/Options/RequestExecutorOptions.cs b/src/HotChocolate/Core/src/Execution/Options/RequestExecutorOptions.cs index ef843b4c47e..6bd449f99b5 100644 --- a/src/HotChocolate/Core/src/Execution/Options/RequestExecutorOptions.cs +++ b/src/HotChocolate/Core/src/Execution/Options/RequestExecutorOptions.cs @@ -11,7 +11,6 @@ public class RequestExecutorOptions : IRequestExecutorOptionsAccessor { private static readonly TimeSpan s_minExecutionTimeout = TimeSpan.FromMilliseconds(100); private TimeSpan _executionTimeout; - private PersistedOperationOptions _persistedOperations = new(); /// /// Initializes a new instance of . @@ -44,12 +43,16 @@ public TimeSpan ExecutionTimeout } /// - /// - /// Gets or sets a value indicating whether the GraphQL errors - /// should be extended with exception details. - /// - /// The default value is . + /// Gets or sets whether exception details should be included for GraphQL + /// errors in the GraphQL response. + /// by default. /// + /// + /// When set to true includes the message and stack trace of exceptions + /// in the user-facing GraphQL error. + /// Since this could leak security-critical information, this option should only + /// be set to true for development purposes and not in production environments. + /// public bool IncludeExceptionDetails { get; set; } = Debugger.IsAttached; /// @@ -57,11 +60,11 @@ public TimeSpan ExecutionTimeout /// public PersistedOperationOptions PersistedOperations { - get => _persistedOperations; + get; set { ArgumentNullException.ThrowIfNull(value, nameof(PersistedOperationOptions)); - _persistedOperations = value; + field = value; } - } + } = new(); } diff --git a/src/HotChocolate/Core/src/Execution/RequestExecutorManager.Warmup.cs b/src/HotChocolate/Core/src/Execution/RequestExecutorManager.Warmup.cs index 9f7768ccc41..4fa032c723a 100644 --- a/src/HotChocolate/Core/src/Execution/RequestExecutorManager.Warmup.cs +++ b/src/HotChocolate/Core/src/Execution/RequestExecutorManager.Warmup.cs @@ -1,34 +1,25 @@ +using Microsoft.Extensions.DependencyInjection; + namespace HotChocolate.Execution; internal sealed partial class RequestExecutorManager { - private bool _initialWarmupDone; - - public async Task WarmupAsync(CancellationToken cancellationToken) + private async Task WarmupExecutorAsync( + IRequestExecutor executor, + bool isInitialCreation, + 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]; + var warmupTasks = executor.Schema.Services + .GetServices(); - for (var i = 0; i < schemasToWarmup.Length; i++) + if (!isInitialCreation) { - // next, we create an initial warmup for each schema - tasks[i] = WarmupSchemaAsync(schemasToWarmup[i], cancellationToken); + warmupTasks = warmupTasks.Where(t => !t.ApplyOnlyOnStartup); } - // last, we wait for all warmup tasks to complete. - await Task.WhenAll(tasks).ConfigureAwait(false); - - async Task WarmupSchemaAsync(string schemaName, CancellationToken cancellationToken) + foreach (var warmupTask in warmupTasks) { - // the actual warmup tasks are executed inlined into the executor creation. - await GetExecutorAsync(schemaName, cancellationToken).ConfigureAwait(false); + await warmupTask.WarmupAsync(executor, cancellationToken).ConfigureAwait(false); } } } diff --git a/src/HotChocolate/Core/src/Execution/RequestExecutorManager.cs b/src/HotChocolate/Core/src/Execution/RequestExecutorManager.cs index 2b8bb67e388..2abde2655cc 100644 --- a/src/HotChocolate/Core/src/Execution/RequestExecutorManager.cs +++ b/src/HotChocolate/Core/src/Execution/RequestExecutorManager.cs @@ -1,5 +1,4 @@ using System.Collections.Concurrent; -using System.Collections.Frozen; using System.Collections.Immutable; using System.Threading.Channels; using HotChocolate.Collections.Immutable; @@ -26,13 +25,11 @@ namespace HotChocolate.Execution; internal sealed partial class RequestExecutorManager : IRequestExecutorManager , IRequestExecutorEvents - , IRequestExecutorWarmup , IAsyncDisposable { 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(); @@ -42,7 +39,6 @@ internal sealed partial class RequestExecutorManager public RequestExecutorManager( IRequestExecutorOptionsMonitor optionsMonitor, - IEnumerable warmupSchemaTasks, IServiceProvider serviceProvider) { ArgumentNullException.ThrowIfNull(optionsMonitor); @@ -51,10 +47,6 @@ public RequestExecutorManager( _optionsMonitor = optionsMonitor; _applicationServices = serviceProvider; - _warmupTasksBySchema = warmupSchemaTasks - .GroupBy(t => t.SchemaName) - .ToFrozenDictionary(g => g.Key, g => g.ToArray()); - var executorEvictionChannel = Channel.CreateUnbounded(); _executorEvictionChannelWriter = executorEvictionChannel.Writer; @@ -196,18 +188,7 @@ await CreateSchemaServicesAsync(context, setup, typeModuleChangeMonitor, cancell await OnRequestExecutorCreatedAsync(context, executor, setup, cancellationToken) .ConfigureAwait(false); - if (_warmupTasksBySchema.TryGetValue(schemaName, out var warmupTasks)) - { - if (!isInitialCreation) - { - warmupTasks = [.. warmupTasks.Where(t => t.KeepWarm)]; - } - - foreach (var warmupTask in warmupTasks) - { - await warmupTask.ExecuteAsync(executor, cancellationToken).ConfigureAwait(false); - } - } + await WarmupExecutorAsync(executor, isInitialCreation, cancellationToken).ConfigureAwait(false); _executors[schemaName] = registeredExecutor; diff --git a/src/HotChocolate/Core/src/Execution/WarmupSchemaTask.cs b/src/HotChocolate/Core/src/Execution/WarmupSchemaTask.cs deleted file mode 100644 index ae8c8d8c990..00000000000 --- a/src/HotChocolate/Core/src/Execution/WarmupSchemaTask.cs +++ /dev/null @@ -1,16 +0,0 @@ -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/src/Types/IReadOnlySchemaOptions.cs b/src/HotChocolate/Core/src/Types/IReadOnlySchemaOptions.cs index 21ef03b88a5..72dc9051922 100644 --- a/src/HotChocolate/Core/src/Types/IReadOnlySchemaOptions.cs +++ b/src/HotChocolate/Core/src/Types/IReadOnlySchemaOptions.cs @@ -190,4 +190,10 @@ public interface IReadOnlySchemaOptions /// to the DataLoader promise cache. /// bool PublishRootFieldPagesToPromiseCache { get; } + + /// + /// Specifies that the should be constructed + /// laz + /// + bool LazyInitialization { get; } } diff --git a/src/HotChocolate/Core/src/Types/SchemaOptions.cs b/src/HotChocolate/Core/src/Types/SchemaOptions.cs index 8003e7ace51..1b387539aed 100644 --- a/src/HotChocolate/Core/src/Types/SchemaOptions.cs +++ b/src/HotChocolate/Core/src/Types/SchemaOptions.cs @@ -126,6 +126,19 @@ public FieldBindingFlags DefaultFieldBindingFlags /// public bool PublishRootFieldPagesToPromiseCache { get; set; } = true; + /// + /// Gets or sets whether the should be initialized lazily. + /// false by default. + /// + /// + /// When set to true the creation of the schema and request executor, as well as + /// the load of the Fusion configuration, is deferred until the request executor + /// is first requested. + /// This can significantly slow down and block initial requests. + /// Therefore it is recommended to not use this option for production environments. + /// + public bool LazyInitialization { get; set; } + /// /// Creates a mutable options object from a read-only options object. /// @@ -161,7 +174,8 @@ public static SchemaOptions FromOptions(IReadOnlySchemaOptions options) StripLeadingIFromInterface = options.StripLeadingIFromInterface, EnableTag = options.EnableTag, DefaultQueryDependencyInjectionScope = options.DefaultQueryDependencyInjectionScope, - DefaultMutationDependencyInjectionScope = options.DefaultMutationDependencyInjectionScope + DefaultMutationDependencyInjectionScope = options.DefaultMutationDependencyInjectionScope, + LazyInitialization = options.LazyInitialization }; } } diff --git a/src/HotChocolate/Core/test/Execution.Tests/Configuration/TypeModuleTests.cs b/src/HotChocolate/Core/test/Execution.Tests/Configuration/TypeModuleTests.cs index cdd6f57d998..d6061c3eadf 100644 --- a/src/HotChocolate/Core/test/Execution.Tests/Configuration/TypeModuleTests.cs +++ b/src/HotChocolate/Core/test/Execution.Tests/Configuration/TypeModuleTests.cs @@ -65,60 +65,6 @@ public async Task Use_Type_Module_From_Factory() .MatchSnapshotAsync(); } - [Fact] - public async Task Ensure_Warmups_Are_Triggered_An_Appropriate_Number_Of_Times() - { - // arrange - var typeModule = new TriggerableTypeModule(); - var warmups = 0; - var warmupResetEvent = new ManualResetEventSlim(false); - using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5)); - - var services = new ServiceCollection(); - services - .AddGraphQL() - .AddTypeModule(_ => typeModule) - .InitializeOnStartup( - warmup: (_, _) => - { - warmups++; - warmupResetEvent.Set(); - return Task.CompletedTask; - }, - keepWarm: true) - .AddQueryType(d => d.Field("foo").Resolve("")); - var provider = services.BuildServiceProvider(); - var warmupService = provider.GetRequiredService(); - - _ = Task.Run(async () => await warmupService.StartAsync(CancellationToken.None), cts.Token); - - await provider.GetRequiredService().GetExecutorAsync(); - - // act - // assert - warmupResetEvent.Wait(cts.Token); - warmupResetEvent.Reset(); - - Assert.Equal(1, warmups); - - typeModule.TriggerChange(); - warmupResetEvent.Wait(cts.Token); - warmupResetEvent.Reset(); - - Assert.Equal(2, warmups); - - typeModule.TriggerChange(); - warmupResetEvent.Wait(cts.Token); - warmupResetEvent.Reset(); - - Assert.Equal(3, warmups); - } - - private sealed class TriggerableTypeModule : TypeModule - { - public void TriggerChange() => OnTypesChanged(); - } - public class DummyTypeModule : ITypeModule { #pragma warning disable CS0067 diff --git a/src/HotChocolate/Core/test/Execution.Tests/RequestExecutorManagerTests.cs b/src/HotChocolate/Core/test/Execution.Tests/RequestExecutorManagerTests.cs index ea6ac97f48c..ffc52943f69 100644 --- a/src/HotChocolate/Core/test/Execution.Tests/RequestExecutorManagerTests.cs +++ b/src/HotChocolate/Core/test/Execution.Tests/RequestExecutorManagerTests.cs @@ -1,6 +1,8 @@ using HotChocolate.Execution.Caching; +using HotChocolate.Execution.Configuration; using HotChocolate.Types; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; namespace HotChocolate.Execution; @@ -73,14 +75,12 @@ public async Task Executor_Should_Only_Be_Switched_Once_It_Is_Warmed_Up() var manager = new ServiceCollection() .AddGraphQL() - .InitializeOnStartup( - warmup: (_, _) => - { - warmupResetEvent.Wait(cts.Token); - - return Task.CompletedTask; - }, - keepWarm: true) + .AddWarmupTask((_, _) => + { + warmupResetEvent.Wait(cts.Token); + + return Task.CompletedTask; + }) .AddQueryType(d => d.Field("foo").Resolve("")) .Services.BuildServiceProvider() .GetRequiredService(); @@ -113,11 +113,8 @@ public async Task Executor_Should_Only_Be_Switched_Once_It_Is_Warmed_Up() cts.Dispose(); } - [Theory] - [InlineData(false, 1)] - [InlineData(true, 2)] - public async Task WarmupSchemaTasks_Are_Applied_Correct_Number_Of_Times( - bool keepWarm, int expectedWarmups) + [Fact] + public async Task WarmupTasks_Are_Applied_Correct_Number_Of_Times() { // arrange var warmups = 0; @@ -126,13 +123,11 @@ public async Task WarmupSchemaTasks_Are_Applied_Correct_Number_Of_Times( var manager = new ServiceCollection() .AddGraphQL() - .InitializeOnStartup( - warmup: (_, _) => - { - warmups++; - return Task.CompletedTask; - }, - keepWarm: keepWarm) + .AddWarmupTask((_, _) => + { + warmups++; + return Task.CompletedTask; + }) .AddQueryType(d => d.Field("foo").Resolve("")) .Services.BuildServiceProvider() .GetRequiredService(); @@ -149,13 +144,15 @@ public async Task WarmupSchemaTasks_Are_Applied_Correct_Number_Of_Times( // assert var initialExecutor = await manager.GetExecutorAsync(cancellationToken: cts.Token); + Assert.Equal(1, warmups); + manager.EvictExecutor(); executorEvictedResetEvent.Wait(cts.Token); var executorAfterEviction = await manager.GetExecutorAsync(cancellationToken: cts.Token); Assert.NotSame(initialExecutor, executorAfterEviction); - Assert.Equal(expectedWarmups, warmups); + Assert.Equal(2, warmups); } [Fact] @@ -213,4 +210,54 @@ public async Task Executor_Resolution_Should_Be_Parallel() cts.Dispose(); } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task Ensure_Executor_Is_Created_During_Startup(bool lazyInitialization) + { + // arrange + var typeModule = new TriggerableTypeModule(); + var executorCreated = false; + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5)); + var createdResetEvent = new ManualResetEventSlim(false); + + var services = new ServiceCollection(); + services + .AddGraphQLServer() + .ModifyOptions(o => o.LazyInitialization = lazyInitialization) + .AddTypeModule(_ => typeModule) + .AddQueryType(d => d.Field("foo").Resolve("")); + var provider = services.BuildServiceProvider(); + var executorManager = provider.GetRequiredService(); + var warmupService = provider.GetRequiredService(); + + executorManager.Subscribe(new RequestExecutorEventObserver(@event => + { + if (@event.Type == RequestExecutorEventType.Created) + { + executorCreated = true; + createdResetEvent.Set(); + } + })); + + // act + await warmupService.StartAsync(cts.Token); + + // assert + if (lazyInitialization) + { + Assert.False(executorCreated); + } + else + { + createdResetEvent.Wait(cts.Token); + Assert.True(executorCreated); + } + } + + private sealed class TriggerableTypeModule : TypeModule + { + public void TriggerChange() => OnTypesChanged(); + } } diff --git a/src/HotChocolate/Fusion-vnext/src/Fusion.AspNetCore/DependencyInjection/FusionServerServiceCollectionExtensions.cs b/src/HotChocolate/Fusion-vnext/src/Fusion.AspNetCore/DependencyInjection/FusionServerServiceCollectionExtensions.cs index fb1accae44c..4833549fb9d 100644 --- a/src/HotChocolate/Fusion-vnext/src/Fusion.AspNetCore/DependencyInjection/FusionServerServiceCollectionExtensions.cs +++ b/src/HotChocolate/Fusion-vnext/src/Fusion.AspNetCore/DependencyInjection/FusionServerServiceCollectionExtensions.cs @@ -69,7 +69,7 @@ private static IFusionGatewayBuilder AddGraphQLGatewayServerCore( private static IFusionGatewayBuilder AddStartupInitialization( this IFusionGatewayBuilder builder) { - builder.Services.AddHostedService(); + builder.Services.AddHostedService(); return builder; } diff --git a/src/HotChocolate/Fusion-vnext/src/Fusion.AspNetCore/RequestExecutorWarmupService.cs b/src/HotChocolate/Fusion-vnext/src/Fusion.AspNetCore/FusionRequestExecutorWarmupService.cs similarity index 85% rename from src/HotChocolate/Fusion-vnext/src/Fusion.AspNetCore/RequestExecutorWarmupService.cs rename to src/HotChocolate/Fusion-vnext/src/Fusion.AspNetCore/FusionRequestExecutorWarmupService.cs index 03ee44722ab..921776482c4 100644 --- a/src/HotChocolate/Fusion-vnext/src/Fusion.AspNetCore/RequestExecutorWarmupService.cs +++ b/src/HotChocolate/Fusion-vnext/src/Fusion.AspNetCore/FusionRequestExecutorWarmupService.cs @@ -6,7 +6,7 @@ namespace Microsoft.Extensions.DependencyInjection; -internal sealed class RequestExecutorWarmupService( +internal sealed class FusionRequestExecutorWarmupService( IOptionsMonitor optionsMonitor, IRequestExecutorProvider provider) : IHostedService { @@ -18,9 +18,9 @@ public async Task StartAsync(CancellationToken cancellationToken) { var setup = optionsMonitor.Get(schemaName); - var requestOptions = FusionRequestExecutorManager.CreateOptions(setup); + var options = FusionRequestExecutorManager.CreateOptions(setup); - if (!requestOptions.LazyInitialization) + if (!options.LazyInitialization) { var warmupTask = WarmupAsync(schemaName, cancellationToken); warmupTasks.Add(warmupTask); diff --git a/src/HotChocolate/Fusion-vnext/src/Fusion.Execution/DependencyInjection/CoreFusionGatewayBuilderExtensions.Warmup.cs b/src/HotChocolate/Fusion-vnext/src/Fusion.Execution/DependencyInjection/CoreFusionGatewayBuilderExtensions.Warmup.cs index 17ddf0f25a5..867696c6b6b 100644 --- a/src/HotChocolate/Fusion-vnext/src/Fusion.Execution/DependencyInjection/CoreFusionGatewayBuilderExtensions.Warmup.cs +++ b/src/HotChocolate/Fusion-vnext/src/Fusion.Execution/DependencyInjection/CoreFusionGatewayBuilderExtensions.Warmup.cs @@ -52,6 +52,8 @@ public static IFusionGatewayBuilder AddWarmupTask( private sealed class DelegateWarmupTask(Func warmupFunc) : IRequestExecutorWarmupTask { + public bool ApplyOnlyOnStartup => false; + public Task WarmupAsync(IRequestExecutor requestExecutor, CancellationToken cancellationToken) { return warmupFunc.Invoke(requestExecutor, cancellationToken); diff --git a/src/HotChocolate/Fusion-vnext/src/Fusion.Execution/Execution/FusionOptions.cs b/src/HotChocolate/Fusion-vnext/src/Fusion.Execution/Execution/FusionOptions.cs index 831dc473966..7a329b19714 100644 --- a/src/HotChocolate/Fusion-vnext/src/Fusion.Execution/Execution/FusionOptions.cs +++ b/src/HotChocolate/Fusion-vnext/src/Fusion.Execution/Execution/FusionOptions.cs @@ -92,7 +92,7 @@ public ErrorHandlingMode DefaultErrorHandlingMode /// false by default. /// /// - /// When set to false the creation of the schema and request executor, as well as + /// When set to true the creation of the schema and request executor, as well as /// the load of the Fusion configuration, is deferred until the request executor /// is first requested. /// This can significantly slow down and block initial requests. diff --git a/src/HotChocolate/Fusion-vnext/src/Fusion.Execution/Execution/FusionRequestExecutorManager.cs b/src/HotChocolate/Fusion-vnext/src/Fusion.Execution/Execution/FusionRequestExecutorManager.cs index d573f60a7b8..4b8ad1b3afc 100644 --- a/src/HotChocolate/Fusion-vnext/src/Fusion.Execution/Execution/FusionRequestExecutorManager.cs +++ b/src/HotChocolate/Fusion-vnext/src/Fusion.Execution/Execution/FusionRequestExecutorManager.cs @@ -148,7 +148,7 @@ private async ValueTask CreateInitialRegistrationAs var executor = CreateRequestExecutor(schemaName, configuration); - await WarmupExecutorAsync(executor, cancellationToken).ConfigureAwait(false); + await WarmupExecutorAsync(executor, true, cancellationToken).ConfigureAwait(false); return new RequestExecutorRegistration( this, @@ -186,9 +186,18 @@ private FusionRequestExecutor CreateRequestExecutor( return executor; } - private async Task WarmupExecutorAsync(IRequestExecutor executor, CancellationToken cancellationToken) + private async Task WarmupExecutorAsync( + IRequestExecutor executor, + bool isInitialCreation, + CancellationToken cancellationToken) { var warmupTasks = executor.Schema.Services.GetServices(); + + if (!isInitialCreation) + { + warmupTasks = warmupTasks.Where(t => !t.ApplyOnlyOnStartup); + } + foreach (var warmupTask in warmupTasks) { await warmupTask.WarmupAsync(executor, cancellationToken).ConfigureAwait(false); @@ -635,7 +644,7 @@ private async Task WaitForUpdatesAsync() var previousExecutor = Executor; var nextExecutor = _manager.CreateRequestExecutor(Executor.Schema.Name, configuration); - await _manager.WarmupExecutorAsync(nextExecutor, _cancellationToken).ConfigureAwait(false); + await _manager.WarmupExecutorAsync(nextExecutor, false, _cancellationToken).ConfigureAwait(false); Executor = nextExecutor; diff --git a/src/HotChocolate/Fusion-vnext/src/Fusion.Execution/Execution/FusionRequestOptions.cs b/src/HotChocolate/Fusion-vnext/src/Fusion.Execution/Execution/FusionRequestOptions.cs index 99a23294808..31526260863 100644 --- a/src/HotChocolate/Fusion-vnext/src/Fusion.Execution/Execution/FusionRequestOptions.cs +++ b/src/HotChocolate/Fusion-vnext/src/Fusion.Execution/Execution/FusionRequestOptions.cs @@ -1,3 +1,4 @@ +using System.Diagnostics; using HotChocolate.Caching.Memory; using HotChocolate.Execution.Relay; using HotChocolate.Language; @@ -78,11 +79,13 @@ public PersistedOperationOptions PersistedOperations /// /// Gets or sets whether exception details should be included for GraphQL /// errors in the GraphQL response. - /// false by default. + /// by default. /// /// - /// This should only be enabled for development purposes - /// and not in production environments. + /// When set to true includes the message and stack trace of exceptions + /// in the user-facing GraphQL error. + /// Since this could leak security-critical information, this option should only + /// be set to true for development purposes and not in production environments. /// public bool IncludeExceptionDetails { @@ -93,7 +96,7 @@ public bool IncludeExceptionDetails field = value; } - } + } = Debugger.IsAttached; /// /// Clones the request options into a new mutable instance. diff --git a/src/HotChocolate/Fusion-vnext/test/Fusion.AspNetCore.Tests/FusionExecutorWarmupTests.cs b/src/HotChocolate/Fusion-vnext/test/Fusion.AspNetCore.Tests/FusionExecutorWarmupTests.cs new file mode 100644 index 00000000000..499c262a0d8 --- /dev/null +++ b/src/HotChocolate/Fusion-vnext/test/Fusion.AspNetCore.Tests/FusionExecutorWarmupTests.cs @@ -0,0 +1,65 @@ +using HotChocolate.Execution; +using HotChocolate.Fusion.Execution; +using HotChocolate.Types.Mutable.Serialization; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; + +namespace HotChocolate.Fusion; + +public sealed class FusionExecutorWarmupTests : FusionTestBase +{ + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task Ensure_Executor_Is_Created_During_Startup(bool lazyInitialization) + { + // arrange + var executorCreated = false; + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5)); + var createdResetEvent = new ManualResetEventSlim(false); + + var schema = SchemaParser.Parse( + """ + type Query @fusion__type(schema: A) { + field: String! @fusion__field(schema: A) + } + + enum fusion__Schema { + A + } + """).ToSyntaxNode(); + + var services = new ServiceCollection(); + services + .AddGraphQLGatewayServer() + .AddInMemoryConfiguration(schema) + .ModifyOptions(o => o.LazyInitialization = lazyInitialization); + var provider = services.BuildServiceProvider(); + + var executorManager = provider.GetRequiredService(); + var warmupService = provider.GetRequiredService(); + + executorManager.Subscribe(new RequestExecutorEventObserver(@event => + { + if (@event.Type == RequestExecutorEventType.Created) + { + executorCreated = true; + createdResetEvent.Set(); + } + })); + + // act + await warmupService.StartAsync(cts.Token); + + // assert + if (lazyInitialization) + { + Assert.False(executorCreated); + } + else + { + createdResetEvent.Wait(cts.Token); + Assert.True(executorCreated); + } + } +} diff --git a/src/HotChocolate/Fusion-vnext/test/Fusion.Execution.Tests/Execution/FusionRequestExecutorManagerTests.cs b/src/HotChocolate/Fusion-vnext/test/Fusion.Execution.Tests/Execution/FusionRequestExecutorManagerTests.cs index 1e16798a66d..0fa171c9750 100644 --- a/src/HotChocolate/Fusion-vnext/test/Fusion.Execution.Tests/Execution/FusionRequestExecutorManagerTests.cs +++ b/src/HotChocolate/Fusion-vnext/test/Fusion.Execution.Tests/Execution/FusionRequestExecutorManagerTests.cs @@ -7,6 +7,7 @@ using HotChocolate.Fusion.Execution.Nodes; using HotChocolate.Fusion.Execution.Pipeline; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; namespace HotChocolate.Fusion.Execution; @@ -261,7 +262,7 @@ type Query { } [Fact] - public async Task WarmupSchemaTasks_Are_Applied_Correct_Number_Of_Times() + public async Task WarmupTasks_Are_Applied_Correct_Number_Of_Times() { // arrange var warmups = 0; From 3539c637b11c2ff409a6face1291f67b70d2216e Mon Sep 17 00:00:00 2001 From: tobias-tengler <45513122+tobias-tengler@users.noreply.github.com> Date: Fri, 3 Oct 2025 15:05:32 +0200 Subject: [PATCH 2/6] Pull out SchemaOptions configuration for easier access --- .../Warmup/RequestExecutorWarmupService.cs | 17 +++++++---------- .../Configuration/RequestExecutorSetup.cs | 11 +++++------ .../SchemaRequestExecutorBuilderExtensions.cs | 2 +- .../src/Execution/RequestExecutorManager.cs | 17 ++++++++++++++++- .../Core/src/Types/SchemaBuilder.cs | 10 +++++++--- 5 files changed, 36 insertions(+), 21 deletions(-) diff --git a/src/HotChocolate/AspNetCore/src/AspNetCore/Warmup/RequestExecutorWarmupService.cs b/src/HotChocolate/AspNetCore/src/AspNetCore/Warmup/RequestExecutorWarmupService.cs index 6db7dd298c7..63b0134adef 100644 --- a/src/HotChocolate/AspNetCore/src/AspNetCore/Warmup/RequestExecutorWarmupService.cs +++ b/src/HotChocolate/AspNetCore/src/AspNetCore/Warmup/RequestExecutorWarmupService.cs @@ -13,17 +13,14 @@ public async Task StartAsync(CancellationToken cancellationToken) foreach (var schemaName in provider.SchemaNames) { - // TODO: Maybe this isn't the best approach... - var options = await executorOptionsMonitor.GetAsync(schemaName, cancellationToken); - // var setup = optionsMonitor.Get(schemaName); - // - // var requestOptions = FusionRequestExecutorManager.CreateRequestOptions(setup); + var setup = await executorOptionsMonitor.GetAsync(schemaName, cancellationToken); + var options = RequestExecutorManager.CreateSchemaOptions(setup); - // if (!requestOptions.LazyInitialization) - // { - // var warmupTask = WarmupAsync(schemaName, cancellationToken); - // warmupTasks.Add(warmupTask); - // } + if (!options.LazyInitialization) + { + var warmupTask = WarmupAsync(schemaName, cancellationToken); + warmupTasks.Add(warmupTask); + } } await Task.WhenAll(warmupTasks).ConfigureAwait(false); diff --git a/src/HotChocolate/Core/src/Execution/Configuration/RequestExecutorSetup.cs b/src/HotChocolate/Core/src/Execution/Configuration/RequestExecutorSetup.cs index cfc7c9863db..3aa278c389e 100644 --- a/src/HotChocolate/Core/src/Execution/Configuration/RequestExecutorSetup.cs +++ b/src/HotChocolate/Core/src/Execution/Configuration/RequestExecutorSetup.cs @@ -12,6 +12,7 @@ public sealed class RequestExecutorSetup private readonly List _onConfigureRequestExecutorOptionsHooks = []; private readonly List _pipeline = []; private readonly List>> _pipelineModifiers = []; + private readonly List> _schemaOptionModifiers = []; private readonly List _onConfigureSchemaServicesHooks = []; private readonly List _onRequestExecutorCreatedHooks = []; private readonly List _onRequestExecutorEvictedHooks = []; @@ -23,11 +24,6 @@ public sealed class RequestExecutorSetup /// public Schema? Schema { get; set; } - /// - /// Gets or sets the schema builder that is used to create the schema. - /// - public ISchemaBuilder? SchemaBuilder { get; set; } - /// /// Gets or sets the request executor options. /// @@ -97,6 +93,9 @@ public IList Pipeline public IList>> PipelineModifiers => _pipelineModifiers; + public IList> SchemaOptionModifiers + => _schemaOptionModifiers; + /// /// Gets or sets the default pipeline factory. /// @@ -111,7 +110,6 @@ public IList>> PipelineModifiers public void CopyTo(RequestExecutorSetup options) { options.Schema = Schema; - options.SchemaBuilder = SchemaBuilder; options.RequestExecutorOptions = RequestExecutorOptions; options._onConfigureSchemaBuilderHooks.AddRange(_onConfigureSchemaBuilderHooks); options._onConfigureRequestExecutorOptionsHooks.AddRange(_onConfigureRequestExecutorOptionsHooks); @@ -121,6 +119,7 @@ public void CopyTo(RequestExecutorSetup options) options._onRequestExecutorEvictedHooks.AddRange(_onRequestExecutorEvictedHooks); options._onBuildDocumentValidatorHooks.AddRange(_onBuildDocumentValidatorHooks); options._pipelineModifiers.AddRange(_pipelineModifiers); + options._schemaOptionModifiers.AddRange(_schemaOptionModifiers); options._typeModules.AddRange(_typeModules); options.EvictionTimeout = EvictionTimeout; diff --git a/src/HotChocolate/Core/src/Execution/DependencyInjection/SchemaRequestExecutorBuilderExtensions.cs b/src/HotChocolate/Core/src/Execution/DependencyInjection/SchemaRequestExecutorBuilderExtensions.cs index 937c0096347..307ab73ea92 100644 --- a/src/HotChocolate/Core/src/Execution/DependencyInjection/SchemaRequestExecutorBuilderExtensions.cs +++ b/src/HotChocolate/Core/src/Execution/DependencyInjection/SchemaRequestExecutorBuilderExtensions.cs @@ -28,7 +28,7 @@ public static IRequestExecutorBuilder ModifyOptions( ArgumentNullException.ThrowIfNull(builder); ArgumentNullException.ThrowIfNull(configure); - return builder.ConfigureSchema(b => b.ModifyOptions(configure)); + return builder.Configure(setup => setup.SchemaOptionModifiers.Add(configure)); } /// diff --git a/src/HotChocolate/Core/src/Execution/RequestExecutorManager.cs b/src/HotChocolate/Core/src/Execution/RequestExecutorManager.cs index 2abde2655cc..ce817f0b21e 100644 --- a/src/HotChocolate/Core/src/Execution/RequestExecutorManager.cs +++ b/src/HotChocolate/Core/src/Execution/RequestExecutorManager.cs @@ -155,9 +155,12 @@ private async Task CreateRequestExecutorAsync( await _optionsMonitor.GetAsync(schemaName, cancellationToken) .ConfigureAwait(false); + var options = CreateSchemaOptions(setup); + var schemaBuilder = SchemaBuilder.New(options); + var context = new ConfigurationContext( schemaName, - setup.SchemaBuilder ?? SchemaBuilder.New(), + schemaBuilder, _applicationServices); var typeModuleChangeMonitor = new TypeModuleChangeMonitor(this, context.SchemaName); @@ -246,6 +249,18 @@ private static async Task RunEvictionEvents(RegisteredExecutor registeredExecuto } } + internal static SchemaOptions CreateSchemaOptions(RequestExecutorSetup setup) + { + var options = new SchemaOptions(); + + foreach (var configure in setup.SchemaOptionModifiers) + { + configure(options); + } + + return options; + } + private async Task CreateSchemaServicesAsync( ConfigurationContext context, RequestExecutorSetup setup, diff --git a/src/HotChocolate/Core/src/Types/SchemaBuilder.cs b/src/HotChocolate/Core/src/Types/SchemaBuilder.cs index e0ea515ed18..f835cf902ae 100644 --- a/src/HotChocolate/Core/src/Types/SchemaBuilder.cs +++ b/src/HotChocolate/Core/src/Types/SchemaBuilder.cs @@ -22,13 +22,15 @@ public partial class SchemaBuilder : ISchemaBuilder private readonly List _types = []; private readonly Dictionary _operations = []; - private readonly SchemaOptions _options = new(); + private readonly SchemaOptions _options; private IsOfTypeFallback? _isOfType; private IServiceProvider? _services; private CreateRef? _schema; - private SchemaBuilder() + private SchemaBuilder(SchemaOptions options) { + _options = options; + var typeInterceptors = new TypeInterceptorCollection(); typeInterceptors.TryAdd(new IntrospectionTypeInterceptor()); @@ -238,5 +240,7 @@ public ISchemaBuilder AddServices(IServiceProvider services) /// /// Returns a new instance of . /// - public static SchemaBuilder New() => new(); + public static SchemaBuilder New() => new(new SchemaOptions()); + + internal static SchemaBuilder New(SchemaOptions options) => new(options); } From 5e9581f4833c15d20de1c80157d4eb8f8e4f7448 Mon Sep 17 00:00:00 2001 From: tobias-tengler <45513122+tobias-tengler@users.noreply.github.com> Date: Fri, 3 Oct 2025 15:49:24 +0200 Subject: [PATCH 3/6] Update migration guide --- .../v16/migrating/migrate-from-15-to-16.md | 24 +++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/website/src/docs/hotchocolate/v16/migrating/migrate-from-15-to-16.md b/website/src/docs/hotchocolate/v16/migrating/migrate-from-15-to-16.md index a49613ee00a..1264b25f0cf 100644 --- a/website/src/docs/hotchocolate/v16/migrating/migrate-from-15-to-16.md +++ b/website/src/docs/hotchocolate/v16/migrating/migrate-from-15-to-16.md @@ -12,6 +12,30 @@ Start by installing the latest `16.x.x` version of **all** of the `HotChocolate. Things that have been removed or had a change in behavior that may cause your code not to compile or lead to unexpected behavior at runtime if not addressed. +## Eager initialization by default + +Previously, Hot Chocolate would only construct the schema and request executor upon the first request. This deferred initialization could create a performance penalty on initial requests and delayed the discovery of schema errors until runtime. + +To address this, we previously offered an `InitializeOnStartup` helper that would initialize the schema and request executor in a blocking hosted service during startup. This ensured everything GraphQL-related was ready before Kestrel began accepting requests. + +Since we believe eager initialization is the right default, it's now the standard behavior. This means your schema and request executor are constructed during application startup, before your server begins accepting traffic. +As a bonus, this tightens your development loop, since schema errors surface immediately when you start debugging rather than only appearing when you send your first request. + +If you're currently using `InitializeOnStartup`, you can safely remove it. If you also provided the `warmup` argument to run a task during the initialization, you can migrate that task to the new `AddWarmupTask` API: + +```diff +builder.Services.AddGraphQLServer() +- .InitializeOnStartup(warmup: (executor, ct) => { /* ... */ }); ++ .AddWarmupTask((executor, ct) => { /* ... */ }); +``` + +If you need to preserve lazy initialization for specific scenarios (though this is rarely recommended), you can opt out by setting the `LazyInitialization` option to `true`: + +```csharp +builder.Services.AddGraphQLServer() + .ModifyOptions(options => options.LazyInitialization = true); +``` + ## MaxAllowedNodeBatchSize & EnsureAllNodesCanBeResolved options moved **Before** From 414ec3d43aaa220291de11ce3c393efdbe5a082e Mon Sep 17 00:00:00 2001 From: tobias-tengler <45513122+tobias-tengler@users.noreply.github.com> Date: Fri, 3 Oct 2025 16:18:21 +0200 Subject: [PATCH 4/6] Update warmup.md --- .../v16/migrating/migrate-from-15-to-16.md | 2 + .../docs/hotchocolate/v16/server/warmup.md | 92 +++++++++++-------- 2 files changed, 54 insertions(+), 40 deletions(-) diff --git a/website/src/docs/hotchocolate/v16/migrating/migrate-from-15-to-16.md b/website/src/docs/hotchocolate/v16/migrating/migrate-from-15-to-16.md index 1264b25f0cf..d4f6a1e7962 100644 --- a/website/src/docs/hotchocolate/v16/migrating/migrate-from-15-to-16.md +++ b/website/src/docs/hotchocolate/v16/migrating/migrate-from-15-to-16.md @@ -29,6 +29,8 @@ builder.Services.AddGraphQLServer() + .AddWarmupTask((executor, ct) => { /* ... */ }); ``` +Warmup tasks registered with `AddWarmupTask` run at startup **and** when the schema is updated at runtime by default. Checkout the [documentation](/docs/hotchocolate/v16/server/warmup), if you need your warmup task to only run at startup. + If you need to preserve lazy initialization for specific scenarios (though this is rarely recommended), you can opt out by setting the `LazyInitialization` option to `true`: ```csharp diff --git a/website/src/docs/hotchocolate/v16/server/warmup.md b/website/src/docs/hotchocolate/v16/server/warmup.md index d1ad547e7a6..5afe053cef7 100644 --- a/website/src/docs/hotchocolate/v16/server/warmup.md +++ b/website/src/docs/hotchocolate/v16/server/warmup.md @@ -2,57 +2,45 @@ title: Warmup --- -By default the creation of Hot Chocolate's schema is lazy. If a request is about to be executed against the schema or the schema is otherwise needed, it will be constructed on the fly. +By default, Hot Chocolate constructs the schema eagerly during server startup. This means the schema and request executor are fully initialized before Kestrel begins accepting requests, ensuring initial requests perform optimally without any cold-start penalty. -Depending on the size of your schema this might be undesired, since it will cause initial requests to run longer than they would, if the schema was already constructed. +This eager initialization also tightens the development feedback loop as schema misconfigurations will cause errors at startup rather than when the first request arrives. -In an environment with a load balancer, you might also want to utilize something like a Readiness Probe to determine when your server is ready (meaning fully initialized) to handle requests. - -# Initializing the schema on startup - -If you want the schema creation process to happen at server startup, rather than lazily, you can chain in a call to `InitializeOnStartup()` on the `IRequestExecutorBuilder`. - -```csharp -builder.Services - .AddGraphQLServer() - .InitializeOnStartup() -``` - -This will cause a hosted service to be executed as part of the server startup process, taking care of the schema creation. This process is blocking, meaning Kestrel won't answer requests until the construction of the schema is done. If you're using standard ASP.NET Core health checks, this will already suffice to implement a simple Readiness Probe. - -This also has the added benefit that schema misconfigurations will cause errors at startup, tightening the feedback loop while developing. +In environments with load balancers, this default behavior works seamlessly with health checks and Readiness Probes, since your server won't report as ready until the schema is fully constructed. # Warming up the executor -Creating the schema at startup is already a big win for the performance of initial requests. Though, you might want to go one step further and already initialize in-memory caches like the document and operation cache, before serving any requests. +While eager initialization ensures your schema is ready at startup, you might want to go further and pre-populate in-memory caches like the document and operation cache before serving any requests. -For this the `InitializeOnStartup()` method contains an argument called `warmup` that allows you to pass a callback where you can execute requests against the newly created schema. +You can add warmup tasks using the `AddWarmupTask()` method, which allows you to execute requests against the newly created schema during initialization: ```csharp builder.Services .AddGraphQLServer() - .InitializeOnStartup( - warmup: async (executor, cancellationToken) => { - await executor.ExecuteAsync("{ __typename }"); - }); + .AddWarmupTask(async (executor, cancellationToken) => + { + await executor.ExecuteAsync("{ __typename }", cancellationToken); + }); ``` -The warmup process is also blocking, meaning the server won't start answering requests until both the schema creation and the warmup process is finished. +The warmup process is blocking, meaning the server won't start answering requests until both the schema creation and all warmup tasks have finished. + +By default, warmup tasks run both at server startup and whenever the schema is rebuilt at runtime (for example, when using [dynamic schemas](/docs/hotchocolate/v16/defining-a-schema/dynamic-schemas)). When the request executor changes, warmup tasks execute in the background while requests continue to be handled by the old request executor. Once warmup is complete, requests will be served by the new and already warmed-up request executor. -Since the execution of an operation could have side-effects, you might want to only warmup the executor, but skip the actual execution of the request. For this you can mark an operation as a warmup request. +Since the execution of an operation could have side-effects, you might want to only warm up the executor but skip the actual execution of the request. For this you can mark an operation as a warmup request: ```csharp var request = OperationRequestBuilder.New() - .SetDocument("{ __typename }") - .MarkAsWarmupRequest() - .Build(); + .SetDocument("{ __typename }") + .MarkAsWarmupRequest() + .Build(); -await executor.ExecuteAsync(request); +await executor.ExecuteAsync(request, cancellationToken); ``` Requests marked as warmup requests will be able to skip security measures like persisted operations and will finish without actually executing the specified operation. -Keep in mind that the operation name is part of the operation cache. If your client is sending an operation name, you also want to include that operation name in the warmup request, or the actual request will miss the cache. +Keep in mind that the operation name is part of the operation cache. If your client is sending an operation name, you also want to include that operation name in the warmup request, or the actual request will miss the cache: ```csharp var request = OperationRequestBuilder.New() @@ -60,18 +48,20 @@ var request = OperationRequestBuilder.New() .SetOperationName("testQuery") .MarkAsWarmupRequest() .Build(); + +await executor.ExecuteAsync(request, cancellationToken); ``` ## Skipping reporting -If you've implemented a custom diagnostic event listener as described [here](/docs/hotchocolate/v16/server/instrumentation#execution-events) you might want to skip reporting certain events in the case of a warmup request. +If you've set up [instrumentation](/docs/hotchocolate/v16/server/instrumentation), you might want to skip reporting certain events in the case of a warmup request. -You can use the `IRequestContext.IsWarmupRequest()` method to determine whether a request is a warmup request or not. +You can use the `RequestContext.IsWarmupRequest()` method to determine whether a request is a warmup request or not: ```csharp public class MyExecutionEventListener : ExecutionDiagnosticEventListener { - public override void RequestError(IRequestContext context, + public override void RequestError(RequestContext context, Exception exception) { if (context.IsWarmupRequest()) @@ -82,20 +72,42 @@ public class MyExecutionEventListener : ExecutionDiagnosticEventListener // Reporting } } +``` + +## Custom warmup tasks + +For more control over warmup behavior, you can implement the `IRequestExecutorWarmupTask` interface: + +```csharp +builder.Services + .AddGraphQLServer() + .AddWarmupTask(); +public class MyWarmupTask : IRequestExecutorWarmupTask +{ + public bool ApplyOnlyOnStartup => false; + + public async Task WarmupAsync( + IRequestExecutor executor, + CancellationToken cancellationToken) + { + // Your warmup logic here + await executor.ExecuteAsync("{ __typename }", cancellationToken); + } +} ``` -## Keeping the executor warm +The `ApplyOnlyOnStartup` property controls whether the warmup task should run only at server startup (`true`) or also when the request executor is rebuilt at runtime (`false`, the default). +Register your custom warmup task using any of these approaches: + +# Opting into lazy initialization -By default the warmup only takes place at server startup. If you're using [dynamic schemas](/docs/hotchocolate/v16/defining-a-schema/dynamic-schemas) for instance, your schema might change throughout the lifetime of the server. -In this case the warmup will not apply to subsequent schema changes, unless you set the `keepWarm` argument to `true`. +If you need to defer schema construction until the first request (though this is rarely recommended), you can opt into lazy initialization: ```csharp builder.Services .AddGraphQLServer() - .InitializeOnStartup( - keepWarm: true, - warmup: /* ... */); + .ModifyOptions(options => options.LazyInitialization = true) ``` -If set to `true`, the schema and its warmup task will be executed in the background, while requests are still handled by the old schema. Once the warmup is finished requests will be served by the new and already warmed up schema. +With lazy initialization enabled, the schema will only be constructed when it's first needed. Either when a request is executed or when the schema is otherwise accessed. Depending on the size of your schema and the configured warmup tasks, this will cause initial requests to run longer than they would with eager initialization. From b367756018e4d25f71df9453a8ac24f0bd9d9f82 Mon Sep 17 00:00:00 2001 From: tobias-tengler <45513122+tobias-tengler@users.noreply.github.com> Date: Fri, 3 Oct 2025 17:01:33 +0200 Subject: [PATCH 5/6] Re-add ExportSchemaOnStartup --- ...tCoreServiceCollectionExtensions.Warmup.cs | 238 +++++++----------- .../RequestExecutorInitializationOptions.cs | 23 -- .../Warmup/SchemaFileExporterWarmupTask.cs | 13 + .../Warmup/SchemaFileInitializationOptions.cs | 19 -- .../Execution/DelegateWarmupTask.cs | 12 + ...reFusionGatewayBuilderExtensions.Warmup.cs | 93 +++++-- 6 files changed, 195 insertions(+), 203 deletions(-) delete mode 100644 src/HotChocolate/AspNetCore/src/AspNetCore/Warmup/RequestExecutorInitializationOptions.cs create mode 100644 src/HotChocolate/AspNetCore/src/AspNetCore/Warmup/SchemaFileExporterWarmupTask.cs delete mode 100644 src/HotChocolate/AspNetCore/src/AspNetCore/Warmup/SchemaFileInitializationOptions.cs create mode 100644 src/HotChocolate/Core/src/Execution.Abstractions/Execution/DelegateWarmupTask.cs diff --git a/src/HotChocolate/AspNetCore/src/AspNetCore/Extensions/HotChocolateAspNetCoreServiceCollectionExtensions.Warmup.cs b/src/HotChocolate/AspNetCore/src/AspNetCore/Extensions/HotChocolateAspNetCoreServiceCollectionExtensions.Warmup.cs index ba1c1e5428d..3b377ad4d64 100644 --- a/src/HotChocolate/AspNetCore/src/AspNetCore/Extensions/HotChocolateAspNetCoreServiceCollectionExtensions.Warmup.cs +++ b/src/HotChocolate/AspNetCore/src/AspNetCore/Extensions/HotChocolateAspNetCoreServiceCollectionExtensions.Warmup.cs @@ -1,4 +1,5 @@ using System.Diagnostics.CodeAnalysis; +using HotChocolate.AspNetCore.Warmup; using HotChocolate.Execution.Configuration; // ReSharper disable once CheckNamespace @@ -9,184 +10,137 @@ public static partial class HotChocolateAspNetCoreServiceCollectionExtensions /// /// Adds a warmup task that will be executed on each newly created request executor. /// + /// + /// The . + /// + /// + /// The warmup delegate to execute. + /// + /// + /// If true, the warmup task will not be registered. + /// + /// + /// Returns the so that configuration can be chained. + /// + /// + /// The is null. + /// + /// + /// The is null. + /// public static IRequestExecutorBuilder AddWarmupTask( this IRequestExecutorBuilder builder, - Func warmupFunc) + Func warmupFunc, + bool skipIf = false) { ArgumentNullException.ThrowIfNull(builder); ArgumentNullException.ThrowIfNull(warmupFunc); - return builder.AddWarmupTask(new DelegateWarmupTask(warmupFunc)); + return builder.AddWarmupTask(new DelegateWarmupTask(warmupFunc), skipIf); } /// /// Adds a warmup task that will be executed on each newly created request executor. /// + /// + /// The . + /// + /// + /// The warmup task to execute. + /// + /// + /// If true, the warmup task will not be registered. + /// + /// + /// Returns the so that configuration can be chained. + /// + /// + /// The is null. + /// + /// + /// The is null. + /// public static IRequestExecutorBuilder AddWarmupTask( this IRequestExecutorBuilder builder, - IRequestExecutorWarmupTask warmupTask) + IRequestExecutorWarmupTask warmupTask, + bool skipIf = false) { ArgumentNullException.ThrowIfNull(builder); ArgumentNullException.ThrowIfNull(warmupTask); + if (skipIf) + { + return builder; + } + builder.ConfigureSchemaServices((_, sc) => sc.AddSingleton(warmupTask)); return builder; } /// - /// Adds a warmup task that will be executed on each newly created request executor. + /// Adds a warmup task for the request executor. /// + /// + /// The . + /// + /// + /// If true, the warmup task will not be registered. + /// + /// + /// The warmup task to execute. + /// + /// + /// Returns the so that configuration can be chained. + /// + /// + /// The is null. + /// public static IRequestExecutorBuilder AddWarmupTask<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors)] T>( - this IRequestExecutorBuilder builder) + this IRequestExecutorBuilder builder, + bool skipIf = false) where T : class, IRequestExecutorWarmupTask { ArgumentNullException.ThrowIfNull(builder); + if (skipIf) + { + return builder; + } + builder.ConfigureSchemaServices( static (_, sc) => sc.AddSingleton()); return builder; } - private sealed class DelegateWarmupTask(Func warmupFunc) - : IRequestExecutorWarmupTask + /// + /// Exports the GraphQL schema to a file on startup or when the schema changes. + /// + /// + /// The . + /// + /// + /// The file name of the schema file. + /// + /// + /// If true, the schema file will not be exported. + /// + /// + /// Returns the so that configuration can be chained. + /// + /// + /// The is null. + /// + public static IRequestExecutorBuilder ExportSchemaOnStartup( + this IRequestExecutorBuilder builder, + string? schemaFileName = null, + bool skipIf = false) { - public bool ApplyOnlyOnStartup => false; + ArgumentNullException.ThrowIfNull(builder); - public Task WarmupAsync(IRequestExecutor requestExecutor, CancellationToken cancellationToken) - { - return warmupFunc.Invoke(requestExecutor, cancellationToken); - } - } + schemaFileName ??= System.IO.Path.Combine(Environment.CurrentDirectory, "schema.graphqls"); - // /// - // /// Adds the current GraphQL configuration to the warmup background service. - // /// - // /// - // /// The . - // /// - // /// - // /// The warmup task that shall be executed on a new executor. - // /// - // /// - // /// Apply warmup task after eviction and keep executor in-memory. - // /// - // /// - // /// Skips the warmup task if set to true. - // /// - // /// - // /// Returns the so that configuration can be chained. - // /// - // /// - // /// The is null. - // /// - // public static IRequestExecutorBuilder InitializeOnStartup( - // this IRequestExecutorBuilder builder, - // Func? warmup = null, - // bool keepWarm = false, - // bool skipIf = false) - // { - // ArgumentNullException.ThrowIfNull(builder); - // - // if (!skipIf) - // { - // builder.Services.AddHostedService(); - // builder.Services.AddSingleton(new WarmupSchemaTask(builder.Name, keepWarm, warmup)); - // } - // - // return builder; - // } - // - // /// - // /// Adds the current GraphQL configuration to the warmup background service. - // /// - // /// - // /// The . - // /// - // /// - // /// The . - // /// - // /// - // /// Skips the warmup task if set to true. - // /// - // /// - // /// Returns the so that configuration can be chained. - // /// - // /// - // /// The is null. - // /// - // public static IRequestExecutorBuilder InitializeOnStartup( - // this IRequestExecutorBuilder builder, - // RequestExecutorInitializationOptions options, - // bool skipIf = false) - // { - // ArgumentNullException.ThrowIfNull(builder); - // - // if (skipIf) - // { - // return builder; - // } - // - // Func? warmup; - // - // if (options.WriteSchemaFile.Enable) - // { - // var schemaFileName = - // options.WriteSchemaFile.FileName - // ?? System.IO.Path.Combine(Environment.CurrentDirectory, "schema.graphqls"); - // - // if (options.Warmup is null) - // { - // warmup = async (executor, cancellationToken) - // => await SchemaFileExporter.Export(schemaFileName, executor, cancellationToken); - // } - // else - // { - // warmup = async (executor, cancellationToken) => - // { - // await SchemaFileExporter.Export(schemaFileName, executor, cancellationToken); - // await options.Warmup(executor, cancellationToken); - // }; - // } - // } - // else - // { - // warmup = options.Warmup; - // } - // - // return InitializeOnStartup(builder, warmup, options.KeepWarm); - // } - // - // /// - // /// Exports the GraphQL schema to a file on startup or when the schema changes. - // /// - // /// - // /// The . - // /// - // /// - // /// The file name of the schema file. - // /// - // /// - // /// Returns the so that configuration can be chained. - // /// - // /// - // /// The is null. - // /// - // public static IRequestExecutorBuilder ExportSchemaOnStartup( - // this IRequestExecutorBuilder builder, - // string? schemaFileName = null) - // { - // ArgumentNullException.ThrowIfNull(builder); - // - // return InitializeOnStartup(builder, new RequestExecutorInitializationOptions - // { - // KeepWarm = true, - // WriteSchemaFile = new SchemaFileInitializationOptions - // { - // Enable = true, - // FileName = schemaFileName - // } - // }); - // } + return builder.AddWarmupTask(new SchemaFileExporterWarmupTask(schemaFileName), skipIf); + } } diff --git a/src/HotChocolate/AspNetCore/src/AspNetCore/Warmup/RequestExecutorInitializationOptions.cs b/src/HotChocolate/AspNetCore/src/AspNetCore/Warmup/RequestExecutorInitializationOptions.cs deleted file mode 100644 index bd71c1e543f..00000000000 --- a/src/HotChocolate/AspNetCore/src/AspNetCore/Warmup/RequestExecutorInitializationOptions.cs +++ /dev/null @@ -1,23 +0,0 @@ -namespace Microsoft.Extensions.DependencyInjection; - -/// -/// Represents the initialization options for a request executor. -/// -public struct RequestExecutorInitializationOptions -{ - /// - /// Gets or sets the warmup task that shall be executed on a new executor. - /// - public Func? Warmup { get; set; } - - /// - /// Gets or sets a value indicating whether the warmup task shall be executed after eviction and - /// keep executor in-memory. - /// - public bool KeepWarm { get; set; } - - /// - /// Gets or sets the schema file initialization options. - /// - public SchemaFileInitializationOptions WriteSchemaFile { get; set; } -} diff --git a/src/HotChocolate/AspNetCore/src/AspNetCore/Warmup/SchemaFileExporterWarmupTask.cs b/src/HotChocolate/AspNetCore/src/AspNetCore/Warmup/SchemaFileExporterWarmupTask.cs new file mode 100644 index 00000000000..011e83bb813 --- /dev/null +++ b/src/HotChocolate/AspNetCore/src/AspNetCore/Warmup/SchemaFileExporterWarmupTask.cs @@ -0,0 +1,13 @@ +using HotChocolate.Execution.Internal; + +namespace HotChocolate.AspNetCore.Warmup; + +internal sealed class SchemaFileExporterWarmupTask(string schemaFileName) : IRequestExecutorWarmupTask +{ + public bool ApplyOnlyOnStartup => false; + + public async Task WarmupAsync(IRequestExecutor executor, CancellationToken cancellationToken) + { + await SchemaFileExporter.Export(schemaFileName, executor, cancellationToken); + } +} diff --git a/src/HotChocolate/AspNetCore/src/AspNetCore/Warmup/SchemaFileInitializationOptions.cs b/src/HotChocolate/AspNetCore/src/AspNetCore/Warmup/SchemaFileInitializationOptions.cs deleted file mode 100644 index 129144216ed..00000000000 --- a/src/HotChocolate/AspNetCore/src/AspNetCore/Warmup/SchemaFileInitializationOptions.cs +++ /dev/null @@ -1,19 +0,0 @@ -namespace Microsoft.Extensions.DependencyInjection; - -/// -/// Represents the schema file initialization options. -/// -public struct SchemaFileInitializationOptions -{ - /// - /// Gets or sets a value indicating whether a schema file shall be written - /// to the file system every time the executor is initialized. - /// - public bool Enable { get; set; } - - /// - /// Gets or sets the name of the schema file. - /// The default value is "schema.graphqls". - /// - public string? FileName { get; set; } -} diff --git a/src/HotChocolate/Core/src/Execution.Abstractions/Execution/DelegateWarmupTask.cs b/src/HotChocolate/Core/src/Execution.Abstractions/Execution/DelegateWarmupTask.cs new file mode 100644 index 00000000000..7e5edd2316f --- /dev/null +++ b/src/HotChocolate/Core/src/Execution.Abstractions/Execution/DelegateWarmupTask.cs @@ -0,0 +1,12 @@ +namespace HotChocolate.Execution; + +public sealed class DelegateWarmupTask(Func warmupFunc) + : IRequestExecutorWarmupTask +{ + public bool ApplyOnlyOnStartup => false; + + public Task WarmupAsync(IRequestExecutor requestExecutor, CancellationToken cancellationToken) + { + return warmupFunc.Invoke(requestExecutor, cancellationToken); + } +} diff --git a/src/HotChocolate/Fusion-vnext/src/Fusion.Execution/DependencyInjection/CoreFusionGatewayBuilderExtensions.Warmup.cs b/src/HotChocolate/Fusion-vnext/src/Fusion.Execution/DependencyInjection/CoreFusionGatewayBuilderExtensions.Warmup.cs index 867696c6b6b..44e4f55811f 100644 --- a/src/HotChocolate/Fusion-vnext/src/Fusion.Execution/DependencyInjection/CoreFusionGatewayBuilderExtensions.Warmup.cs +++ b/src/HotChocolate/Fusion-vnext/src/Fusion.Execution/DependencyInjection/CoreFusionGatewayBuilderExtensions.Warmup.cs @@ -2,6 +2,7 @@ using HotChocolate.Execution; using HotChocolate.Fusion.Configuration; +// ReSharper disable once CheckNamespace namespace Microsoft.Extensions.DependencyInjection; public static partial class CoreFusionGatewayBuilderExtensions @@ -9,54 +10,108 @@ public static partial class CoreFusionGatewayBuilderExtensions /// /// Adds a warmup task that will be executed on each newly created request executor. /// + /// + /// The . + /// + /// + /// The warmup delegate to execute. + /// + /// + /// If true, the warmup task will not be registered. + /// + /// + /// Returns the so that configuration can be chained. + /// + /// + /// The is null. + /// + /// + /// The is null. + /// public static IFusionGatewayBuilder AddWarmupTask( this IFusionGatewayBuilder builder, - Func warmupFunc) + Func warmupFunc, + bool skipIf = false) { ArgumentNullException.ThrowIfNull(builder); ArgumentNullException.ThrowIfNull(warmupFunc); - return builder.AddWarmupTask(new DelegateWarmupTask(warmupFunc)); + return builder.AddWarmupTask(new DelegateWarmupTask(warmupFunc), skipIf); } /// /// Adds a warmup task that will be executed on each newly created request executor. /// + /// + /// The . + /// + /// + /// The warmup task to execute. + /// + /// + /// If true, the warmup task will not be registered. + /// + /// + /// Returns the so that configuration can be chained. + /// + /// + /// The is null. + /// + /// + /// The is null. + /// public static IFusionGatewayBuilder AddWarmupTask( this IFusionGatewayBuilder builder, - IRequestExecutorWarmupTask warmupTask) + IRequestExecutorWarmupTask warmupTask, + bool skipIf = false) { ArgumentNullException.ThrowIfNull(builder); ArgumentNullException.ThrowIfNull(warmupTask); + if (skipIf) + { + return builder; + } + builder.ConfigureSchemaServices((_, sc) => sc.AddSingleton(warmupTask)); return builder; } /// - /// Adds a warmup task that will be executed on each newly created request executor. + /// Adds a warmup task for the request executor. /// - public static IFusionGatewayBuilder AddWarmupTask<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors)] T>( - this IFusionGatewayBuilder builder) + /// + /// The . + /// + /// + /// If true, the warmup task will not be registered. + /// + /// + /// The warmup task to execute. + /// + /// + /// Returns the so that configuration can be chained. + /// + /// + /// The is null. + /// + public static IFusionGatewayBuilder AddWarmupTask< + [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors)] + T>( + this IFusionGatewayBuilder builder, + bool skipIf = false) where T : class, IRequestExecutorWarmupTask { ArgumentNullException.ThrowIfNull(builder); - builder.ConfigureSchemaServices( - static (_, sc) => sc.AddSingleton()); - - return builder; - } - - private sealed class DelegateWarmupTask(Func warmupFunc) - : IRequestExecutorWarmupTask - { - public bool ApplyOnlyOnStartup => false; - - public Task WarmupAsync(IRequestExecutor requestExecutor, CancellationToken cancellationToken) + if (skipIf) { - return warmupFunc.Invoke(requestExecutor, cancellationToken); + return builder; } + + builder.ConfigureSchemaServices(static (_, sc) => sc.AddSingleton()); + + return builder; } } From 1598439b310a3ab5941a8ec3a3b6774431553a8a Mon Sep 17 00:00:00 2001 From: tobias-tengler <45513122+tobias-tengler@users.noreply.github.com> Date: Fri, 10 Oct 2025 17:23:40 +0200 Subject: [PATCH 6/6] Cleanup --- ...tCoreServiceCollectionExtensions.Warmup.cs | 6 +- ...s => DelegateRequestExecutorWarmupTask.cs} | 2 +- .../Execution/IRequestExecutorWarmupTask.cs | 2 +- .../RequestExecutorManagerTests.cs | 40 ++++++++++++- ...reFusionGatewayBuilderExtensions.Warmup.cs | 6 +- .../FusionRequestExecutorManagerTests.cs | 41 ++++++++++++- .../DependencyInjection/ServiceException.cs | 12 ---- .../Utilities/HotChocolate.Utilities.csproj | 2 - .../docs/hotchocolate/v16/server/warmup.md | 58 ++++++++++++------- 9 files changed, 119 insertions(+), 50 deletions(-) rename src/HotChocolate/Core/src/Execution.Abstractions/Execution/{DelegateWarmupTask.cs => DelegateRequestExecutorWarmupTask.cs} (72%) delete mode 100644 src/HotChocolate/Utilities/src/Utilities/DependencyInjection/ServiceException.cs diff --git a/src/HotChocolate/AspNetCore/src/AspNetCore/Extensions/HotChocolateAspNetCoreServiceCollectionExtensions.Warmup.cs b/src/HotChocolate/AspNetCore/src/AspNetCore/Extensions/HotChocolateAspNetCoreServiceCollectionExtensions.Warmup.cs index 3b377ad4d64..7bcd3ba47b0 100644 --- a/src/HotChocolate/AspNetCore/src/AspNetCore/Extensions/HotChocolateAspNetCoreServiceCollectionExtensions.Warmup.cs +++ b/src/HotChocolate/AspNetCore/src/AspNetCore/Extensions/HotChocolateAspNetCoreServiceCollectionExtensions.Warmup.cs @@ -36,7 +36,7 @@ public static IRequestExecutorBuilder AddWarmupTask( ArgumentNullException.ThrowIfNull(builder); ArgumentNullException.ThrowIfNull(warmupFunc); - return builder.AddWarmupTask(new DelegateWarmupTask(warmupFunc), skipIf); + return builder.AddWarmupTask(new DelegateRequestExecutorWarmupTask(warmupFunc), skipIf); } /// @@ -73,9 +73,7 @@ public static IRequestExecutorBuilder AddWarmupTask( return builder; } - builder.ConfigureSchemaServices((_, sc) => sc.AddSingleton(warmupTask)); - - return builder; + return builder.ConfigureSchemaServices((_, sc) => sc.AddSingleton(warmupTask)); } /// diff --git a/src/HotChocolate/Core/src/Execution.Abstractions/Execution/DelegateWarmupTask.cs b/src/HotChocolate/Core/src/Execution.Abstractions/Execution/DelegateRequestExecutorWarmupTask.cs similarity index 72% rename from src/HotChocolate/Core/src/Execution.Abstractions/Execution/DelegateWarmupTask.cs rename to src/HotChocolate/Core/src/Execution.Abstractions/Execution/DelegateRequestExecutorWarmupTask.cs index 7e5edd2316f..98716e63f49 100644 --- a/src/HotChocolate/Core/src/Execution.Abstractions/Execution/DelegateWarmupTask.cs +++ b/src/HotChocolate/Core/src/Execution.Abstractions/Execution/DelegateRequestExecutorWarmupTask.cs @@ -1,6 +1,6 @@ namespace HotChocolate.Execution; -public sealed class DelegateWarmupTask(Func warmupFunc) +public sealed class DelegateRequestExecutorWarmupTask(Func warmupFunc) : IRequestExecutorWarmupTask { public bool ApplyOnlyOnStartup => false; diff --git a/src/HotChocolate/Core/src/Execution.Abstractions/Execution/IRequestExecutorWarmupTask.cs b/src/HotChocolate/Core/src/Execution.Abstractions/Execution/IRequestExecutorWarmupTask.cs index 680af8f1e38..7e67c9fd327 100644 --- a/src/HotChocolate/Core/src/Execution.Abstractions/Execution/IRequestExecutorWarmupTask.cs +++ b/src/HotChocolate/Core/src/Execution.Abstractions/Execution/IRequestExecutorWarmupTask.cs @@ -8,7 +8,7 @@ public interface IRequestExecutorWarmupTask { /// /// Specifies whether the warmup task should be only applied on startup, - /// but not subsequent request executor creations. + /// but not on subsequent request executor creations. /// bool ApplyOnlyOnStartup { get; } diff --git a/src/HotChocolate/Core/test/Execution.Tests/RequestExecutorManagerTests.cs b/src/HotChocolate/Core/test/Execution.Tests/RequestExecutorManagerTests.cs index ffc52943f69..c226ef24d7f 100644 --- a/src/HotChocolate/Core/test/Execution.Tests/RequestExecutorManagerTests.cs +++ b/src/HotChocolate/Core/test/Execution.Tests/RequestExecutorManagerTests.cs @@ -1,5 +1,6 @@ using HotChocolate.Execution.Caching; using HotChocolate.Execution.Configuration; +using HotChocolate.Language; using HotChocolate.Types; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; @@ -51,14 +52,14 @@ public async Task Operation_Cache_Should_Be_Scoped_To_Executor() // act var firstExecutor = await manager.GetExecutorAsync(cancellationToken: cts.Token); - var firstOperationCache = firstExecutor.Schema.Services.GetCombinedServices() + var firstOperationCache = firstExecutor.Schema.Services .GetRequiredService(); manager.EvictExecutor(); executorEvictedResetEvent.Wait(cts.Token); var secondExecutor = await manager.GetExecutorAsync(cancellationToken: cts.Token); - var secondOperationCache = secondExecutor.Schema.Services.GetCombinedServices() + var secondOperationCache = secondExecutor.Schema.Services .GetRequiredService(); // assert @@ -256,6 +257,41 @@ public async Task Ensure_Executor_Is_Created_During_Startup(bool lazyInitializat } } + [Fact(Skip = "SomeService needs to be registered with the schema services")] + public async Task WarmupTask_Should_Be_Able_To_Access_Schema_And_Regular_Services() + { + // arrange + var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5)); + + var services = new ServiceCollection(); + services.AddSingleton(); + services + .AddGraphQLServer() + .AddWarmupTask() + .AddQueryType(d => d.Field("foo").Resolve("")); + var provider = services.BuildServiceProvider(); + var manager = provider.GetRequiredService(); + + // act + var executor = await manager.GetExecutorAsync(cancellationToken: cts.Token); + + // assert + Assert.NotNull(executor); + + cts.Dispose(); + } + +#pragma warning disable CS9113 // Parameter is unread. + private sealed class CustomWarmupTask(IDocumentCache documentCache, SomeService service) : IRequestExecutorWarmupTask +#pragma warning restore CS9113 // Parameter is unread. + { + public bool ApplyOnlyOnStartup => false; + + public Task WarmupAsync(IRequestExecutor executor, CancellationToken cancellationToken) => Task.CompletedTask; + } + + private class SomeService; + private sealed class TriggerableTypeModule : TypeModule { public void TriggerChange() => OnTypesChanged(); diff --git a/src/HotChocolate/Fusion-vnext/src/Fusion.Execution/DependencyInjection/CoreFusionGatewayBuilderExtensions.Warmup.cs b/src/HotChocolate/Fusion-vnext/src/Fusion.Execution/DependencyInjection/CoreFusionGatewayBuilderExtensions.Warmup.cs index 44e4f55811f..abbf691800a 100644 --- a/src/HotChocolate/Fusion-vnext/src/Fusion.Execution/DependencyInjection/CoreFusionGatewayBuilderExtensions.Warmup.cs +++ b/src/HotChocolate/Fusion-vnext/src/Fusion.Execution/DependencyInjection/CoreFusionGatewayBuilderExtensions.Warmup.cs @@ -36,7 +36,7 @@ public static IFusionGatewayBuilder AddWarmupTask( ArgumentNullException.ThrowIfNull(builder); ArgumentNullException.ThrowIfNull(warmupFunc); - return builder.AddWarmupTask(new DelegateWarmupTask(warmupFunc), skipIf); + return builder.AddWarmupTask(new DelegateRequestExecutorWarmupTask(warmupFunc), skipIf); } /// @@ -73,9 +73,7 @@ public static IFusionGatewayBuilder AddWarmupTask( return builder; } - builder.ConfigureSchemaServices((_, sc) => sc.AddSingleton(warmupTask)); - - return builder; + return builder.ConfigureSchemaServices((_, sc) => sc.AddSingleton(warmupTask)); } /// diff --git a/src/HotChocolate/Fusion-vnext/test/Fusion.Execution.Tests/Execution/FusionRequestExecutorManagerTests.cs b/src/HotChocolate/Fusion-vnext/test/Fusion.Execution.Tests/Execution/FusionRequestExecutorManagerTests.cs index 0fa171c9750..82319ec4e84 100644 --- a/src/HotChocolate/Fusion-vnext/test/Fusion.Execution.Tests/Execution/FusionRequestExecutorManagerTests.cs +++ b/src/HotChocolate/Fusion-vnext/test/Fusion.Execution.Tests/Execution/FusionRequestExecutorManagerTests.cs @@ -1,4 +1,5 @@ using System.Buffers; +using System.Net.Security; using System.Text.Json; using HotChocolate.Buffers; using HotChocolate.Caching.Memory; @@ -6,6 +7,7 @@ using HotChocolate.Fusion.Configuration; using HotChocolate.Fusion.Execution.Nodes; using HotChocolate.Fusion.Execution.Pipeline; +using HotChocolate.Language; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; @@ -173,7 +175,7 @@ public async Task Plan_Cache_Should_Be_Scoped_To_Executor() // act var firstExecutor = await manager.GetExecutorAsync(cancellationToken: cts.Token); - var firstPlanCache = firstExecutor.Schema.Services.GetCombinedServices() + var firstPlanCache = firstExecutor.Schema.Services .GetRequiredService>(); configProvider.UpdateConfiguration( @@ -190,7 +192,7 @@ type Query { executorEvictedResetEvent.Wait(cts.Token); var secondExecutor = await manager.GetExecutorAsync(cancellationToken: cts.Token); - var secondPlanCache = secondExecutor.Schema.Services.GetCombinedServices() + var secondPlanCache = secondExecutor.Schema.Services .GetRequiredService>(); // assert @@ -385,6 +387,41 @@ public async Task Executor_Resolution_Should_Be_Parallel() cts.Dispose(); } + [Fact(Skip = "SomeService needs to be registered with the schema services")] + public async Task WarmupTask_Should_Be_Able_To_Access_Schema_And_Regular_Services() + { + // arrange + var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5)); + + var services = new ServiceCollection(); + services.AddSingleton(); + services + .AddGraphQLGateway() + .AddInMemoryConfiguration(CreateConfiguration().Schema) + .AddWarmupTask(); + var provider = services.BuildServiceProvider(); + var manager = provider.GetRequiredService(); + + // act + var executor = await manager.GetExecutorAsync(cancellationToken: cts.Token); + + // assert + Assert.NotNull(executor); + + cts.Dispose(); + } + +#pragma warning disable CS9113 // Parameter is unread. + private sealed class CustomWarmupTask(IDocumentCache documentCache, SomeService service) : IRequestExecutorWarmupTask +#pragma warning restore CS9113 // Parameter is unread. + { + public bool ApplyOnlyOnStartup => false; + + public Task WarmupAsync(IRequestExecutor executor, CancellationToken cancellationToken) => Task.CompletedTask; + } + + private class SomeService; + private static FusionConfiguration CreateConfiguration(string? sourceSchemaText = null) { sourceSchemaText ??= diff --git a/src/HotChocolate/Utilities/src/Utilities/DependencyInjection/ServiceException.cs b/src/HotChocolate/Utilities/src/Utilities/DependencyInjection/ServiceException.cs deleted file mode 100644 index 4c7dc9f4214..00000000000 --- a/src/HotChocolate/Utilities/src/Utilities/DependencyInjection/ServiceException.cs +++ /dev/null @@ -1,12 +0,0 @@ -namespace HotChocolate.Utilities; - -public class ServiceException : Exception -{ - public ServiceException() { } - - public ServiceException(string message) - : base(message) { } - - public ServiceException(string message, Exception inner) - : base(message, inner) { } -} diff --git a/src/HotChocolate/Utilities/src/Utilities/HotChocolate.Utilities.csproj b/src/HotChocolate/Utilities/src/Utilities/HotChocolate.Utilities.csproj index 3d8f5b5e8d9..5b7ac9180f6 100644 --- a/src/HotChocolate/Utilities/src/Utilities/HotChocolate.Utilities.csproj +++ b/src/HotChocolate/Utilities/src/Utilities/HotChocolate.Utilities.csproj @@ -19,8 +19,6 @@ - - diff --git a/website/src/docs/hotchocolate/v16/server/warmup.md b/website/src/docs/hotchocolate/v16/server/warmup.md index 5afe053cef7..f6cf5447389 100644 --- a/website/src/docs/hotchocolate/v16/server/warmup.md +++ b/website/src/docs/hotchocolate/v16/server/warmup.md @@ -52,28 +52,6 @@ var request = OperationRequestBuilder.New() await executor.ExecuteAsync(request, cancellationToken); ``` -## Skipping reporting - -If you've set up [instrumentation](/docs/hotchocolate/v16/server/instrumentation), you might want to skip reporting certain events in the case of a warmup request. - -You can use the `RequestContext.IsWarmupRequest()` method to determine whether a request is a warmup request or not: - -```csharp -public class MyExecutionEventListener : ExecutionDiagnosticEventListener -{ - public override void RequestError(RequestContext context, - Exception exception) - { - if (context.IsWarmupRequest()) - { - return; - } - - // Reporting - } -} -``` - ## Custom warmup tasks For more control over warmup behavior, you can implement the `IRequestExecutorWarmupTask` interface: @@ -100,6 +78,42 @@ public class MyWarmupTask : IRequestExecutorWarmupTask The `ApplyOnlyOnStartup` property controls whether the warmup task should run only at server startup (`true`) or also when the request executor is rebuilt at runtime (`false`, the default). Register your custom warmup task using any of these approaches: + + # Opting into lazy initialization If you need to defer schema construction until the first request (though this is rarely recommended), you can opt into lazy initialization: