diff --git a/src/HotChocolate/Caching/src/Caching/Extensions/QueryCacheRequestExecutorBuilderExtensions.cs b/src/HotChocolate/Caching/src/Caching/Extensions/QueryCacheRequestExecutorBuilderExtensions.cs index c798cd71d98..9747fba206e 100644 --- a/src/HotChocolate/Caching/src/Caching/Extensions/QueryCacheRequestExecutorBuilderExtensions.cs +++ b/src/HotChocolate/Caching/src/Caching/Extensions/QueryCacheRequestExecutorBuilderExtensions.cs @@ -42,6 +42,7 @@ public static IRequestExecutorBuilder UseQueryCachePipeline( .UseDocumentValidation() .UseOperationCache() .UseOperationResolver() + .UseSkipWarmupExecution() .UseOperationVariableCoercion() .UseOperationExecution(); } diff --git a/src/HotChocolate/Core/src/Abstractions/Execution/ExecutionResultKind.cs b/src/HotChocolate/Core/src/Abstractions/Execution/ExecutionResultKind.cs index 6892f01cd68..0af580428ce 100644 --- a/src/HotChocolate/Core/src/Abstractions/Execution/ExecutionResultKind.cs +++ b/src/HotChocolate/Core/src/Abstractions/Execution/ExecutionResultKind.cs @@ -24,4 +24,9 @@ public enum ExecutionResultKind /// A subscription response stream. /// SubscriptionResult, + + /// + /// A no-op result for warmup requests. + /// + WarmupResult, } diff --git a/src/HotChocolate/Core/src/Abstractions/Execution/OperationRequestBuilderExtensions.cs b/src/HotChocolate/Core/src/Abstractions/Execution/OperationRequestBuilderExtensions.cs index caabc4720ff..61d599c764e 100644 --- a/src/HotChocolate/Core/src/Abstractions/Execution/OperationRequestBuilderExtensions.cs +++ b/src/HotChocolate/Core/src/Abstractions/Execution/OperationRequestBuilderExtensions.cs @@ -74,4 +74,11 @@ public static OperationRequestBuilder SetUser( this OperationRequestBuilder builder, ClaimsPrincipal claimsPrincipal) => builder.SetGlobalState(nameof(ClaimsPrincipal), claimsPrincipal); + + /// + /// Marks this request as a warmup request that will bypass security measures and skip execution. + /// + public static OperationRequestBuilder MarkAsWarmupRequest( + this OperationRequestBuilder builder) + => builder.SetGlobalState(WellKnownContextData.IsWarmupRequest, true); } diff --git a/src/HotChocolate/Core/src/Abstractions/Execution/WarmupExecutionResult.cs b/src/HotChocolate/Core/src/Abstractions/Execution/WarmupExecutionResult.cs new file mode 100644 index 00000000000..d4b864c9fc8 --- /dev/null +++ b/src/HotChocolate/Core/src/Abstractions/Execution/WarmupExecutionResult.cs @@ -0,0 +1,8 @@ +namespace HotChocolate.Execution; + +public sealed class WarmupExecutionResult : ExecutionResult +{ + public override ExecutionResultKind Kind => ExecutionResultKind.WarmupResult; + + public override IReadOnlyDictionary? ContextData => null; +} diff --git a/src/HotChocolate/Core/src/Abstractions/WellKnownContextData.cs b/src/HotChocolate/Core/src/Abstractions/WellKnownContextData.cs index 21da2c72034..efd00842378 100644 --- a/src/HotChocolate/Core/src/Abstractions/WellKnownContextData.cs +++ b/src/HotChocolate/Core/src/Abstractions/WellKnownContextData.cs @@ -328,4 +328,9 @@ public static class WellKnownContextData /// The key to access the compiled requirements. /// public const string FieldRequirements = "HotChocolate.Types.ObjectField.Requirements"; + + /// + /// The key to determine whether the request is a warmup request. + /// + public const string IsWarmupRequest = "HotChocolate.AspNetCore.Warmup.IsWarmupRequest"; } diff --git a/src/HotChocolate/Core/src/Execution/DependencyInjection/RequestExecutorBuilderExtensions.UseRequest.cs b/src/HotChocolate/Core/src/Execution/DependencyInjection/RequestExecutorBuilderExtensions.UseRequest.cs index 768df411ddf..193ff3ab283 100644 --- a/src/HotChocolate/Core/src/Execution/DependencyInjection/RequestExecutorBuilderExtensions.UseRequest.cs +++ b/src/HotChocolate/Core/src/Execution/DependencyInjection/RequestExecutorBuilderExtensions.UseRequest.cs @@ -120,6 +120,10 @@ public static IRequestExecutorBuilder UseOperationVariableCoercion( this IRequestExecutorBuilder builder) => builder.UseRequest(OperationVariableCoercionMiddleware.Create()); + public static IRequestExecutorBuilder UseSkipWarmupExecution( + this IRequestExecutorBuilder builder) => + builder.UseRequest(SkipWarmupExecutionMiddleware.Create()); + public static IRequestExecutorBuilder UseReadPersistedOperation( this IRequestExecutorBuilder builder) => builder.UseRequest(ReadPersistedOperationMiddleware.Create()); @@ -191,6 +195,7 @@ public static IRequestExecutorBuilder UsePersistedOperationPipeline( .UseDocumentValidation() .UseOperationCache() .UseOperationResolver() + .UseSkipWarmupExecution() .UseOperationVariableCoercion() .UseOperationExecution(); } @@ -215,6 +220,7 @@ public static IRequestExecutorBuilder UseAutomaticPersistedOperationPipeline( .UseDocumentValidation() .UseOperationCache() .UseOperationResolver() + .UseSkipWarmupExecution() .UseOperationVariableCoercion() .UseOperationExecution(); } @@ -229,6 +235,7 @@ internal static void AddDefaultPipeline(this IList pipeli pipeline.Add(DocumentValidationMiddleware.Create()); pipeline.Add(OperationCacheMiddleware.Create()); pipeline.Add(OperationResolverMiddleware.Create()); + pipeline.Add(SkipWarmupExecutionMiddleware.Create()); pipeline.Add(OperationVariableCoercionMiddleware.Create()); pipeline.Add(OperationExecutionMiddleware.Create()); } diff --git a/src/HotChocolate/Core/src/Execution/Extensions/WarmupRequestContextExtensions.cs b/src/HotChocolate/Core/src/Execution/Extensions/WarmupRequestContextExtensions.cs new file mode 100644 index 00000000000..21eab8a7994 --- /dev/null +++ b/src/HotChocolate/Core/src/Execution/Extensions/WarmupRequestContextExtensions.cs @@ -0,0 +1,7 @@ +namespace HotChocolate.Execution; + +public static class WarmupRequestExecutorExtensions +{ + public static bool IsWarmupRequest(this IRequestContext requestContext) + => requestContext.ContextData.ContainsKey(WellKnownContextData.IsWarmupRequest); +} diff --git a/src/HotChocolate/Core/src/Execution/Pipeline/OnlyPersistedOperationsAllowedMiddleware.cs b/src/HotChocolate/Core/src/Execution/Pipeline/OnlyPersistedOperationsAllowedMiddleware.cs index 64508627949..6cab50e07b1 100644 --- a/src/HotChocolate/Core/src/Execution/Pipeline/OnlyPersistedOperationsAllowedMiddleware.cs +++ b/src/HotChocolate/Core/src/Execution/Pipeline/OnlyPersistedOperationsAllowedMiddleware.cs @@ -38,8 +38,8 @@ private OnlyPersistedOperationsAllowedMiddleware( public ValueTask InvokeAsync(IRequestContext context) { - // if all operations are allowed we can skip this middleware. - if(!_options.OnlyAllowPersistedDocuments) + // if all operations are allowed or the request is a warmup request, we can skip this middleware. + if(!_options.OnlyAllowPersistedDocuments || context.IsWarmupRequest()) { return _next(context); } diff --git a/src/HotChocolate/Core/src/Execution/Pipeline/SkipWarmupExecutionMiddleware.cs b/src/HotChocolate/Core/src/Execution/Pipeline/SkipWarmupExecutionMiddleware.cs new file mode 100644 index 00000000000..9a97cfbcc74 --- /dev/null +++ b/src/HotChocolate/Core/src/Execution/Pipeline/SkipWarmupExecutionMiddleware.cs @@ -0,0 +1,22 @@ +namespace HotChocolate.Execution.Pipeline; + +internal sealed class SkipWarmupExecutionMiddleware(RequestDelegate next) +{ + public async ValueTask InvokeAsync(IRequestContext context) + { + if (context.IsWarmupRequest()) + { + context.Result = new WarmupExecutionResult(); + return; + } + + await next(context).ConfigureAwait(false); + } + + public static RequestCoreMiddleware Create() + => (_, next) => + { + var middleware = new SkipWarmupExecutionMiddleware(next); + return context => middleware.InvokeAsync(context); + }; +} diff --git a/src/HotChocolate/Core/test/Execution.Tests/WarmupRequestTests.cs b/src/HotChocolate/Core/test/Execution.Tests/WarmupRequestTests.cs new file mode 100644 index 00000000000..fe04071361d --- /dev/null +++ b/src/HotChocolate/Core/test/Execution.Tests/WarmupRequestTests.cs @@ -0,0 +1,100 @@ +using HotChocolate.Execution.Caching; +using HotChocolate.Language; +using Microsoft.Extensions.DependencyInjection; +using Moq; + +namespace HotChocolate.Execution; + +public class WarmupRequestTests +{ + [Fact] + public async Task Warmup_Request_Warms_Up_Caches() + { + // arrange + var executor = await new ServiceCollection() + .AddGraphQL() + .AddQueryType() + .BuildRequestExecutorAsync(); + + var documentId = "f614e9a2ed367399e87751d41ca09105"; + var warmupRequest = OperationRequestBuilder.New() + .SetDocument("query test($name: String!) { greeting(name: $name) }") + .SetDocumentId(documentId) + .MarkAsWarmupRequest() + .Build(); + + var regularRequest = OperationRequestBuilder.New() + .SetDocumentId(documentId) + .SetVariableValues(new Dictionary { ["name"] = "Foo" }) + .Build(); + + // act 1 + var warmupResult = await executor.ExecuteAsync(warmupRequest); + + // assert 1 + Assert.IsType(warmupResult); + + var provider = executor.Services.GetCombinedServices(); + var documentCache = provider.GetRequiredService(); + var operationCache = provider.GetRequiredService(); + + Assert.True(documentCache.TryGetDocument(documentId, out _)); + Assert.Equal(1, operationCache.Count); + + // act 2 + var regularResult = await executor.ExecuteAsync(regularRequest); + var regularOperationResult = regularResult.ExpectOperationResult(); + + // assert 2 + Assert.Null(regularOperationResult.Errors); + Assert.NotNull(regularOperationResult.Data); + Assert.NotEmpty(regularOperationResult.Data); + + Assert.True(documentCache.TryGetDocument(documentId, out _)); + Assert.Equal(1, operationCache.Count); + } + + [Fact] + public async Task Warmup_Request_Can_Skip_Persisted_Operation_Check() + { + // arrange + var executor = await new ServiceCollection() + .AddGraphQL() + .ConfigureSchemaServices(services => + { + services.AddSingleton(_ => new Mock().Object); + }) + .AddQueryType() + .ModifyRequestOptions(options => + { + options.PersistedOperations.OnlyAllowPersistedDocuments = true; + }) + .UsePersistedOperationPipeline() + .BuildRequestExecutorAsync(); + + var documentId = "f614e9a2ed367399e87751d41ca09105"; + var warmupRequest = OperationRequestBuilder.New() + .SetDocument("query test($name: String!) { greeting(name: $name) }") + .SetDocumentId(documentId) + .MarkAsWarmupRequest() + .Build(); + + // act + var warmupResult = await executor.ExecuteAsync(warmupRequest); + + // assert + Assert.IsType(warmupResult); + + var provider = executor.Services.GetCombinedServices(); + var documentCache = provider.GetRequiredService(); + var operationCache = provider.GetRequiredService(); + + Assert.True(documentCache.TryGetDocument(documentId, out _)); + Assert.Equal(1, operationCache.Count); + } + + public class Query + { + public string Greeting(string name) => $"Hello {name}"; + } +} diff --git a/src/HotChocolate/Fusion/src/Core/DependencyInjection/FusionRequestExecutorBuilderExtensions.cs b/src/HotChocolate/Fusion/src/Core/DependencyInjection/FusionRequestExecutorBuilderExtensions.cs index 39fd20bca4e..12ba1e5490a 100644 --- a/src/HotChocolate/Fusion/src/Core/DependencyInjection/FusionRequestExecutorBuilderExtensions.cs +++ b/src/HotChocolate/Fusion/src/Core/DependencyInjection/FusionRequestExecutorBuilderExtensions.cs @@ -564,6 +564,7 @@ private static IRequestExecutorBuilder UseFusionDefaultPipeline( .UseDocumentValidation() .UseOperationCache() .UseOperationResolver() + .UseSkipWarmupExecution() .UseOperationVariableCoercion() .UseDistributedOperationExecution(); } @@ -588,6 +589,7 @@ private static IRequestExecutorBuilder UseFusionPersistedOperationPipeline( .UseDocumentValidation() .UseOperationCache() .UseOperationResolver() + .UseSkipWarmupExecution() .UseOperationVariableCoercion() .UseDistributedOperationExecution(); } @@ -612,6 +614,7 @@ private static IRequestExecutorBuilder UseFusionAutomaticPersistedOperationPipel .UseDocumentValidation() .UseOperationCache() .UseOperationResolver() + .UseSkipWarmupExecution() .UseOperationVariableCoercion() .UseDistributedOperationExecution(); } diff --git a/website/src/docs/docs.json b/website/src/docs/docs.json index 45ab2522ad2..f42840a2487 100644 --- a/website/src/docs/docs.json +++ b/website/src/docs/docs.json @@ -121,6 +121,338 @@ "metaDescription": "Hot Chocolate is the most efficient, feature-rich, open-source GraphQL server in the .NET ecosystem, that helps developers to build powerful APIs.", "latestStableVersion": "v13", "versions": [ + { + "path": "v15", + "title": "v15", + "items": [ + { + "path": "index", + "title": "Introduction" + }, + { + "path": "get-started-with-graphql-in-net-core", + "title": "Getting Started" + }, + { + "path": "defining-a-schema", + "title": "Defining a schema", + "items": [ + { + "path": "index", + "title": "Overview" + }, + { + "path": "queries", + "title": "Queries" + }, + { + "path": "mutations", + "title": "Mutations" + }, + { + "path": "subscriptions", + "title": "Subscriptions" + }, + { + "path": "object-types", + "title": "Object Types" + }, + { + "path": "scalars", + "title": "Scalars" + }, + { + "path": "arguments", + "title": "Arguments" + }, + { + "path": "input-object-types", + "title": "Input Object Types" + }, + { + "path": "lists", + "title": "Lists" + }, + { + "path": "non-null", + "title": "Non-Null" + }, + { + "path": "enums", + "title": "Enums" + }, + { + "path": "interfaces", + "title": "Interfaces" + }, + { + "path": "unions", + "title": "Unions" + }, + { + "path": "extending-types", + "title": "Extending Types" + }, + { + "path": "directives", + "title": "Directives" + }, + { + "path": "documentation", + "title": "Documentation" + }, + { + "path": "versioning", + "title": "Versioning" + }, + { + "path": "relay", + "title": "Relay" + }, + { + "path": "dynamic-schemas", + "title": "Dynamic Schemas" + } + ] + }, + { + "path": "fetching-data", + "title": "Fetching data", + "items": [ + { + "path": "index", + "title": "Overview" + }, + { + "path": "resolvers", + "title": "Resolvers" + }, + { + "path": "fetching-from-databases", + "title": "Fetching from Databases" + }, + { + "path": "fetching-from-rest", + "title": "Fetching from REST" + }, + { + "path": "dataloader", + "title": "DataLoader" + }, + { + "path": "pagination", + "title": "Pagination" + }, + { + "path": "filtering", + "title": "Filtering" + }, + { + "path": "sorting", + "title": "Sorting" + }, + { + "path": "projections", + "title": "Projections" + } + ] + }, + { + "path": "execution-engine", + "title": "Execution Engine", + "items": [ + { + "path": "index", + "title": "Overview" + }, + { + "path": "field-middleware", + "title": "Field middleware" + } + ] + }, + { + "path": "integrations", + "title": "Integrations", + "items": [ + { + "path": "index", + "title": "Overview" + }, + { + "path": "entity-framework", + "title": "Entity Framework" + }, + { + "path": "mongodb", + "title": "MongoDB" + }, + { + "path": "spatial-data", + "title": "Spatial Data" + }, + { + "path": "marten", + "title": "Marten" + } + ] + }, + { + "path": "server", + "title": "Server", + "items": [ + { + "path": "index", + "title": "Overview" + }, + { + "path": "endpoints", + "title": "Endpoints" + }, + { + "path": "http-transport", + "title": "HTTP transport" + }, + { + "path": "interceptors", + "title": "Interceptors" + }, + { + "path": "dependency-injection", + "title": "Dependency injection" + }, + { + "path": "warmup", + "title": "Warmup" + }, + { + "path": "global-state", + "title": "Global State" + }, + { + "path": "introspection", + "title": "Introspection" + }, + { + "path": "files", + "title": "Files" + }, + { + "path": "instrumentation", + "title": "Instrumentation" + }, + { + "path": "batching", + "title": "Batching" + }, + { + "path": "command-line", + "title": "Command Line" + } + ] + }, + { + "path": "performance", + "title": "Performance", + "items": [ + { + "path": "index", + "title": "Overview" + }, + { + "path": "persisted-operations", + "title": "Persisted operations" + }, + { + "path": "automatic-persisted-operations", + "title": "Automatic persisted operations" + } + ] + }, + { + "path": "security", + "title": "Security", + "items": [ + { + "path": "index", + "title": "Overview" + }, + { + "path": "authentication", + "title": "Authentication" + }, + { + "path": "authorization", + "title": "Authorization" + }, + { + "path": "cost-analysis", + "title": "Cost Analysis" + } + ] + }, + { + "path": "api-reference", + "title": "API Reference", + "items": [ + { + "path": "custom-attributes", + "title": "Custom Attributes" + }, + { + "path": "errors", + "title": "Errors" + }, + { + "path": "language", + "title": "Language" + }, + { + "path": "extending-filtering", + "title": "Extending Filtering" + }, + { + "path": "visitors", + "title": "Visitors" + }, + { + "path": "apollo-federation", + "title": "Apollo Federation" + }, + { + "path": "executable", + "title": "Executable" + } + ] + }, + { + "path": "migrating", + "title": "Migrating", + "items": [ + { + "path": "migrate-from-14-to-15", + "title": "Migrate from 14 to 15" + }, + { + "path": "migrate-from-13-to-14", + "title": "Migrate from 13 to 14" + }, + { + "path": "migrate-from-12-to-13", + "title": "Migrate from 12 to 13" + }, + { + "path": "migrate-from-11-to-12", + "title": "Migrate from 11 to 12" + }, + { + "path": "migrate-from-10-to-11", + "title": "Migrate from 10 to 11" + } + ] + } + ] + }, { "path": "v14", "title": "v14", diff --git a/website/src/docs/hotchocolate/v15/server/warmup.md b/website/src/docs/hotchocolate/v15/server/warmup.md new file mode 100644 index 00000000000..6bf74375564 --- /dev/null +++ b/website/src/docs/hotchocolate/v15/server/warmup.md @@ -0,0 +1,101 @@ +--- +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. + +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. + +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. + +# 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. + +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. + +```csharp +builder.Services + .AddGraphQLServer() + .InitializeOnStartup( + warmup: async (executor, cancellationToken) => { + await executor.ExecuteAsync("{ __typename }"); + }); +``` + +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. + +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. + +```csharp +var request = OperationRequestBuilder.New() + .SetDocument("{ __typename }") + .MarkAsWarmupRequest() + .Build(); + +await executor.ExecuteAsync(request); +``` + +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. + +```csharp +var request = OperationRequestBuilder.New() + .SetDocument("query testQuery { __typename }") + .SetOperationName("testQuery") + .MarkAsWarmupRequest() + .Build(); +``` + +## Skipping reporting + +If you've implemented a custom diagnostic event listener as described [here](/docs/hotchocolate/v15/server/instrumentation#execution-events) 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. + +```csharp +public class MyExecutionEventListener : ExecutionDiagnosticEventListener +{ + public override void RequestError(IRequestContext context, + Exception exception) + { + if (context.IsWarmupRequest()) + { + return; + } + + // Reporting + } +} + +``` + +## Keeping the executor warm + +By default the warmup only takes place at server startup. If you're using [dynamic schemas](/docs/hotchocolate/v15/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`. + +```csharp +builder.Services + .AddGraphQLServer() + .InitializeOnStartup( + keepWarm: true, + warmup: /* ... */); +``` + +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.