From 64dd4c5c0e25a2aea1715fe05ffbb6aa83b91094 Mon Sep 17 00:00:00 2001 From: emeraldleaf Date: Sat, 23 May 2026 21:44:32 -0600 Subject: [PATCH] fix(payment): flush Wolverine outbox in ExecuteInTransactionAsync MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ExecuteInTransactionAsync opens an EF transaction, runs the caller's work delegate, then commits. Missing: a SaveChangesAsync between work() and Commit. Without it, Wolverine's outbox staging — which happens via UseEntityFrameworkCoreTransactions' SaveChanges interceptor — never reaches the wolverine.outgoing_envelopes table. The entity write commits, the event publish silently disappears. Concrete failure mode: PaymentRecoveryJob calls ExecuteInTransactionAsync(async ct => { await UpdateAsync(payment, ct); await eventPublisher.PublishAsync(failedEvent, ct); }, ct). UpdateAsync's internal SaveChanges flushes the Payment row. PublishAsync then stages the envelope to the tracker. work() returns. tx.CommitAsync commits the Payment update, but no SaveChanges has run to flush the envelope — it stays in the tracker, the transaction commits without it, and PaymentFailedEvent is dropped. The saga stalls — Order never sees the payment failed, never marks itself failed, customer never sees a refund offer. Fix is one line: `await context.SaveChangesAsync(ct)` between work() and Commit. The teaching comment is generous because this is exactly the kind of "looks correct, silently wrong" outbox bug that the architecture review caught and that a junior reader needs to understand to not regress. Discovered by: architecture-reviewer agent pass on PaymentRecoveryJob. Follow-up not in this PR: integration test that asserts a row appears in wolverine.outgoing_envelopes inside the same transaction as the Payment update. Should be added to tests/PaymentService.Tests.Integration when that slice exists. Co-Authored-By: Claude Opus 4.7 --- PaymentService/Infrastructure/PaymentRepository.cs | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/PaymentService/Infrastructure/PaymentRepository.cs b/PaymentService/Infrastructure/PaymentRepository.cs index af8fd343..0fbab830 100644 --- a/PaymentService/Infrastructure/PaymentRepository.cs +++ b/PaymentService/Infrastructure/PaymentRepository.cs @@ -43,6 +43,18 @@ public async Task ExecuteInTransactionAsync(Func work, { await using var tx = await context.Database.BeginTransactionAsync(ct); await work(ct); + + // CRITICAL: SaveChangesAsync after work() is what flushes Wolverine's staged + // outbox envelopes. Wolverine's UseEntityFrameworkCoreTransactions bridge + // intercepts SaveChanges to persist the wolverine.outgoing_envelopes rows + // *in the same transaction* as the entity write. Without this call, + // PublishAsync(...) inside work() stages envelopes in the change tracker + // but they never reach the DB — entity commits, event is silently dropped. + // The intermediate UpdateAsync inside work() flushes the entity, but the + // outbox row is staged *after* that point and needs a second flush here. + // See CLAUDE.md "Performance Rules — Outbox atomicity" + Wolverine outbox docs. + await context.SaveChangesAsync(ct); + await tx.CommitAsync(ct); } }