diff --git a/src/Persistence/EfCoreTests.MultiTenancy/domain_events_scraping_with_managed_multi_tenancy.cs b/src/Persistence/EfCoreTests.MultiTenancy/domain_events_scraping_with_managed_multi_tenancy.cs new file mode 100644 index 000000000..a78b2a21c --- /dev/null +++ b/src/Persistence/EfCoreTests.MultiTenancy/domain_events_scraping_with_managed_multi_tenancy.cs @@ -0,0 +1,99 @@ +using System.Diagnostics; +using IntegrationTests; +using JasperFx; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; +using SharedPersistenceModels.Items; +using SharedPersistenceModels.Orders; +using Shouldly; +using Wolverine; +using Wolverine.EntityFrameworkCore; +using Wolverine.Persistence; +using Wolverine.Postgresql; +using Wolverine.RDBMS; +using Wolverine.Runtime; +using Wolverine.Tracking; + +namespace EfCoreTests.MultiTenancy; + +/// +/// Regression guard for the managed-multi-tenancy counterpart of the single-DbContext domain-event +/// scraper tests in EfCoreTests/DomainEvents/configuration_of_domain_events_scrapers.cs. +/// +/// The EF Core transactional middleware has two codegen paths: the single-DbContext path commits +/// through EfCoreEnvelopeTransaction.CommitAsync(), which runs the registered +/// IDomainEventScrapers; the managed-multi-tenancy path +/// (StartDatabaseTransactionForDbContext / CommitTenantedDbContextTransaction) used to +/// commit the raw EF transaction directly and never invoked CommitAsync(), so the scrapers +/// registered by PublishDomainEventsFromEntityFrameworkCore<T>(x => x.Events) never ran. +/// A mutated entity's domain events were therefore silently dropped under managed multi-tenancy. +/// +/// This test mutates an in a handler under a tenant so the entity publishes an +/// domain event, then asserts (via a Wolverine tracked session) that the +/// event was scraped and published. +/// +public class domain_events_scraping_with_managed_multi_tenancy : MultiTenancyCompliance +{ + public domain_events_scraping_with_managed_multi_tenancy() : base(DatabaseEngine.PostgreSQL) + { + } + + public override void Configure(WolverineOptions opts) + { + opts.PersistMessagesWithPostgresql(Servers.PostgresConnectionString, "domain_event_scraping_mt") + .RegisterStaticTenants(tenants => + { + tenants.Register("red", tenant1ConnectionString); + tenants.Register("blue", tenant2ConnectionString); + tenants.Register("green", tenant3ConnectionString); + }); + + // Little weird, but we have to remove this DbContext to use + // the lightweight saga persistence + opts.Services.RemoveAll(typeof(OrdersDbContext)); + opts.AddSagaType(); + + opts.Services.AddDbContextWithWolverineManagedMultiTenancy((builder, connectionString, _) => + { + builder.UseNpgsql(connectionString.Value, b => b.MigrationsAssembly("MultiTenantedEfCoreWithPostgreSQL")); + }, AutoCreate.CreateOrUpdate); + + // The behavior under test: scrape domain events off entities tracked by the tenant DbContext. + opts.PublishDomainEventsFromEntityFrameworkCore(x => x.Events); + + // The approval handler + ItemApproved handler live in this test assembly. + opts.Discovery.IncludeAssembly(GetType().Assembly); + } + + [Fact] + public async Task publishes_domain_events_scraped_from_the_tenant_db_context() + { + var itemId = Guid.NewGuid(); + + // Seed the item in the "blue" tenant database. + await theHost.InvokeMessageAndWaitAsync(new StartNewItem(itemId, "Latte"), "blue"); + + // Approve it under the same tenant - the handler calls Item.Approve(), which publishes an + // ItemApproved domain event into the entity's Events collection. The scraper must relay it. + var tracked = await theHost.InvokeMessageAndWaitAsync(new ApproveTenantedItem(itemId), "blue"); + + tracked.Sent.SingleMessage().Id.ShouldBe(itemId); + } +} + +public record ApproveTenantedItem(Guid Id); + +public static class ApproveTenantedItemHandler +{ + // Taking the ItemsDbContext (via [Entity]) applies the EF Core transactional middleware so the + // domain-event scrape path is exercised on commit. + public static IStorageAction Handle(ApproveTenantedItem command, [Entity] Item item) + { + // Publishes an ItemApproved event internally on the Item entity that we want relayed to Wolverine. + item.Approve(); + return Storage.Update(item); + } + + public static void Handle(ItemApproved e) => Debug.WriteLine($"Got item approved for {e.Id}"); +} diff --git a/src/Persistence/Wolverine.EntityFrameworkCore/Codegen/StartDatabaseTransactionForDbContext.cs b/src/Persistence/Wolverine.EntityFrameworkCore/Codegen/StartDatabaseTransactionForDbContext.cs index dd6ddd896..fedf6535b 100644 --- a/src/Persistence/Wolverine.EntityFrameworkCore/Codegen/StartDatabaseTransactionForDbContext.cs +++ b/src/Persistence/Wolverine.EntityFrameworkCore/Codegen/StartDatabaseTransactionForDbContext.cs @@ -4,6 +4,7 @@ using JasperFx.Core.Reflection; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Storage; +using Wolverine.EntityFrameworkCore.Internals; using Wolverine.Persistence; using Wolverine.Runtime; @@ -83,12 +84,24 @@ public override IEnumerable FindVariables(IMethodVariables chain) /// so the chain does not also add a standalone FlushOutgoingMessages /// postprocessor (which would flush after the response, and before the commit). See GH-2917. /// +/// +/// Before committing, this frame runs any registered s against the +/// tenant DbContext exactly as +/// does on the single-DbContext path (via ). Unlike that +/// path, the multi-tenant DbContext is created at runtime inside +/// , so the +/// enlisted is never +/// surfaced as a codegen variable and its CommitAsync is not in the generated chain. The scrape +/// loop is therefore inlined here so that PublishDomainEventsFromEntityFrameworkCore works under +/// managed multi-tenancy too. +/// internal class CommitTenantedDbContextTransaction : AsyncFrame, IFlushesMessages { private readonly Type _dbContextType; private Variable _dbContext = null!; private Variable _context = null!; private Variable _cancellation = null!; + private Variable _scrapers = null!; public CommitTenantedDbContextTransaction(Type dbContextType) { @@ -97,6 +110,12 @@ public CommitTenantedDbContextTransaction(Type dbContextType) public override void GenerateCode(GeneratedMethod method, ISourceWriter writer) { + writer.WriteComment( + "Scrape any domain events out of the tenant DbContext before committing (mirrors EfCoreEnvelopeTransaction.CommitAsync)"); + writer.Write($"BLOCK:foreach (var scraper in {_scrapers.Usage})"); + writer.Write($"await scraper.{nameof(IDomainEventScraper.ScrapeEvents)}({_dbContext.Usage}, {_context.Usage}).ConfigureAwait(false);"); + writer.FinishBlock(); + writer.WriteComment( "Commit the EF Core transaction and flush outgoing messages before writing the response (GH-2917)"); writer.Write($"await {_dbContext.Usage}.Database.CommitTransactionAsync({_cancellation.Usage}).ConfigureAwait(false);"); @@ -106,6 +125,9 @@ public override void GenerateCode(GeneratedMethod method, ISourceWriter writer) public override IEnumerable FindVariables(IMethodVariables chain) { + _scrapers = chain.FindVariable(typeof(IEnumerable)); + yield return _scrapers; + _dbContext = chain.FindVariable(_dbContextType); yield return _dbContext;