diff --git a/docs/guide/durability/efcore/transactional-middleware.md b/docs/guide/durability/efcore/transactional-middleware.md index a38417d50..ab30c22ea 100644 --- a/docs/guide/durability/efcore/transactional-middleware.md +++ b/docs/guide/durability/efcore/transactional-middleware.md @@ -101,3 +101,52 @@ await host.StartAsync(); With this option, you will no longer need to decorate handler methods with the `[Transactional]` attribute. +## Transaction Middleware Mode + +By default, the EF Core transactional middleware uses `TransactionMiddlewareMode.Eager`, which eagerly opens an +explicit database transaction via `Database.BeginTransactionAsync()` before the handler executes. This is appropriate +when you need explicit transaction control, such as when using EF Core bulk operations. + +If you prefer to rely solely on `DbContext.SaveChangesAsync()` as your transactional boundary without opening an +explicit database transaction, you can use `TransactionMiddlewareMode.Lightweight`: + +```cs +builder.Host.UseWolverine(opts => +{ + opts.PersistMessagesWithSqlServer(connectionString, "wolverine"); + + // Use Lightweight mode — no explicit transaction, relies on SaveChangesAsync() + opts.UseEntityFrameworkCoreTransactions(TransactionMiddlewareMode.Lightweight); + + opts.Policies.UseDurableLocalQueues(); +}); +``` + +::: tip +`TransactionMiddlewareMode.Lightweight` is **not** supported or necessary for Marten or RavenDb, which have their own +unit of work implementations. +::: + +### Per-Handler Override + +You can override the global `TransactionMiddlewareMode` for individual handlers using the `[Transactional]` attribute's +`Mode` property: + +```cs +// This handler will use an explicit transaction even if the global mode is Lightweight +[Transactional(Mode = TransactionMiddlewareMode.Eager)] +public static ItemCreated Handle(CreateItemCommand command, ItemsDbContext db) +{ + var item = new Item { Name = command.Name }; + db.Items.Add(item); + return new ItemCreated { Id = item.Id }; +} + +// This handler skips the explicit transaction even if the global mode is Eager +[Transactional(Mode = TransactionMiddlewareMode.Lightweight)] +public static void Handle(UpdateItemCommand command, ItemsDbContext db) +{ + // Just uses SaveChangesAsync() without an explicit transaction +} +``` + diff --git a/src/Persistence/EfCoreTests/transaction_middleware_mode_tests.cs b/src/Persistence/EfCoreTests/transaction_middleware_mode_tests.cs new file mode 100644 index 000000000..acc9c0fed --- /dev/null +++ b/src/Persistence/EfCoreTests/transaction_middleware_mode_tests.cs @@ -0,0 +1,244 @@ +using IntegrationTests; +using JasperFx.CodeGeneration.Frames; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Hosting; +using Shouldly; +using Wolverine; +using Wolverine.Attributes; +using Wolverine.EntityFrameworkCore; +using Wolverine.EntityFrameworkCore.Codegen; +using Wolverine.Persistence; +using Wolverine.SqlServer; +using Wolverine.Tracking; + +namespace EfCoreTests; + +[Collection("sqlserver")] +public class transaction_middleware_mode_tests +{ + [Fact] + public async Task eager_mode_should_add_transaction_frame() + { + using var host = await Host.CreateDefaultBuilder() + .UseWolverine(opts => + { + opts.Durability.Mode = DurabilityMode.Solo; + + opts.Services.AddDbContextWithWolverineIntegration(x => + x.UseSqlServer(Servers.SqlServerConnectionString)); + + opts.PersistMessagesWithSqlServer(Servers.SqlServerConnectionString, "txmode"); + opts.UseEntityFrameworkCoreTransactions(TransactionMiddlewareMode.Eager); + opts.Policies.AutoApplyTransactions(); + + opts.Discovery.DisableConventionalDiscovery() + .IncludeType(); + }).StartAsync(); + + var chain = host.GetRuntime().Handlers.ChainFor(); + + chain.Middleware.OfType().ShouldNotBeEmpty(); + + chain.Postprocessors.OfType() + .Any(x => x.Method.Name == nameof(DbContext.SaveChangesAsync)) + .ShouldBeTrue(); + } + + [Fact] + public async Task lightweight_mode_should_not_add_transaction_frame() + { + using var host = await Host.CreateDefaultBuilder() + .UseWolverine(opts => + { + opts.Durability.Mode = DurabilityMode.Solo; + + opts.Services.AddDbContextWithWolverineIntegration(x => + x.UseSqlServer(Servers.SqlServerConnectionString)); + + opts.PersistMessagesWithSqlServer(Servers.SqlServerConnectionString, "txmode"); + opts.UseEntityFrameworkCoreTransactions(TransactionMiddlewareMode.Lightweight); + opts.Policies.AutoApplyTransactions(); + + opts.Discovery.DisableConventionalDiscovery() + .IncludeType(); + }).StartAsync(); + + var chain = host.GetRuntime().Handlers.ChainFor(); + + chain.Middleware.OfType().ShouldBeEmpty(); + chain.Middleware.OfType().ShouldBeEmpty(); + + chain.Postprocessors.OfType() + .Any(x => x.Method.Name == nameof(DbContext.SaveChangesAsync)) + .ShouldBeTrue(); + } + + [Fact] + public async Task transactional_attribute_lightweight_overrides_eager_default() + { + using var host = await Host.CreateDefaultBuilder() + .UseWolverine(opts => + { + opts.Durability.Mode = DurabilityMode.Solo; + + opts.Services.AddDbContextWithWolverineIntegration(x => + x.UseSqlServer(Servers.SqlServerConnectionString)); + + opts.PersistMessagesWithSqlServer(Servers.SqlServerConnectionString, "txmode"); + opts.UseEntityFrameworkCoreTransactions(TransactionMiddlewareMode.Eager); + opts.Policies.AutoApplyTransactions(); + + opts.Discovery.DisableConventionalDiscovery() + .IncludeType() + .IncludeType(); + }).StartAsync(); + + // Verify the auto-applied handler uses the Eager default + var eagerChain = host.GetRuntime().Handlers.ChainFor(); + eagerChain.Middleware.OfType().ShouldNotBeEmpty(); + + // Force compilation of the [Transactional] chain by triggering HandlerFor + host.GetRuntime().Handlers.HandlerFor(); + var chain = host.GetRuntime().Handlers.ChainFor(); + + // The attribute overrides to Lightweight, so no transaction frame + chain.IsTransactional.ShouldBeTrue(); + chain.Middleware.OfType().ShouldBeEmpty(); + chain.Middleware.OfType().ShouldBeEmpty(); + + chain.Postprocessors.OfType() + .Any(x => x.Method.Name == nameof(DbContext.SaveChangesAsync)) + .ShouldBeTrue(); + } + + [Fact] + public async Task transactional_attribute_eager_overrides_lightweight_default() + { + using var host = await Host.CreateDefaultBuilder() + .UseWolverine(opts => + { + opts.Durability.Mode = DurabilityMode.Solo; + + opts.Services.AddDbContextWithWolverineIntegration(x => + x.UseSqlServer(Servers.SqlServerConnectionString)); + + opts.PersistMessagesWithSqlServer(Servers.SqlServerConnectionString, "txmode"); + opts.UseEntityFrameworkCoreTransactions(TransactionMiddlewareMode.Lightweight); + opts.Policies.AutoApplyTransactions(); + + opts.Discovery.DisableConventionalDiscovery() + .IncludeType() + .IncludeType(); + }).StartAsync(); + + // Verify the auto-applied handler uses the Lightweight default + var lightChain = host.GetRuntime().Handlers.ChainFor(); + lightChain.Middleware.OfType().ShouldBeEmpty(); + + // Force compilation of the [Transactional] chain by triggering HandlerFor + host.GetRuntime().Handlers.HandlerFor(); + var chain = host.GetRuntime().Handlers.ChainFor(); + + // The attribute overrides to Eager, so transaction frame should be present + chain.IsTransactional.ShouldBeTrue(); + chain.Middleware.OfType().ShouldNotBeEmpty(); + + chain.Postprocessors.OfType() + .Any(x => x.Method.Name == nameof(DbContext.SaveChangesAsync)) + .ShouldBeTrue(); + } + + [Fact] + public async Task default_mode_is_eager() + { + using var host = await Host.CreateDefaultBuilder() + .UseWolverine(opts => + { + opts.Durability.Mode = DurabilityMode.Solo; + + opts.Services.AddDbContextWithWolverineIntegration(x => + x.UseSqlServer(Servers.SqlServerConnectionString)); + + opts.PersistMessagesWithSqlServer(Servers.SqlServerConnectionString, "txmode"); + opts.UseEntityFrameworkCoreTransactions(); + opts.Policies.AutoApplyTransactions(); + + opts.Discovery.DisableConventionalDiscovery() + .IncludeType(); + }).StartAsync(); + + var chain = host.GetRuntime().Handlers.ChainFor(); + + // Default should be Eager + chain.Middleware.OfType().ShouldNotBeEmpty(); + } +} + +#region test message types and handlers + +public record EagerModeMessage; + +public class EagerModeHandler +{ + public static void Handle(EagerModeMessage message, CleanDbContext db) + { + } +} + +public record LightweightModeMessage; + +public class LightweightModeHandler +{ + public static void Handle(LightweightModeMessage message, CleanDbContext db) + { + } +} + +public record LightweightAttributeMessage; + +public class LightweightAttributeHandler +{ + [Transactional(Mode = TransactionMiddlewareMode.Lightweight)] + public static void Handle(LightweightAttributeMessage message, CleanDbContext db) + { + } +} + +public record EagerAttributeMessage; + +public class EagerAttributeHandler +{ + [Transactional(Mode = TransactionMiddlewareMode.Eager)] + public static void Handle(EagerAttributeMessage message, CleanDbContext db) + { + } +} + +public record DefaultModeMessage; + +public class DefaultModeHandler +{ + public static void Handle(DefaultModeMessage message, CleanDbContext db) + { + } +} + +public record EagerAutoApplyMessage; + +public class EagerAutoApplyHandler +{ + public static void Handle(EagerAutoApplyMessage message, CleanDbContext db) + { + } +} + +public record LightweightAutoApplyMessage; + +public class LightweightAutoApplyHandler +{ + public static void Handle(LightweightAutoApplyMessage message, CleanDbContext db) + { + } +} + +#endregion diff --git a/src/Persistence/Wolverine.EntityFrameworkCore/Codegen/EFCorePersistenceFrameProvider.cs b/src/Persistence/Wolverine.EntityFrameworkCore/Codegen/EFCorePersistenceFrameProvider.cs index edb7ef3ec..c786b8bed 100644 --- a/src/Persistence/Wolverine.EntityFrameworkCore/Codegen/EFCorePersistenceFrameProvider.cs +++ b/src/Persistence/Wolverine.EntityFrameworkCore/Codegen/EFCorePersistenceFrameProvider.cs @@ -21,8 +21,11 @@ namespace Wolverine.EntityFrameworkCore.Codegen; internal class EFCorePersistenceFrameProvider : IPersistenceFrameProvider { public const string UsingEfCoreTransaction = "uses_efcore_transaction"; + public const string TransactionModeKey = "TransactionMiddlewareMode"; private ImHashMap _dbContextTypes = ImHashMap.Empty; + public TransactionMiddlewareMode DefaultMode { get; set; } = TransactionMiddlewareMode.Eager; + public bool CanPersist(Type entityType, IServiceContainer container, out Type persistenceService) { var dbContextType = TryDetermineDbContextType(entityType, container); @@ -121,23 +124,32 @@ public void ApplyTransactionSupport(IChain chain, IServiceContainer container) chain.Tags.Add(UsingEfCoreTransaction, true); var dbContextType = DetermineDbContextType(chain, container); + + // Resolve effective mode: per-chain override from [Transactional] attribute, or default + var mode = chain.Tags.TryGetValue(TransactionModeKey, out var modeObj) + ? (TransactionMiddlewareMode)modeObj + : DefaultMode; + var runtime = container.Services.GetRequiredService(); if (runtime.Stores.HasAncillaryStoreFor(dbContextType)) { var frame = typeof(ApplyAncillaryStoreFrame<>).CloseAndBuildAs(dbContextType); chain.Middleware.Insert(0, frame); } - - if (isMultiTenanted(container, dbContextType)) - { - var createContext = typeof(CreateTenantedDbContext<>).CloseAndBuildAs(dbContextType); - chain.Middleware.Insert(0, createContext); - chain.Middleware.Insert(0, new StartDatabaseTransactionForDbContext(dbContextType, chain.Idempotency)); - } - else + if (mode == TransactionMiddlewareMode.Eager) { - chain.Middleware.Insert(0, new EnrollDbContextInTransaction(dbContextType, chain.Idempotency)); + if (isMultiTenanted(container, dbContextType)) + { + var createContext = typeof(CreateTenantedDbContext<>).CloseAndBuildAs(dbContextType); + + chain.Middleware.Insert(0, createContext); + chain.Middleware.Insert(0, new StartDatabaseTransactionForDbContext(dbContextType, chain.Idempotency)); + } + else + { + chain.Middleware.Insert(0, new EnrollDbContextInTransaction(dbContextType, chain.Idempotency)); + } } var saveChangesAsync = @@ -169,17 +181,26 @@ public void ApplyTransactionSupport(IChain chain, IServiceContainer container, T chain.Tags.Add(UsingEfCoreTransaction, true); var dbType = DetermineDbContextType(entityType, container); - if (isMultiTenanted(container, dbType)) - { - var createContext = typeof(CreateTenantedDbContext<>).CloseAndBuildAs(dbType); - chain.Middleware.Insert(0, createContext); - chain.Middleware.Insert(0, new StartDatabaseTransactionForDbContext(dbType, chain.Idempotency)); - } - else + + // Resolve effective mode: per-chain override from [Transactional] attribute, or default + var mode = chain.Tags.TryGetValue(TransactionModeKey, out var modeObj) + ? (TransactionMiddlewareMode)modeObj + : DefaultMode; + + if (mode == TransactionMiddlewareMode.Eager) { - chain.Middleware.Insert(0, new EnrollDbContextInTransaction(dbType, chain.Idempotency)); + if (isMultiTenanted(container, dbType)) + { + var createContext = typeof(CreateTenantedDbContext<>).CloseAndBuildAs(dbType); + chain.Middleware.Insert(0, createContext); + chain.Middleware.Insert(0, new StartDatabaseTransactionForDbContext(dbType, chain.Idempotency)); + } + else + { + chain.Middleware.Insert(0, new EnrollDbContextInTransaction(dbType, chain.Idempotency)); + } } - + var saveChangesAsync = dbType.GetMethod(nameof(DbContext.SaveChangesAsync), [typeof(CancellationToken)]); diff --git a/src/Persistence/Wolverine.EntityFrameworkCore/WolverineEntityCoreExtensions.cs b/src/Persistence/Wolverine.EntityFrameworkCore/WolverineEntityCoreExtensions.cs index 1328fb3d0..c1f3025b8 100644 --- a/src/Persistence/Wolverine.EntityFrameworkCore/WolverineEntityCoreExtensions.cs +++ b/src/Persistence/Wolverine.EntityFrameworkCore/WolverineEntityCoreExtensions.cs @@ -8,9 +8,12 @@ using Microsoft.EntityFrameworkCore.Infrastructure; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection.Extensions; +using Wolverine.EntityFrameworkCore.Codegen; using Wolverine.EntityFrameworkCore.Internals; using Wolverine.EntityFrameworkCore.Internals.Migrations; +using Wolverine.Persistence; using Wolverine.Persistence.Durability; +using Wolverine.Persistence.Sagas; using Wolverine.RDBMS; using Wolverine.Runtime; @@ -172,10 +175,24 @@ private static IServiceCollection addDbContextWithWolverineIntegration(IServi /// /// Uses Entity Framework Core for Saga persistence and transactional - /// middleware + /// middleware using mode by default. /// /// public static void UseEntityFrameworkCoreTransactions(this WolverineOptions options) + { + options.UseEntityFrameworkCoreTransactions(TransactionMiddlewareMode.Eager); + } + + /// + /// Uses Entity Framework Core for Saga persistence and transactional + /// middleware with the specified . + /// opens an explicit database transaction immediately. + /// only relies on DbContext.SaveChangesAsync() + /// without opening an explicit transaction. + /// + /// + /// The transaction middleware mode to use + public static void UseEntityFrameworkCoreTransactions(this WolverineOptions options, TransactionMiddlewareMode mode) { try { @@ -191,8 +208,15 @@ public static void UseEntityFrameworkCoreTransactions(this WolverineOptions opti throw; } } - + options.Include(); + + var providers = options.CodeGeneration.PersistenceProviders(); + var efProvider = providers.OfType().FirstOrDefault(); + if (efProvider != null) + { + efProvider.DefaultMode = mode; + } } /// diff --git a/src/Wolverine/Attributes/TransactionalAttribute.cs b/src/Wolverine/Attributes/TransactionalAttribute.cs index c4eecf2d0..bcb939aac 100644 --- a/src/Wolverine/Attributes/TransactionalAttribute.cs +++ b/src/Wolverine/Attributes/TransactionalAttribute.cs @@ -14,22 +14,45 @@ namespace Wolverine.Attributes; /// public class TransactionalAttribute : ModifyChainAttribute { + private bool _modeExplicitlySet; + public override void Modify(IChain chain, GenerationRules rules, IServiceContainer container) { if (Idempotency.HasValue) { chain.Idempotency = Idempotency.Value; } - + + if (_modeExplicitlySet) + { + chain.Tags["TransactionMiddlewareMode"] = Mode; + } + chain.ApplyImpliedMiddlewareFromHandlers(rules); var transactionFrameProvider = rules.As().GetPersistenceProviders(chain, container); transactionFrameProvider.ApplyTransactionSupport(chain, container); chain.IsTransactional = true; } - + public IdempotencyStyle? Idempotency { get; set; } + /// + /// Optionally override the for just this handler chain. + /// When set, this takes precedence over the global mode configured in + /// UseEntityFrameworkCoreTransactions(). + /// + public TransactionMiddlewareMode Mode + { + get => _mode; + set + { + _mode = value; + _modeExplicitlySet = true; + } + } + private TransactionMiddlewareMode _mode; + public TransactionalAttribute() { } diff --git a/src/Wolverine/Persistence/TransactionMiddlewareMode.cs b/src/Wolverine/Persistence/TransactionMiddlewareMode.cs new file mode 100644 index 000000000..53d02db96 --- /dev/null +++ b/src/Wolverine/Persistence/TransactionMiddlewareMode.cs @@ -0,0 +1,17 @@ +namespace Wolverine.Persistence; + +public enum TransactionMiddlewareMode +{ + /// + /// Start a native database transaction immediately when starting the message handling or HTTP request handling. + /// Use this for tools like EF Core that may require an explicit transaction for bulk operations + /// + /// Not supported or necessary for Marten or RavenDb + /// + Eager, + + /// + /// Only rely on the underlying persistence tool's version of SaveChangesAsync() for transactional boundaries + /// + Lightweight +} \ No newline at end of file