From f918fd23ea12347aabdde9478fc4200a73d038e3 Mon Sep 17 00:00:00 2001 From: Roman Date: Thu, 3 Apr 2025 20:03:04 +0200 Subject: [PATCH 1/4] Introduce async transaction scope handler --- ...cutorBuilderExtensions.TransactionScope.cs | 47 +++++++++++++++++-- .../Pipeline/OperationExecutionMiddleware.cs | 11 +++-- .../Processing/IAsyncTransactionScope.cs | 12 +++++ .../IAsyncTransactionScopeHandler.cs | 19 ++++++++ .../Processing/NoOpTransactionScope.cs | 9 ++-- .../Processing/NoOpTransactionScopeHandler.cs | 6 ++- .../TransactionScopeAsyncAdapter.cs | 23 +++++++++ .../TransactionScopeHandlerAsyncAdapter.cs | 17 +++++++ 8 files changed, 131 insertions(+), 13 deletions(-) create mode 100644 src/HotChocolate/Core/src/Execution/Processing/IAsyncTransactionScope.cs create mode 100644 src/HotChocolate/Core/src/Execution/Processing/IAsyncTransactionScopeHandler.cs create mode 100644 src/HotChocolate/Core/src/Execution/Processing/TransactionScopeAsyncAdapter.cs create mode 100644 src/HotChocolate/Core/src/Execution/Processing/TransactionScopeHandlerAsyncAdapter.cs diff --git a/src/HotChocolate/Core/src/Execution/DependencyInjection/RequestExecutorBuilderExtensions.TransactionScope.cs b/src/HotChocolate/Core/src/Execution/DependencyInjection/RequestExecutorBuilderExtensions.TransactionScope.cs index a0bd7d62371..51ed342119e 100644 --- a/src/HotChocolate/Core/src/Execution/DependencyInjection/RequestExecutorBuilderExtensions.TransactionScope.cs +++ b/src/HotChocolate/Core/src/Execution/DependencyInjection/RequestExecutorBuilderExtensions.TransactionScope.cs @@ -39,10 +39,50 @@ public static IRequestExecutorBuilder AddTransactionScopeHandler( services => { // we remove all handlers from the schema DI - services.RemoveAll(typeof(ITransactionScopeHandler)); + services.RemoveAll(typeof(IAsyncTransactionScopeHandler)); + + // and then reference the transaction scope handler from the global DI. + services.AddSingleton( + s => new TransactionScopeHandlerAsyncAdapter(s.GetApplicationServices().GetRequiredService())); + }); + } + + /// + /// Adds a custom transaction scope handler to the schema. + /// + /// + /// The request executor builder. + /// + /// + /// The concrete type of the transaction scope handler. + /// + /// + /// The request executor builder. + /// + /// + /// The is null. + /// + public static IRequestExecutorBuilder AddAsyncTransactionScopeHandler( + this IRequestExecutorBuilder builder) + where T : class, IAsyncTransactionScopeHandler + { + if (builder is null) + { + throw new ArgumentNullException(nameof(builder)); + } + + // we host the transaction scope in the global DI. + builder.Services.TryAddSingleton(); + + return ConfigureSchemaServices( + builder, + services => + { + // we remove all handlers from the schema DI + services.RemoveAll(typeof(IAsyncTransactionScopeHandler)); // and then reference the transaction scope handler from the global DI. - services.AddSingleton( + services.AddSingleton( s => s.GetApplicationServices().GetRequiredService()); }); } @@ -74,6 +114,7 @@ public static IRequestExecutorBuilder AddTransactionScopeHandler( services => { services.RemoveAll(typeof(ITransactionScopeHandler)); + services.RemoveAll(typeof(IAsyncTransactionScopeHandler)); services.AddSingleton(sp => create(sp.GetCombinedServices())); }); } @@ -111,7 +152,7 @@ internal static IRequestExecutorBuilder TryAddNoOpTransactionScopeHandler( builder, services => { - services.TryAddSingleton( + services.TryAddSingleton( sp => sp.GetApplicationService()); }); } diff --git a/src/HotChocolate/Core/src/Execution/Pipeline/OperationExecutionMiddleware.cs b/src/HotChocolate/Core/src/Execution/Pipeline/OperationExecutionMiddleware.cs index 1a523a06172..eda8c0fe015 100644 --- a/src/HotChocolate/Core/src/Execution/Pipeline/OperationExecutionMiddleware.cs +++ b/src/HotChocolate/Core/src/Execution/Pipeline/OperationExecutionMiddleware.cs @@ -14,7 +14,7 @@ internal sealed class OperationExecutionMiddleware private readonly IFactory _contextFactory; private readonly QueryExecutor _queryExecutor; private readonly SubscriptionExecutor _subscriptionExecutor; - private readonly ITransactionScopeHandler _transactionScopeHandler; + private readonly IAsyncTransactionScopeHandler _transactionScopeHandler; private object? _cachedQuery; private object? _cachedMutation; @@ -23,7 +23,7 @@ private OperationExecutionMiddleware( IFactory contextFactory, [SchemaService] QueryExecutor queryExecutor, [SchemaService] SubscriptionExecutor subscriptionExecutor, - [SchemaService] ITransactionScopeHandler transactionScopeHandler) + [SchemaService] IAsyncTransactionScopeHandler transactionScopeHandler) { _next = next ?? throw new ArgumentNullException(nameof(next)); @@ -233,7 +233,7 @@ private async Task ExecuteQueryOrMutationAsync( if (operation.Definition.Operation is OperationType.Mutation) { - using var transactionScope = _transactionScopeHandler.Create(context); + await using var transactionScope = await _transactionScopeHandler.CreateAsync(context); var mutation = GetMutationRootValue(context); @@ -253,7 +253,7 @@ private async Task ExecuteQueryOrMutationAsync( context.Result = result; // we complete the transaction scope and are done. - transactionScope.Complete(); + await transactionScope.CompleteAsync(); return result; } @@ -316,7 +316,8 @@ public static RequestCoreMiddleware Create() var contextFactory = core.Services.GetRequiredService>(); var queryExecutor = core.SchemaServices.GetRequiredService(); var subscriptionExecutor = core.SchemaServices.GetRequiredService(); - var transactionScopeHandler = core.SchemaServices.GetRequiredService(); + var transactionScopeHandler = core.SchemaServices.GetRequiredService(); + var middleware = new OperationExecutionMiddleware( next, contextFactory, diff --git a/src/HotChocolate/Core/src/Execution/Processing/IAsyncTransactionScope.cs b/src/HotChocolate/Core/src/Execution/Processing/IAsyncTransactionScope.cs new file mode 100644 index 00000000000..db9ab70200c --- /dev/null +++ b/src/HotChocolate/Core/src/Execution/Processing/IAsyncTransactionScope.cs @@ -0,0 +1,12 @@ +namespace HotChocolate.Execution.Processing; + +/// +/// Represents a mutation transaction scope. +/// +public interface IAsyncTransactionScope : IAsyncDisposable +{ + /// + /// Completes a transaction (commits or discards the changes). + /// + ValueTask CompleteAsync(); +} diff --git a/src/HotChocolate/Core/src/Execution/Processing/IAsyncTransactionScopeHandler.cs b/src/HotChocolate/Core/src/Execution/Processing/IAsyncTransactionScopeHandler.cs new file mode 100644 index 00000000000..a59086fa408 --- /dev/null +++ b/src/HotChocolate/Core/src/Execution/Processing/IAsyncTransactionScopeHandler.cs @@ -0,0 +1,19 @@ +namespace HotChocolate.Execution.Processing; + +/// +/// Allows to make mutation execution transactional. +/// +public interface IAsyncTransactionScopeHandler +{ + /// + /// Creates a new transaction scope for the current + /// request represented by the . + /// + /// + /// The GraphQL request context. + /// + /// + /// Returns a new . + /// + Task CreateAsync(IRequestContext context); +} diff --git a/src/HotChocolate/Core/src/Execution/Processing/NoOpTransactionScope.cs b/src/HotChocolate/Core/src/Execution/Processing/NoOpTransactionScope.cs index 8d0a783ca05..70b8ae0458a 100644 --- a/src/HotChocolate/Core/src/Execution/Processing/NoOpTransactionScope.cs +++ b/src/HotChocolate/Core/src/Execution/Processing/NoOpTransactionScope.cs @@ -1,15 +1,18 @@ + namespace HotChocolate.Execution.Processing; /// /// This transaction scope represents a non transactional mutation transaction scope. /// -internal sealed class NoOpTransactionScope : ITransactionScope +internal sealed class NoOpTransactionScope : IAsyncTransactionScope { - public void Complete() + public ValueTask CompleteAsync() { + return ValueTask.CompletedTask; } - public void Dispose() + public ValueTask DisposeAsync() { + return ValueTask.CompletedTask; } } diff --git a/src/HotChocolate/Core/src/Execution/Processing/NoOpTransactionScopeHandler.cs b/src/HotChocolate/Core/src/Execution/Processing/NoOpTransactionScopeHandler.cs index babf143e07b..ed0deebd0aa 100644 --- a/src/HotChocolate/Core/src/Execution/Processing/NoOpTransactionScopeHandler.cs +++ b/src/HotChocolate/Core/src/Execution/Processing/NoOpTransactionScopeHandler.cs @@ -1,12 +1,14 @@ + namespace HotChocolate.Execution.Processing; /// /// This transaction scope handler represents creates /// a non transactional mutation transaction scope. /// -internal sealed class NoOpTransactionScopeHandler : ITransactionScopeHandler +internal sealed class NoOpTransactionScopeHandler : IAsyncTransactionScopeHandler { private readonly NoOpTransactionScope _noOpTransaction = new(); - public ITransactionScope Create(IRequestContext context) => _noOpTransaction; + Task IAsyncTransactionScopeHandler.CreateAsync(IRequestContext context) + => Task.FromResult(_noOpTransaction); } diff --git a/src/HotChocolate/Core/src/Execution/Processing/TransactionScopeAsyncAdapter.cs b/src/HotChocolate/Core/src/Execution/Processing/TransactionScopeAsyncAdapter.cs new file mode 100644 index 00000000000..a1111656d33 --- /dev/null +++ b/src/HotChocolate/Core/src/Execution/Processing/TransactionScopeAsyncAdapter.cs @@ -0,0 +1,23 @@ +namespace HotChocolate.Execution.Processing; + +internal class TransactionScopeAsyncAdapter : IAsyncTransactionScope +{ + private readonly ITransactionScope _transactionScope; + + public TransactionScopeAsyncAdapter(ITransactionScope transactionScope) + { + _transactionScope = transactionScope; + } + + public ValueTask CompleteAsync() + { + _transactionScope.Complete(); + return ValueTask.CompletedTask; + } + + public ValueTask DisposeAsync() + { + _transactionScope.Dispose(); + return ValueTask.CompletedTask; + } +} diff --git a/src/HotChocolate/Core/src/Execution/Processing/TransactionScopeHandlerAsyncAdapter.cs b/src/HotChocolate/Core/src/Execution/Processing/TransactionScopeHandlerAsyncAdapter.cs new file mode 100644 index 00000000000..8b55e13d066 --- /dev/null +++ b/src/HotChocolate/Core/src/Execution/Processing/TransactionScopeHandlerAsyncAdapter.cs @@ -0,0 +1,17 @@ +namespace HotChocolate.Execution.Processing; + +public class TransactionScopeHandlerAsyncAdapter : IAsyncTransactionScopeHandler +{ + private readonly ITransactionScopeHandler _transactionScopeHandler; + + public TransactionScopeHandlerAsyncAdapter(ITransactionScopeHandler transactionScopeHandler) + { + _transactionScopeHandler = transactionScopeHandler; + } + + public Task CreateAsync(IRequestContext context) + { + IAsyncTransactionScope asyncTransactionScope = new TransactionScopeAsyncAdapter(_transactionScopeHandler.Create(context)); + return Task.FromResult(asyncTransactionScope); + } +} From f54a2a27dfb730c9bf5e8e35c64bab7987cdcac6 Mon Sep 17 00:00:00 2001 From: Roman Date: Thu, 3 Apr 2025 20:23:17 +0200 Subject: [PATCH 2/4] add tests --- ...cutorBuilderExtensions.TransactionScope.cs | 33 ++++++++- .../Pipeline/TransactionScopeHandlerTests.cs | 70 ++++++++++++++++++- 2 files changed, 101 insertions(+), 2 deletions(-) diff --git a/src/HotChocolate/Core/src/Execution/DependencyInjection/RequestExecutorBuilderExtensions.TransactionScope.cs b/src/HotChocolate/Core/src/Execution/DependencyInjection/RequestExecutorBuilderExtensions.TransactionScope.cs index 51ed342119e..1ab0095e382 100644 --- a/src/HotChocolate/Core/src/Execution/DependencyInjection/RequestExecutorBuilderExtensions.TransactionScope.cs +++ b/src/HotChocolate/Core/src/Execution/DependencyInjection/RequestExecutorBuilderExtensions.TransactionScope.cs @@ -113,7 +113,38 @@ public static IRequestExecutorBuilder AddTransactionScopeHandler( builder, services => { - services.RemoveAll(typeof(ITransactionScopeHandler)); + services.RemoveAll(typeof(IAsyncTransactionScopeHandler)); + services.AddSingleton(sp + => new TransactionScopeHandlerAsyncAdapter(create(sp.GetCombinedServices()))); + }); + } + + /// + /// Adds a custom transaction scope handler to the schema. + /// + /// + /// The request executor builder. + /// + /// + /// A factory to create the transaction scope. + /// + /// + /// The request executor builder. + /// + /// + public static IRequestExecutorBuilder AddAsyncTransactionScopeHandler( + this IRequestExecutorBuilder builder, + Func create) + { + if (builder is null) + { + throw new ArgumentNullException(nameof(builder)); + } + + return ConfigureSchemaServices( + builder, + services => + { services.RemoveAll(typeof(IAsyncTransactionScopeHandler)); services.AddSingleton(sp => create(sp.GetCombinedServices())); }); diff --git a/src/HotChocolate/Core/test/Execution.Tests/Pipeline/TransactionScopeHandlerTests.cs b/src/HotChocolate/Core/test/Execution.Tests/Pipeline/TransactionScopeHandlerTests.cs index 84f2ebd0e3b..ac04ea1f5ce 100644 --- a/src/HotChocolate/Core/test/Execution.Tests/Pipeline/TransactionScopeHandlerTests.cs +++ b/src/HotChocolate/Core/test/Execution.Tests/Pipeline/TransactionScopeHandlerTests.cs @@ -47,6 +47,47 @@ public async Task Custom_Transaction_Is_Detects_Error_and_Disposes() Assert.True(disposed, "transaction must be disposed"); } + [Fact] + public async Task Custom_Async_Transaction_Is_Correctly_Completed_and_Disposed() + { + var completed = false; + var disposed = false; + + void Complete() => completed = true; + void Dispose() => disposed = true; + + await new ServiceCollection() + .AddGraphQL() + .AddQueryType() + .AddMutationType() + .ModifyRequestOptions(o => o.ExecutionTimeout = TimeSpan.FromMilliseconds(100)) + .AddAsyncTransactionScopeHandler(_ => new MockAsyncTransactionScopeHandler(Complete, Dispose)) + .ExecuteRequestAsync("mutation { doNothing }"); + + Assert.True(completed, "transaction must be completed"); + Assert.True(disposed, "transaction must be disposed"); + } + + [Fact] + public async Task Custom_Async_Transaction_Is_Detects_Error_and_Disposes() + { + var completed = false; + var disposed = false; + + void Complete() => completed = true; + void Dispose() => disposed = true; + + await new ServiceCollection() + .AddGraphQL() + .AddQueryType() + .AddMutationType() + .AddAsyncTransactionScopeHandler(_ => new MockAsyncTransactionScopeHandler(Complete, Dispose)) + .ExecuteRequestAsync("mutation { doError }"); + + Assert.False(completed, "transaction was not completed due to error"); + Assert.True(disposed, "transaction must be disposed"); + } + [Fact] public async Task DefaultTransactionScopeHandler_Creates_SystemTransactionScope() { @@ -95,7 +136,7 @@ public class MockTransactionScope(Action complete, Action dispose, IRequestConte { public void Complete() { - if(context.Result is IOperationResult { Data: not null, Errors: null or { Count: 0, }, }) + if (context.Result is IOperationResult { Data: not null, Errors: null or { Count: 0, }, }) { complete(); } @@ -103,4 +144,31 @@ public void Complete() public void Dispose() => dispose(); } + + public class MockAsyncTransactionScopeHandler(Action complete, Action dispose) : IAsyncTransactionScopeHandler + { + public ITransactionScope Create(IRequestContext context) + => new MockTransactionScope(complete, dispose, context); + + public Task CreateAsync(IRequestContext context) + => Task.FromResult(new MockAsyncTransactionScope(complete, dispose, context)); + } + + public class MockAsyncTransactionScope(Action complete, Action dispose, IRequestContext context) : IAsyncTransactionScope + { + public ValueTask CompleteAsync() + { + if (context.Result is IOperationResult { Data: not null, Errors: null or { Count: 0, }, }) + { + complete(); + } + return ValueTask.CompletedTask; + } + + public ValueTask DisposeAsync() + { + dispose(); + return ValueTask.CompletedTask; + } + } } From 4bd778acb1399d98c69f1fd8976d2a71fa87555b Mon Sep 17 00:00:00 2001 From: Roman Date: Thu, 3 Apr 2025 20:26:01 +0200 Subject: [PATCH 3/4] formatting --- .../RequestExecutorBuilderExtensions.TransactionScope.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/HotChocolate/Core/src/Execution/DependencyInjection/RequestExecutorBuilderExtensions.TransactionScope.cs b/src/HotChocolate/Core/src/Execution/DependencyInjection/RequestExecutorBuilderExtensions.TransactionScope.cs index 1ab0095e382..49835b8b08a 100644 --- a/src/HotChocolate/Core/src/Execution/DependencyInjection/RequestExecutorBuilderExtensions.TransactionScope.cs +++ b/src/HotChocolate/Core/src/Execution/DependencyInjection/RequestExecutorBuilderExtensions.TransactionScope.cs @@ -114,8 +114,8 @@ public static IRequestExecutorBuilder AddTransactionScopeHandler( services => { services.RemoveAll(typeof(IAsyncTransactionScopeHandler)); - services.AddSingleton(sp - => new TransactionScopeHandlerAsyncAdapter(create(sp.GetCombinedServices()))); + services.AddSingleton( + sp => new TransactionScopeHandlerAsyncAdapter(create(sp.GetCombinedServices()))); }); } From 77227d257debeca98cff5ae5e78077b768af31a7 Mon Sep 17 00:00:00 2001 From: Roman Date: Thu, 3 Apr 2025 20:30:25 +0200 Subject: [PATCH 4/4] format --- .../Execution.Tests/Pipeline/TransactionScopeHandlerTests.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/HotChocolate/Core/test/Execution.Tests/Pipeline/TransactionScopeHandlerTests.cs b/src/HotChocolate/Core/test/Execution.Tests/Pipeline/TransactionScopeHandlerTests.cs index ac04ea1f5ce..c8a9a1fc5ea 100644 --- a/src/HotChocolate/Core/test/Execution.Tests/Pipeline/TransactionScopeHandlerTests.cs +++ b/src/HotChocolate/Core/test/Execution.Tests/Pipeline/TransactionScopeHandlerTests.cs @@ -136,7 +136,7 @@ public class MockTransactionScope(Action complete, Action dispose, IRequestConte { public void Complete() { - if (context.Result is IOperationResult { Data: not null, Errors: null or { Count: 0, }, }) + if(context.Result is IOperationResult { Data: not null, Errors: null or { Count: 0, }, }) { complete(); } @@ -158,7 +158,7 @@ public class MockAsyncTransactionScope(Action complete, Action dispose, IRequest { public ValueTask CompleteAsync() { - if (context.Result is IOperationResult { Data: not null, Errors: null or { Count: 0, }, }) + if(context.Result is IOperationResult { Data: not null, Errors: null or { Count: 0, }, }) { complete(); }