Skip to content

Commit f204932

Browse files
Merge pull request #3298 from JasperFx/fix/efcore-lightweight-http-outbox-3291
fix(efcore): enlist HTTP endpoint outbox in Lightweight mode so cascades flush after commit (GH-3291)
2 parents 251ded2 + 64aab40 commit f204932

3 files changed

Lines changed: 246 additions & 0 deletions

File tree

Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
1+
using Alba;
2+
using IntegrationTests;
3+
using JasperFx;
4+
using JasperFx.CodeGeneration;
5+
using JasperFx.Core.Reflection;
6+
using Marten;
7+
using Microsoft.AspNetCore.Builder;
8+
using Microsoft.EntityFrameworkCore;
9+
using Microsoft.Extensions.DependencyInjection;
10+
using Shouldly;
11+
using Wolverine.EntityFrameworkCore;
12+
using Wolverine.Marten;
13+
using Wolverine.Persistence;
14+
using WolverineWebApi;
15+
using Xunit;
16+
17+
namespace Wolverine.Http.Tests;
18+
19+
// Reproducer for GH-3291. A Wolverine.Http endpoint has no incoming envelope, so (unlike a message
20+
// handler, whose MessageContext is enlisted in the outbox by ReadEnvelope at runtime) its
21+
// MessageContext.Transaction stays null. In TransactionMiddlewareMode.Lightweight the EF Core
22+
// transaction middleware does NOT enroll the endpoint's DbContext/context in the outbox, so
23+
// MessageBus.PersistOrSendAsync takes the StoreAndForwardAsync() (send-now) branch and the cascaded
24+
// message is sent BEFORE the SaveChangesAsync postprocessor commits - silently dropping the
25+
// transactional-outbox guarantee the HTTP docs advertise. Message handlers are unaffected in both
26+
// modes; the bug is specific to HTTP endpoints in Lightweight mode.
27+
//
28+
// Like the sibling Eager-mode reproducer (Bug_efcore_outbox_flush_before_commit), we assert at the
29+
// codegen surface rather than at runtime: the runtime symptom (a stranded wolverine_outgoing row) is
30+
// cleaned up by the durability agent within ~250ms and races any post-request query. The generated
31+
// composition is the deterministic proof.
32+
//
33+
// Pre-fix state (the bug): the Lightweight HTTP chain carries a standalone FlushOutgoingMessages
34+
// postprocessor as its ONLY flush trigger, and its MessageContext is never enlisted, so the generated
35+
// code never calls EnlistInOutboxAsync. Fixed state: the chain enlists the DbContext in the outbox
36+
// WITHOUT an explicit BeginTransactionAsync (an IFlushesMessages middleware -> no standalone
37+
// FlushOutgoingMessages), and the buffered messages are flushed after the commit.
38+
public class Bug_3291_lightweight_http_cascade_flushes_before_commit
39+
{
40+
private static async Task<IAlbaHost> buildLightweightHostAsync()
41+
{
42+
var schema = "ef_lw_" + Guid.NewGuid().ToString("N")[..8];
43+
var builder = WebApplication.CreateBuilder();
44+
45+
// Wolverine-integrated DbContext supplies the outbox enrollment for EF Core (mirrors the
46+
// AddDbContextWithWolverineIntegration setup the issue reports).
47+
builder.Services.AddDbContextWithWolverineIntegration<ItemsDbContext>(x =>
48+
x.UseNpgsql(Servers.PostgresConnectionString));
49+
50+
builder.Host.UseWolverine(opts =>
51+
{
52+
opts.Durability.Mode = DurabilityMode.Solo;
53+
54+
opts.Services.AddMarten(m =>
55+
{
56+
m.Connection(Servers.PostgresConnectionString);
57+
m.DatabaseSchemaName = schema;
58+
}).IntegrateWithWolverine();
59+
60+
// The bug is specific to Lightweight mode. Eager already works (see the sibling reproducer).
61+
opts.UseEntityFrameworkCoreTransactions(TransactionMiddlewareMode.Lightweight);
62+
63+
opts.Policies.AutoApplyTransactions();
64+
opts.Policies.UseDurableLocalQueues();
65+
66+
opts.Discovery.DisableConventionalDiscovery();
67+
// The cascaded ItemCreated needs a routed handler so it actually buffers into Outstanding.
68+
opts.Discovery.IncludeType<LightweightCascadeItemCreatedHandler>();
69+
opts.Discovery.IncludeAssembly(typeof(Bug_3291_lightweight_http_cascade_flushes_before_commit).Assembly);
70+
});
71+
72+
builder.Services.AddWolverineHttp();
73+
74+
return await AlbaHost.For(builder, app => app.MapWolverineEndpoints());
75+
}
76+
77+
[Fact]
78+
public async Task lightweight_http_endpoint_enlists_outbox_and_does_not_flush_before_commit()
79+
{
80+
await using var host = await buildLightweightHostAsync();
81+
82+
var graph = host.Services.GetRequiredService<WolverineHttpOptions>().Endpoints!;
83+
var chain = graph.ChainFor("POST", "/ef/lightweight/publish");
84+
chain.ShouldNotBeNull();
85+
86+
// (1) No standalone pre-commit flush. Pre-fix, applyEagerCommitOrLightweightFlush adds a
87+
// FlushOutgoingMessages postprocessor that (because the context is never enlisted) runs the
88+
// send BEFORE SaveChangesAsync commits. Fixed, the enlist middleware is IFlushesMessages, so
89+
// this standalone flush is gone and the commit path does the post-commit flush instead.
90+
chain.Postprocessors.OfType<FlushOutgoingMessages>().ShouldBeEmpty(
91+
"GH-3291: a Lightweight-mode HTTP endpoint that cascades messages still has a standalone " +
92+
"FlushOutgoingMessages postprocessor. Its MessageContext is never enlisted in the outbox, " +
93+
"so the cascade is sent before SaveChangesAsync commits.");
94+
95+
// (2) The generated code must enlist the DbContext + IMessageContext in the outbox so the
96+
// cascade buffers and flushes after commit. Pre-fix this call is absent.
97+
// GH-3291: pre-fix the Lightweight HTTP endpoint never enrolls its DbContext/context in the
98+
// outbox, so cascaded messages are sent immediately instead of buffered until after the commit.
99+
chain.As<ICodeFile>().InitializeSynchronously(graph.Rules, graph, host.Services);
100+
var source = chain!.SourceCode;
101+
source.ShouldNotBeNull();
102+
source.ShouldContain("EnlistInOutboxAsync");
103+
104+
// (3) Ordering: enroll in the outbox -> [endpoint body] -> SaveChangesAsync commits -> the
105+
// envelope transaction's CommitAsync flushes the buffered messages. The flush must come AFTER
106+
// the commit; that is the whole point of the fix.
107+
// Match the actual call sites (".Method(") rather than bare names, which also appear in comments.
108+
var enlistAt = source.IndexOf(".EnlistInOutboxAsync(", StringComparison.Ordinal);
109+
var saveAt = source.IndexOf(".SaveChangesAsync(", StringComparison.Ordinal);
110+
var commitAt = source.IndexOf(".CommitAsync(", StringComparison.Ordinal);
111+
112+
saveAt.ShouldBeGreaterThan(enlistAt, "SaveChangesAsync must run after the outbox enrollment");
113+
commitAt.ShouldBeGreaterThan(saveAt,
114+
"The outbox flush (EfCoreEnvelopeTransaction.CommitAsync) must run after SaveChangesAsync commits");
115+
}
116+
}
117+
118+
public static class LightweightEfCascadeEndpoint
119+
{
120+
// Writes an entity AND cascades a message: the exact shape from the GH-3291 report. In Lightweight
121+
// mode the cascade must not be sent until SaveChangesAsync commits the item.
122+
[WolverinePost("/ef/lightweight/publish")]
123+
public static async Task Publish(CreateItemCommand command, ItemsDbContext db, IMessageBus bus)
124+
{
125+
var item = new Item { Name = command.Name };
126+
db.Items.Add(item);
127+
await bus.PublishAsync(new ItemCreated { Id = item.Id });
128+
}
129+
}
130+
131+
// A routed handler so the cascaded ItemCreated has somewhere to go (durable local queue). Body is
132+
// irrelevant — the test asserts the outbox enrollment/flush composition, not the handling.
133+
public class LightweightCascadeItemCreatedHandler
134+
{
135+
public void Handle(ItemCreated _)
136+
{
137+
}
138+
}

src/Persistence/Wolverine.EntityFrameworkCore/Codegen/EFCorePersistenceFrameProvider.cs

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -226,6 +226,24 @@ public void ApplyTransactionSupport(IChain chain, IServiceContainer container)
226226
enrolledInTransaction = true;
227227
}
228228
}
229+
else if (isHttpChain(chain)
230+
&& !isMultiTenanted(container, dbContextType)
231+
&& chain.RequiresOutbox()
232+
&& chain.ShouldFlushOutgoingMessages())
233+
{
234+
// GH-3291: A Wolverine.Http endpoint has no incoming envelope, so - unlike a message handler,
235+
// whose MessageContext is enlisted by MessageContext.ReadEnvelope at runtime - its
236+
// MessageContext.Transaction stays null in Lightweight mode. Cascaded messages would then be
237+
// sent immediately, before the SaveChangesAsync postprocessor commits, silently dropping the
238+
// transactional-outbox guarantee for HTTP endpoints (message handlers are unaffected). Enroll
239+
// the DbContext in the outbox WITHOUT an explicit BeginTransactionAsync (SaveChanges' implicit
240+
// transaction covers the write, and skipping the explicit begin keeps this compatible with EF
241+
// Core's EnableRetryOnFailure). Setting enrolledInTransaction = true makes the code below add
242+
// the CommitEfCoreEnvelopeTransaction postprocessor (which flushes after commit) instead of a
243+
// standalone, pre-commit FlushOutgoingMessages. Restricted to HttpChain on purpose.
244+
chain.Middleware.Insert(0, new EnlistDbContextInOutbox(dbContextType));
245+
enrolledInTransaction = true;
246+
}
229247

230248
var abstractionType = chain.ServiceDependencies(container, Type.EmptyTypes).FirstOrDefault(x => _abstractions.Contains(x));
231249
if (abstractionType != null)
@@ -323,6 +341,13 @@ private bool isMultiTenanted(IServiceContainer container, Type dbContextType)
323341
return container.HasRegistrationFor(typeof(IDbContextBuilder<>).MakeGenericType(dbContextType));
324342
}
325343

344+
// HttpChain is the only chain type whose Scoping is HttpEndpoints. Detected via the scoping enum
345+
// rather than a type reference so Wolverine.EntityFrameworkCore need not depend on Wolverine.Http.
346+
private static bool isHttpChain(IChain chain)
347+
{
348+
return chain.Scoping == MiddlewareScoping.HttpEndpoints;
349+
}
350+
326351
public void ApplyTransactionSupport(IChain chain, IServiceContainer container, Type entityType)
327352
{
328353
// GH-3039: For saga chains, defer to the saga's own transaction-support application at codegen
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
using JasperFx.CodeGeneration;
2+
using JasperFx.CodeGeneration.Frames;
3+
using JasperFx.CodeGeneration.Model;
4+
using JasperFx.Core.Reflection;
5+
using Wolverine.EntityFrameworkCore.Internals;
6+
using Wolverine.Persistence;
7+
using Wolverine.Runtime;
8+
9+
namespace Wolverine.EntityFrameworkCore.Codegen;
10+
11+
/// <summary>
12+
/// GH-3291: enrolls a Wolverine-enabled DbContext + the IMessageContext in the outgoing outbox
13+
/// transaction WITHOUT beginning an explicit database transaction. Used for Wolverine.Http endpoints in
14+
/// <see cref="Wolverine.Persistence.TransactionMiddlewareMode.Lightweight"/> mode.
15+
///
16+
/// Unlike a message handler — whose MessageContext is enlisted at runtime by
17+
/// <c>MessageContext.ReadEnvelope</c> when it reads the incoming envelope — an HTTP endpoint has no
18+
/// incoming envelope, so its <c>MessageContext.Transaction</c> is otherwise null. In Lightweight mode
19+
/// that means <c>MessageBus.PersistOrSendAsync</c> takes the send-now branch and cascaded messages are
20+
/// dispatched BEFORE the <c>SaveChangesAsync</c> postprocessor commits. Enlisting here makes those
21+
/// cascades buffer and flush after the commit instead.
22+
///
23+
/// This deliberately does NOT call <c>BeginTransactionAsync</c> (that is what
24+
/// <see cref="EnrollDbContextInTransaction"/> does for Eager mode): the write is covered by the implicit
25+
/// transaction <c>SaveChangesAsync</c> opens, and skipping the explicit begin keeps this compatible with
26+
/// EF Core's retrying execution strategy (<c>EnableRetryOnFailure</c>), which forbids user-initiated
27+
/// transactions. Implements <see cref="IFlushesMessages"/> so <c>HttpChain</c> does not also add a
28+
/// standalone (pre-commit) <c>FlushOutgoingMessages</c>; the paired
29+
/// <see cref="CommitEfCoreEnvelopeTransaction"/> postprocessor performs the post-commit flush.
30+
/// </summary>
31+
internal class EnlistDbContextInOutbox : AsyncFrame, IFlushesMessages
32+
{
33+
private readonly Type _dbContextType;
34+
private readonly Variable _envelopeTransaction;
35+
private Variable _dbContext = null!;
36+
private Variable? _context;
37+
private Variable _scrapers = null!;
38+
39+
public EnlistDbContextInOutbox(Type dbContextType)
40+
{
41+
_dbContextType = dbContextType;
42+
_envelopeTransaction = new Variable(typeof(EfCoreEnvelopeTransaction), this);
43+
}
44+
45+
public override void GenerateCode(GeneratedMethod method, ISourceWriter writer)
46+
{
47+
writer.WriteLine("");
48+
writer.WriteComment(
49+
"GH-3291: enroll the DbContext & IMessagingContext in the outbox so cascaded messages buffer");
50+
writer.WriteComment(
51+
"and flush AFTER SaveChangesAsync commits. No explicit transaction is started (Lightweight mode).");
52+
writer.Write($"var {_envelopeTransaction.Usage} = new {typeof(EfCoreEnvelopeTransaction).FullNameInCode()}({_dbContext.Usage}, {_context!.Usage}, {_scrapers.Usage});");
53+
writer.Write($"await {_context.Usage}.{nameof(MessageContext.EnlistInOutboxAsync)}({_envelopeTransaction.Usage}).ConfigureAwait(false);");
54+
55+
Next?.GenerateCode(method, writer);
56+
}
57+
58+
public override void GenerateFSharpCode(GeneratedMethod method, ISourceWriter writer)
59+
{
60+
// Mirrors the C# body inside an async `task { }` computation expression: awaits become `do!`
61+
// and `.ConfigureAwait(false)` is dropped (the CE controls scheduling). See
62+
// EnrollDbContextInTransaction for the same conventions.
63+
writer.Write("");
64+
writer.WriteComment(
65+
"GH-3291: enroll the DbContext & IMessagingContext in the outbox (Lightweight mode, no explicit transaction)");
66+
writer.Write($"{_envelopeTransaction.FSharpAssignmentUsage} = {typeof(EfCoreEnvelopeTransaction).FSharpName()}({_dbContext.FSharpUsage}, {_context!.FSharpUsage}, {_scrapers.FSharpUsage})");
67+
writer.Write($"do! {_context.FSharpUsage}.{nameof(MessageContext.EnlistInOutboxAsync)}({_envelopeTransaction.FSharpUsage})");
68+
69+
Next?.GenerateFSharpCode(method, writer);
70+
}
71+
72+
public override IEnumerable<Variable> FindVariables(IMethodVariables chain)
73+
{
74+
_scrapers = chain.FindVariable(typeof(IEnumerable<IDomainEventScraper>));
75+
yield return _scrapers;
76+
77+
_context = chain.FindVariable(typeof(MessageContext));
78+
yield return _context;
79+
80+
_dbContext = chain.FindVariable(_dbContextType);
81+
yield return _dbContext;
82+
}
83+
}

0 commit comments

Comments
 (0)