Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
/// Regression guard for the managed-multi-tenancy counterpart of the single-DbContext domain-event
/// scraper tests in <c>EfCoreTests/DomainEvents/configuration_of_domain_events_scrapers.cs</c>.
///
/// The EF Core transactional middleware has two codegen paths: the single-DbContext path commits
/// through <c>EfCoreEnvelopeTransaction.CommitAsync()</c>, which runs the registered
/// <c>IDomainEventScraper</c>s; the managed-multi-tenancy path
/// (<c>StartDatabaseTransactionForDbContext</c> / <c>CommitTenantedDbContextTransaction</c>) used to
/// commit the raw EF transaction directly and never invoked <c>CommitAsync()</c>, so the scrapers
/// registered by <c>PublishDomainEventsFromEntityFrameworkCore&lt;T&gt;(x =&gt; x.Events)</c> never ran.
/// A mutated entity's domain events were therefore silently dropped under managed multi-tenancy.
///
/// This test mutates an <see cref="Item"/> in a handler under a tenant so the entity publishes an
/// <see cref="ItemApproved"/> domain event, then asserts (via a Wolverine tracked session) that the
/// event was scraped and published.
/// </summary>
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<Order>();

opts.Services.AddDbContextWithWolverineManagedMultiTenancy<ItemsDbContext>((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<IEntity, IDomainEvent>(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<ItemApproved>().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<Item> 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}");
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -83,12 +84,24 @@ public override IEnumerable<Variable> FindVariables(IMethodVariables chain)
/// <see cref="IFlushesMessages" /> so the chain does not also add a standalone FlushOutgoingMessages
/// postprocessor (which would flush after the response, and before the commit). See GH-2917.
/// </summary>
/// <remarks>
/// Before committing, this frame runs any registered <see cref="IDomainEventScraper" />s against the
/// tenant DbContext exactly as <see cref="Wolverine.EntityFrameworkCore.Internals.EfCoreEnvelopeTransaction.CommitAsync" />
/// does on the single-DbContext path (via <see cref="CommitEfCoreEnvelopeTransaction" />). Unlike that
/// path, the multi-tenant DbContext is created at runtime inside
/// <see cref="Wolverine.EntityFrameworkCore.Internals.IDbContextBuilder{T}.BuildAndEnrollAsync" />, so the
/// enlisted <see cref="Wolverine.EntityFrameworkCore.Internals.EfCoreEnvelopeTransaction" /> is never
/// surfaced as a codegen variable and its <c>CommitAsync</c> is not in the generated chain. The scrape
/// loop is therefore inlined here so that <c>PublishDomainEventsFromEntityFrameworkCore</c> works under
/// managed multi-tenancy too.
/// </remarks>
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)
{
Expand All @@ -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);");
Expand All @@ -106,6 +125,9 @@ public override void GenerateCode(GeneratedMethod method, ISourceWriter writer)

public override IEnumerable<Variable> FindVariables(IMethodVariables chain)
{
_scrapers = chain.FindVariable(typeof(IEnumerable<IDomainEventScraper>));
yield return _scrapers;

_dbContext = chain.FindVariable(_dbContextType);
yield return _dbContext;

Expand Down
Loading