Skip to content

Feature: async transaction scope support #8227

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Draft
wants to merge 5 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -39,10 +39,50 @@ public static IRequestExecutorBuilder AddTransactionScopeHandler<T>(
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<ITransactionScopeHandler>(
services.AddSingleton<IAsyncTransactionScopeHandler>(
s => new TransactionScopeHandlerAsyncAdapter(s.GetApplicationServices().GetRequiredService<T>()));
});
}

/// <summary>
/// Adds a custom transaction scope handler to the schema.
/// </summary>
/// <param name="builder">
/// The request executor builder.
/// </param>
/// <typeparam name="T">
/// The concrete type of the transaction scope handler.
/// </typeparam>
/// <returns>
/// The request executor builder.
/// </returns>
/// <exception cref="ArgumentNullException">
/// The <paramref name="builder"/> is <c>null</c>.
/// </exception>
public static IRequestExecutorBuilder AddAsyncTransactionScopeHandler<T>(
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<T>();

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<IAsyncTransactionScopeHandler>(
s => s.GetApplicationServices().GetRequiredService<T>());
});
}
Expand Down Expand Up @@ -73,7 +113,39 @@ public static IRequestExecutorBuilder AddTransactionScopeHandler(
builder,
services =>
{
services.RemoveAll(typeof(ITransactionScopeHandler));
services.RemoveAll(typeof(IAsyncTransactionScopeHandler));
services.AddSingleton<IAsyncTransactionScopeHandler>(
sp => new TransactionScopeHandlerAsyncAdapter(create(sp.GetCombinedServices())));
});
}

/// <summary>
/// Adds a custom transaction scope handler to the schema.
/// </summary>
/// <param name="builder">
/// The request executor builder.
/// </param>
/// <param name="create">
/// A factory to create the transaction scope.
/// </param>
/// <returns>
/// The request executor builder.
/// </returns>
/// <exception cref="ArgumentNullException"></exception>
public static IRequestExecutorBuilder AddAsyncTransactionScopeHandler(
this IRequestExecutorBuilder builder,
Func<IServiceProvider, IAsyncTransactionScopeHandler> create)
{
if (builder is null)
{
throw new ArgumentNullException(nameof(builder));
}

return ConfigureSchemaServices(
builder,
services =>
{
services.RemoveAll(typeof(IAsyncTransactionScopeHandler));
services.AddSingleton(sp => create(sp.GetCombinedServices()));
});
}
Expand Down Expand Up @@ -111,7 +183,7 @@ internal static IRequestExecutorBuilder TryAddNoOpTransactionScopeHandler(
builder,
services =>
{
services.TryAddSingleton<ITransactionScopeHandler>(
services.TryAddSingleton<IAsyncTransactionScopeHandler>(
sp => sp.GetApplicationService<NoOpTransactionScopeHandler>());
});
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ internal sealed class OperationExecutionMiddleware
private readonly IFactory<OperationContextOwner> _contextFactory;
private readonly QueryExecutor _queryExecutor;
private readonly SubscriptionExecutor _subscriptionExecutor;
private readonly ITransactionScopeHandler _transactionScopeHandler;
private readonly IAsyncTransactionScopeHandler _transactionScopeHandler;
private object? _cachedQuery;
private object? _cachedMutation;

Expand All @@ -23,7 +23,7 @@ private OperationExecutionMiddleware(
IFactory<OperationContextOwner> contextFactory,
[SchemaService] QueryExecutor queryExecutor,
[SchemaService] SubscriptionExecutor subscriptionExecutor,
[SchemaService] ITransactionScopeHandler transactionScopeHandler)
[SchemaService] IAsyncTransactionScopeHandler transactionScopeHandler)
{
_next = next ??
throw new ArgumentNullException(nameof(next));
Expand Down Expand Up @@ -233,7 +233,7 @@ private async Task<IOperationResult> 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);

Expand All @@ -253,7 +253,7 @@ private async Task<IOperationResult> ExecuteQueryOrMutationAsync(
context.Result = result;

// we complete the transaction scope and are done.
transactionScope.Complete();
await transactionScope.CompleteAsync();
return result;
}

Expand Down Expand Up @@ -316,7 +316,8 @@ public static RequestCoreMiddleware Create()
var contextFactory = core.Services.GetRequiredService<IFactory<OperationContextOwner>>();
var queryExecutor = core.SchemaServices.GetRequiredService<QueryExecutor>();
var subscriptionExecutor = core.SchemaServices.GetRequiredService<SubscriptionExecutor>();
var transactionScopeHandler = core.SchemaServices.GetRequiredService<ITransactionScopeHandler>();
var transactionScopeHandler = core.SchemaServices.GetRequiredService<IAsyncTransactionScopeHandler>();

var middleware = new OperationExecutionMiddleware(
next,
contextFactory,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
namespace HotChocolate.Execution.Processing;

/// <summary>
/// Represents a mutation transaction scope.
/// </summary>
public interface IAsyncTransactionScope : IAsyncDisposable
{
/// <summary>
/// Completes a transaction (commits or discards the changes).
/// </summary>
ValueTask CompleteAsync();
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
namespace HotChocolate.Execution.Processing;

/// <summary>
/// Allows to make mutation execution transactional.
/// </summary>
public interface IAsyncTransactionScopeHandler
{
/// <summary>
/// Creates a new transaction scope for the current
/// request represented by the <see cref="IRequestContext"/>.
/// </summary>
/// <param name="context">
/// The GraphQL request context.
/// </param>
/// <returns>
/// Returns a new <see cref="ITransactionScope"/>.
/// </returns>
Task<IAsyncTransactionScope> CreateAsync(IRequestContext context);
}
Original file line number Diff line number Diff line change
@@ -1,15 +1,18 @@

namespace HotChocolate.Execution.Processing;

/// <summary>
/// This transaction scope represents a non transactional mutation transaction scope.
/// </summary>
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;
}
}
Original file line number Diff line number Diff line change
@@ -1,12 +1,14 @@

namespace HotChocolate.Execution.Processing;

/// <summary>
/// This transaction scope handler represents creates
/// a non transactional mutation transaction scope.
/// </summary>
internal sealed class NoOpTransactionScopeHandler : ITransactionScopeHandler
internal sealed class NoOpTransactionScopeHandler : IAsyncTransactionScopeHandler
{
private readonly NoOpTransactionScope _noOpTransaction = new();

public ITransactionScope Create(IRequestContext context) => _noOpTransaction;
Task<IAsyncTransactionScope> IAsyncTransactionScopeHandler.CreateAsync(IRequestContext context)
=> Task.FromResult<IAsyncTransactionScope>(_noOpTransaction);
}
Original file line number Diff line number Diff line change
@@ -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;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
namespace HotChocolate.Execution.Processing;

public class TransactionScopeHandlerAsyncAdapter : IAsyncTransactionScopeHandler
{
private readonly ITransactionScopeHandler _transactionScopeHandler;

public TransactionScopeHandlerAsyncAdapter(ITransactionScopeHandler transactionScopeHandler)
{
_transactionScopeHandler = transactionScopeHandler;
}

public Task<IAsyncTransactionScope> CreateAsync(IRequestContext context)
{
IAsyncTransactionScope asyncTransactionScope = new TransactionScopeAsyncAdapter(_transactionScopeHandler.Create(context));
return Task.FromResult(asyncTransactionScope);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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<Query>()
.AddMutationType<Mutation>()
.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<Query>()
.AddMutationType<Mutation>()
.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()
{
Expand Down Expand Up @@ -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<IAsyncTransactionScope> CreateAsync(IRequestContext context)
=> Task.FromResult<IAsyncTransactionScope>(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;
}
}
}