Follow-up to #3291 (fixed by #3298, shipped in 6.17.0).
Summary
#3291 fixed the Lightweight-mode HTTP outbox enrollment in EFCorePersistenceFrameProvider.ApplyTransactionSupport(IChain, IServiceContainer) — the two-argument overload, reached from the AutoApplyTransactions policy.
There is a second overload, ApplyTransactionSupport(IChain, IServiceContainer, Type entityType), reached from the side-effect return types (IStorageAction<T>, Storage.Insert/Store/Update/Delete). It has no isHttpChain branch, so it never inserts EnlistDbContextInOutbox.
The two are mutually exclusive via the UsingEfCoreTransaction chain tag — whichever applies first wins. AutoApplyTransactions only fires when EFCorePersistenceFrameProvider.CanApply is true, and that requires the chain to have a DbContext service dependency. So a Wolverine.Http endpoint that persists purely through a storage action and never injects the DbContext is claimed only by the entityType overload, and in Lightweight mode its cascaded messages are dispatched before SaveChangesAsync commits — the same defect #3291 described, on a path the fix does not cover.
Message handlers remain unaffected (an incoming envelope enlists the MessageContext in MessageContext.ReadEnvelope).
Reproducing endpoint
[WolverinePost("/ef/lightweight/storage-action")]
public static (IResult, Insert<Item>, ItemStored) Post(CreateItemCommand command)
{
var item = new Item { Id = Guid.NewGuid(), Name = command.Name };
return (Results.Ok(), Storage.Insert(item), new ItemStored { Id = item.Id });
}
with opts.UseEntityFrameworkCoreTransactions(TransactionMiddlewareMode.Lightweight), AutoApplyTransactions(), UseDurableLocalQueues(), and a routed handler for ItemStored.
Generated code (EF Core owning the storage action)
itemsDbContext.Add(insertOfItem.Entity);
// Outgoing, cascaded message
await messageContext.EnqueueCascadingAsync(itemStored).ConfigureAwait(false);
// Added by EF Core Transaction Middleware
var result_of_SaveChangesAsync = await itemsDbContext.SaveChangesAsync(httpContext.RequestAborted).ConfigureAwait(false);
await result.ExecuteAsync(httpContext).ConfigureAwait(false);
// Have to flush outgoing messages just in case Marten did nothing ...
await messageContext.FlushOutgoingMessagesAsync().ConfigureAwait(false);
No EnlistInOutboxAsync. MessageContext.Transaction is null, so MessageBus.PersistOrSendAsync takes the StoreAndForwardAsync() (send-now) branch at the EnqueueCascadingAsync line — i.e. before SaveChangesAsync. The trailing FlushOutgoingMessagesAsync() has nothing left to flush. A downstream handler can observe the database before the row that triggered the message exists.
Why a copy of the #3291 branch does not fix it
I spiked the isHttpChain(chain) && !isMultiTenanted(...) && chain.RequiresOutbox() && chain.ShouldFlushOutgoingMessages() branch into the entityType overload. The test stayed red. Instrumenting the guards at that call site:
entityType-overload chain=HttpChain mode=Lightweight isHttp=True multiTenant=False requiresOutbox=False shouldFlush=True
chain.RequiresOutbox() is false there. The entityType overload runs from SideEffectPolicy, which WolverineOptions' constructor registers before OutgoingMessagesPolicy:
Policies.Add<SagaPersistenceChainPolicy>();
Policies.Add<SideEffectPolicy>(); // <- entityType overload runs here
Policies.Add<ResponsePolicy>();
Policies.Add<OutgoingMessagesPolicy>(); // <- cascading message registered as outgoing here
So at that point the chain does not yet know it has outgoing messages, and the RequiresOutbox() gate can never be satisfied.
This is also why the shipped fix works where it does: the two-argument overload is invoked from AutoApplyTransactions, a user-added policy that runs after OutgoingMessagesPolicy, by which time RequiresOutbox() is true. And the UsingEfCoreTransaction tag that SideEffectPolicy's early pass sets is what prevents AutoApplyTransactions from applying the (fixed) two-argument path afterwards.
A fix therefore needs to defer the enrollment decision to codegen, or apply it without gating on RequiresOutbox() at side-effect-policy time — not simply mirror the existing branch.
Reproducer test
A red test is attached below (Bug_3291_lightweight_http_storage_action_cascade), written for Wolverine.Http.Tests. It asserts at the codegen surface, matching the convention of the existing Bug_3291_lightweight_http_cascade_flushes_before_commit and Bug_efcore_outbox_flush_before_commit reproducers (the runtime symptom races the durability agent's cleanup).
One wrinkle worth flagging for review: the test needs an IWolverineExtension that pushes EFCorePersistenceFrameProvider to the front of GenerationRules.PersistenceProviders(). Wolverine.Http.Tests always registers Marten (endpoint discovery scans the whole assembly, and other endpoints there persist Marten documents), MartenPersistenceFrameProvider.CanPersist returns true for every type, and TryFindPersistenceFrameProvider takes candidates.First(). Without the reorder, Marten claims Item and the endpoint generates documentSession.Insert — testing nothing. Provider order is set by extension application order (InsertFirstPersistenceStrategy; last extension applied wins index 0). In an EF-Core-only application EF Core is the only candidate, so the reorder just reproduces what such an app has naturally.
Separately, that blanket CanPersist => true on the Marten provider combined with candidates.First() may be worth a look on its own — in any mixed Marten + EF Core app, a storage action over an EF-mapped entity resolves to Marten purely on registration order.
Environment
main @ 56ded66, and 6.17.0
WolverineFx.EntityFrameworkCore + WolverineFx.Http
References
Reproducer test (red on main)
using Alba;
using IntegrationTests;
using JasperFx;
using JasperFx.CodeGeneration;
using JasperFx.Core.Reflection;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using JasperFx.CodeGeneration.Model;
using Marten;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Shouldly;
using Wolverine.Configuration;
using Wolverine.EntityFrameworkCore;
using Wolverine.Marten;
using Wolverine.Persistence;
using Wolverine.Persistence.Sagas;
using WolverineWebApi;
using Xunit;
namespace Wolverine.Http.Tests;
// Follow-up probe for GH-3291. The fix landed in EFCorePersistenceFrameProvider.ApplyTransactionSupport(
// chain, container) - the two-argument overload reached from the AutoApplyTransactions policy. There is a
// SECOND overload, ApplyTransactionSupport(chain, container, entityType), reached from the side-effect
// return types (IStorageAction<T>, Storage.Insert/Store/Update/Delete). That overload has no isHttpChain
// branch, so it never inserts EnlistDbContextInOutbox.
//
// The two are mutually exclusive via the UsingEfCoreTransaction chain tag: whichever runs first wins.
// AutoApplyTransactions only fires when EFCorePersistenceFrameProvider.CanApply is true, and CanApply
// requires the chain to have a DbContext service dependency. So an HTTP endpoint that persists purely
// through a storage action - and never injects the DbContext - is claimed ONLY by the entityType overload.
//
// If that endpoint also cascades a message, the Lightweight composition should be the same one GH-3291
// established for the tuple-return case: enlist in the outbox, SaveChangesAsync, then flush after commit.
// This test asserts exactly that. It is written to FAIL if the entityType overload leaves the endpoint's
// MessageContext unenlisted (cascade sent before commit).
public class Bug_3291_lightweight_http_storage_action_cascade
{
private static async Task<IAlbaHost> buildLightweightHostAsync()
{
var schema = "ef_lw_sa_" + Guid.NewGuid().ToString("N")[..8];
var builder = WebApplication.CreateBuilder();
builder.Host.UseWolverine(opts =>
{
opts.Durability.Mode = DurabilityMode.Solo;
// Marten backs the message store only (mirrors the sibling GH-3291 reproducer). Endpoint
// discovery scans the whole test assembly, and other endpoints in it persist Marten
// documents, so Marten has to be registered here.
//
opts.Services.AddMarten(m =>
{
m.Connection(Servers.PostgresConnectionString);
m.DatabaseSchemaName = schema;
}).IntegrateWithWolverine();
opts.Services.AddDbContextWithWolverineIntegration<ItemsDbContext>(x =>
x.UseNpgsql(Servers.PostgresConnectionString));
// TryFindPersistenceFrameProvider takes candidates.First(), and
// MartenPersistenceFrameProvider.CanPersist returns true for EVERY type, so whichever
// provider sits at index 0 claims Item. Both integrations call InsertFirstPersistenceStrategy
// from an IWolverineExtension, so index 0 belongs to whichever extension is applied LAST.
//
// This has to be an extension, not a chain policy: SideEffectPolicy is registered in the
// WolverineOptions constructor, so it runs ahead of any user policy and binds the storage
// action's provider (via effect.UseReturnAction) before a policy could reorder anything.
// Extensions are applied during host build, well before any policy runs. Registering last
// puts EF Core first, exactly as an EF-Core-only application would have it. EF Core's
// CanPersist is selective (DbContext-mapped types only), so Todo2 and the other
// Marten-backed endpoints in this assembly still fall through to Marten.
opts.Services.AddSingleton<IWolverineExtension>(new EfCoreProviderFirstExtension());
opts.UseEntityFrameworkCoreTransactions(TransactionMiddlewareMode.Lightweight);
opts.Policies.AutoApplyTransactions();
opts.Policies.UseDurableLocalQueues();
opts.Discovery.DisableConventionalDiscovery();
opts.Discovery.IncludeType<StorageActionItemStoredHandler>();
opts.Discovery.IncludeAssembly(typeof(Bug_3291_lightweight_http_storage_action_cascade).Assembly);
});
builder.Services.AddWolverineHttp();
return await AlbaHost.For(builder, app => app.MapWolverineEndpoints());
}
[Fact]
public async Task storage_action_endpoint_enlists_outbox_and_does_not_flush_before_commit()
{
await using var host = await buildLightweightHostAsync();
var graph = host.Services.GetRequiredService<WolverineHttpOptions>().Endpoints!;
var chain = graph.ChainFor("POST", "/ef/lightweight/storage-action");
chain.ShouldNotBeNull();
chain.As<ICodeFile>().InitializeSynchronously(graph.Rules, graph, host.Services);
var source = chain!.SourceCode;
source.ShouldNotBeNull();
// Guard: EF Core, not Marten, must own the storage action for this test to mean anything.
source.ShouldContain("ItemsDbContext");
// (1) The MessageContext must be enlisted in the outbox, or the cascaded ItemStored is dispatched
// by the send-now branch of MessageBus.PersistOrSendAsync before SaveChangesAsync commits.
source.ShouldContain("EnlistInOutboxAsync");
// (2) No standalone pre-commit flush.
chain.Postprocessors.OfType<FlushOutgoingMessages>().ShouldBeEmpty(
"A Lightweight-mode HTTP endpoint that persists via a storage action and cascades a message " +
"still carries a standalone FlushOutgoingMessages postprocessor, so the cascade is sent " +
"before SaveChangesAsync commits.");
// (3) enlist -> SaveChangesAsync -> post-commit flush.
var enlistAt = source.IndexOf(".EnlistInOutboxAsync(", StringComparison.Ordinal);
var saveAt = source.IndexOf(".SaveChangesAsync(", StringComparison.Ordinal);
var commitAt = source.IndexOf(".CommitAsync(", StringComparison.Ordinal);
saveAt.ShouldBeGreaterThan(enlistAt, "SaveChangesAsync must run after the outbox enrollment");
commitAt.ShouldBeGreaterThan(saveAt,
"The outbox flush (EfCoreEnvelopeTransaction.CommitAsync) must run after SaveChangesAsync commits");
}
}
public static class LightweightStorageActionEndpoint
{
// Persists ONLY through the storage action, and never injects ItemsDbContext. That combination is what
// routes this chain to ApplyTransactionSupport(chain, container, entityType) instead of the two-arg
// overload that carries the GH-3291 fix.
[WolverinePost("/ef/lightweight/storage-action")]
public static (IResult, Insert<Item>, ItemStored) Post(CreateItemCommand command)
{
var item = new Item { Id = Guid.NewGuid(), Name = command.Name };
return (Results.Ok(), Storage.Insert(item), new ItemStored { Id = item.Id });
}
}
public class ItemStored
{
public Guid Id { get; set; }
}
// Applied after the Marten and EF Core integrations' own extensions (IWolverineExtension instances are
// resolved in DI registration order), and before any chain policy runs.
//
// This has to be an extension rather than an IChainPolicy: SideEffectPolicy is registered in the
// WolverineOptions constructor, so it runs ahead of every user-added policy and binds the storage
// action's provider - via effect.UseReturnAction - before a policy could reorder anything.
internal class EfCoreProviderFirstExtension : IWolverineExtension
{
public void Configure(WolverineOptions options)
{
var providers = options.CodeGeneration.PersistenceProviders();
var efCore = providers.FirstOrDefault(x => x.GetType().Name == "EFCorePersistenceFrameProvider");
if (efCore != null && providers.IndexOf(efCore) > 0)
{
providers.Remove(efCore);
providers.Insert(0, efCore);
}
}
}
// Gives the cascaded ItemStored a routed destination so it actually buffers into Outstanding.
public class StorageActionItemStoredHandler
{
public void Handle(ItemStored _)
{
}
}
Follow-up to #3291 (fixed by #3298, shipped in 6.17.0).
Summary
#3291 fixed the Lightweight-mode HTTP outbox enrollment in
EFCorePersistenceFrameProvider.ApplyTransactionSupport(IChain, IServiceContainer)— the two-argument overload, reached from theAutoApplyTransactionspolicy.There is a second overload,
ApplyTransactionSupport(IChain, IServiceContainer, Type entityType), reached from the side-effect return types (IStorageAction<T>,Storage.Insert/Store/Update/Delete). It has noisHttpChainbranch, so it never insertsEnlistDbContextInOutbox.The two are mutually exclusive via the
UsingEfCoreTransactionchain tag — whichever applies first wins.AutoApplyTransactionsonly fires whenEFCorePersistenceFrameProvider.CanApplyis true, and that requires the chain to have aDbContextservice dependency. So aWolverine.Httpendpoint that persists purely through a storage action and never injects theDbContextis claimed only by theentityTypeoverload, and inLightweightmode its cascaded messages are dispatched beforeSaveChangesAsynccommits — the same defect #3291 described, on a path the fix does not cover.Message handlers remain unaffected (an incoming envelope enlists the
MessageContextinMessageContext.ReadEnvelope).Reproducing endpoint
with
opts.UseEntityFrameworkCoreTransactions(TransactionMiddlewareMode.Lightweight),AutoApplyTransactions(),UseDurableLocalQueues(), and a routed handler forItemStored.Generated code (EF Core owning the storage action)
No
EnlistInOutboxAsync.MessageContext.Transactionis null, soMessageBus.PersistOrSendAsynctakes theStoreAndForwardAsync()(send-now) branch at theEnqueueCascadingAsyncline — i.e. beforeSaveChangesAsync. The trailingFlushOutgoingMessagesAsync()has nothing left to flush. A downstream handler can observe the database before the row that triggered the message exists.Why a copy of the #3291 branch does not fix it
I spiked the
isHttpChain(chain) && !isMultiTenanted(...) && chain.RequiresOutbox() && chain.ShouldFlushOutgoingMessages()branch into theentityTypeoverload. The test stayed red. Instrumenting the guards at that call site:chain.RequiresOutbox()is false there. TheentityTypeoverload runs fromSideEffectPolicy, whichWolverineOptions' constructor registers beforeOutgoingMessagesPolicy:So at that point the chain does not yet know it has outgoing messages, and the
RequiresOutbox()gate can never be satisfied.This is also why the shipped fix works where it does: the two-argument overload is invoked from
AutoApplyTransactions, a user-added policy that runs afterOutgoingMessagesPolicy, by which timeRequiresOutbox()is true. And theUsingEfCoreTransactiontag thatSideEffectPolicy's early pass sets is what preventsAutoApplyTransactionsfrom applying the (fixed) two-argument path afterwards.A fix therefore needs to defer the enrollment decision to codegen, or apply it without gating on
RequiresOutbox()at side-effect-policy time — not simply mirror the existing branch.Reproducer test
A red test is attached below (
Bug_3291_lightweight_http_storage_action_cascade), written forWolverine.Http.Tests. It asserts at the codegen surface, matching the convention of the existingBug_3291_lightweight_http_cascade_flushes_before_commitandBug_efcore_outbox_flush_before_commitreproducers (the runtime symptom races the durability agent's cleanup).One wrinkle worth flagging for review: the test needs an
IWolverineExtensionthat pushesEFCorePersistenceFrameProviderto the front ofGenerationRules.PersistenceProviders().Wolverine.Http.Testsalways registers Marten (endpoint discovery scans the whole assembly, and other endpoints there persist Marten documents),MartenPersistenceFrameProvider.CanPersistreturnstruefor every type, andTryFindPersistenceFrameProvidertakescandidates.First(). Without the reorder, Marten claimsItemand the endpoint generatesdocumentSession.Insert— testing nothing. Provider order is set by extension application order (InsertFirstPersistenceStrategy; last extension applied wins index 0). In an EF-Core-only application EF Core is the only candidate, so the reorder just reproduces what such an app has naturally.Separately, that blanket
CanPersist => trueon the Marten provider combined withcandidates.First()may be worth a look on its own — in any mixed Marten + EF Core app, a storage action over an EF-mapped entity resolves to Marten purely on registration order.Environment
main@ 56ded66, and 6.17.0WolverineFx.EntityFrameworkCore+WolverineFx.HttpReferences
Lightweighttransaction mode:Wolverine.Httpendpoint cascading messages are relayed **before** the DbContext transaction commits #3291, PR fix(efcore): enlist HTTP endpoint outbox in Lightweight mode so cascades flush after commit (GH-3291) #3298EFCorePersistenceFrameProvider.ApplyTransactionSupport(IChain, IServiceContainer, Type)— missing theisHttpChainbranchStorage.TryApply/IStorageAction<T>.BuildFrame/Insert<T>.BuildFrame→rules.TryFindPersistenceFrameProviderMessageBus.PersistOrSendAsync— theTransaction is null→StoreAndForwardAsync()branchSideEffectPolicyvsOutgoingMessagesPolicyregistration order in theWolverineOptionsconstructorReproducer test (red on main)