Skip to content

Cascading IEnumerable<object> starts an EF transaction during EnqueueCascadingAsync but does not commit #2040

@andreikarkkanen

Description

@andreikarkkanen

When using Wolverine managed EF Core multi-tenancy + durable local queues + InvokeForTenantAsync(request/reply), returning domain events as the second item of:

Task<(TResponse, IEnumerable<object>)>

causes Wolverine to start an EF Core transaction during context.EnqueueCascadingAsync(outgoing2), but the generated handler code only calls SaveChangesAsync() and never commits the started transaction. The DbContext is disposed at the end of the handler, so the transaction is rolled back and the entity insert does not persist.

Additionally, even though the transaction is rolled back (customer row not stored), the cascaded message handler (CustomerCreatedHandler) still executes. With UseDurableLocalQueues() and transactional outbox, we would expect the cascaded message to not be dispatched if the transaction is not committed.

Versions / environment

  • WolverineFx: 5.10.0
  • WolverineFx.EntityFrameworkCore: 5.10.0
  • WolverineFx.SqlServer: 5.10.0
  • EF Core: 10.0.2
  • SQL Server
  • Target framework: net10.0

Expected behavior

  • When handler returns (result, [new DomainEvent(...)]):
    1. the customer insert is committed, and
    2. the domain event is stored in the outbox and later dispatched.
  • If the transaction rolls back, the domain event should not be dispatched (durable local).

Actual behavior

  • When the cascading enumerable contains at least one event:
    • transaction is started during EnqueueCascadingAsync
    • generated adapter ends with SaveChangesAsync() only, no explicit commit
    • customer insert is rolled back (row missing in DB)
    • but CustomerCreatedHandler still fires (unexpected under durable local/outbox)
  • When the handler returns an empty enumerable ([]), the customer insert commits normally.

Repro project
MultiTenantMessaging.zip

Steps to reproduce

  1. Set two connection strings in appsettings.json:
    • ConnectionStrings:main = Wolverine message store DB
    • ConnectionStrings:tenant1 = tenant DB that contains Customers
  2. Run the app.

The app invokes two tenant commands:

  • CreateCustomerNoEvents (returns []) - persists
  • CreateCustomerWithEvents (returns [new CustomerCreated(...)]) - customer row is NOT persisted, but CustomerCreatedHandler still runs.

Minimal handler code

[Transactional]
[WolverineHandler]
public sealed class CreateCustomerWithEventsHandler(ICustomerRepository repository)
{
    public async Task<(CreateCustomerResult, IEnumerable<object>)> Handle(
        CreateCustomerWithEvents request,
        CancellationToken ct)
    {
        var customer = new Customer { Id = Guid.NewGuid(), Name = request.Name };

        await repository.AddAsync(customer, ct);

        return (new CreateCustomerResult(customer.Id, customer.Name),
            [new CustomerCreated(customer.Id)]);
    }
}

Generated handler adapter
Internal/Generated/WolverineHandlers/CreateCustomerWithEventsHandler*.cs:

// Outgoing, cascaded message
await context.EnqueueCascadingAsync(outgoing2).ConfigureAwait(false);

// Added by EF Core Transaction Middleware
var result_of_SaveChangesAsync = await tenantDbContext.SaveChangesAsync(cancellation).ConfigureAwait(false);

EnqueueCascadingAsync(outgoing2) appears to start an EF transaction (via EfCoreEnvelopeTransaction), but there is no explicit commit afterward. Because the DbContext is disposed at the end of the generated handler method, the transaction is rolled back and the customer row is not saved.

Despite that, CustomerCreatedHandler still fires, seems to violate the durable local/outbox expectation that outgoing messages are only dispatched after successful commit.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions