diff --git a/src/Persistence/EfCoreTests.MultiTenancy/ConjoinedTenancy/ConjoinedDescriptorCompliance.cs b/src/Persistence/EfCoreTests.MultiTenancy/ConjoinedTenancy/ConjoinedDescriptorCompliance.cs new file mode 100644 index 000000000..060ba3fd6 --- /dev/null +++ b/src/Persistence/EfCoreTests.MultiTenancy/ConjoinedTenancy/ConjoinedDescriptorCompliance.cs @@ -0,0 +1,137 @@ +using IntegrationTests; +using JasperFx; +using JasperFx.Descriptors; +using JasperFx.Events; +using JasperFx.MultiTenancy; +using JasperFx.Resources; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Shouldly; +using Wolverine; +using Wolverine.EntityFrameworkCore; +using Wolverine.EntityFrameworkCore.Internals; +using Wolverine.Postgresql; +using Wolverine.SqlServer; + +namespace EfCoreTests.MultiTenancy.ConjoinedTenancy; + +/// +/// GH-3536: a conjoined EF Core context must be distinguishable from a plain single-DB +/// context in its DbContextUsage descriptor — a "Conjoined" tenancy style, DynamicMultiple +/// cardinality, and the tenant ids from the wolverine_tenants registry surfaced on the single +/// shared-database descriptor — so CritterWatch's descriptor-driven UI can gate/badge it. +/// +[Collection("multi-tenancy")] +public abstract class ConjoinedDescriptorCompliance : IAsyncLifetime +{ + private readonly DatabaseEngine _engine; + protected IHost theHost = null!; + protected IDynamicTenantSource theSource = null!; + + protected ConjoinedDescriptorCompliance(DatabaseEngine engine) + { + _engine = engine; + } + + public async Task InitializeAsync() + { + theHost = await Host.CreateDefaultBuilder() + .UseWolverine(opts => + { + opts.Durability.Mode = DurabilityMode.Solo; + + opts.Discovery.DisableConventionalDiscovery() + .IncludeType(); + + if (_engine == DatabaseEngine.PostgreSQL) + { + opts.PersistMessagesWithPostgresql(Servers.PostgresConnectionString, "conjoined_descriptor"); + opts.Services.AddDbContextWithWolverineManagedConjoinedTenancy( + (builder, connectionString) => builder.UseNpgsql(connectionString.Value), + AutoCreate.CreateOrUpdate); + } + else + { + opts.PersistMessagesWithSqlServer(Servers.SqlServerConnectionString, "conjoined_descriptor"); + opts.Services.AddDbContextWithWolverineManagedConjoinedTenancy( + (builder, connectionString) => builder.UseSqlServer(connectionString.Value), + AutoCreate.CreateOrUpdate); + } + + opts.UseEntityFrameworkCoreTransactions(); + opts.UseEntityFrameworkCoreWolverineManagedMigrations(); + opts.Policies.AutoApplyTransactions(); + opts.Services.AddResourceSetupOnStartup(); + opts.PublishAllMessages().Locally(); + }).StartAsync(); + + theSource = theHost.Services.GetRequiredService>(); + } + + public async Task DisposeAsync() + { + await theHost.StopAsync(); + theHost.Dispose(); + } + + private async Task createUsageAsync() + { + var source = theHost.Services.GetServices() + .Single(x => x.Subject == new Uri("efcore://ConjoinedItemsDbContext")); + + var usage = await source.TryCreateUsage(CancellationToken.None); + usage.ShouldNotBeNull(); + return usage!; + } + + [Fact] + public async Task conjoined_context_advertises_conjoined_tenancy_style_and_dynamic_multiple_cardinality() + { + var usage = await createUsageAsync(); + + // The whole point of GH-3536: not "Single". + usage.TenancyStyle.ShouldBe("Conjoined"); + usage.Database.Cardinality.ShouldBe(DatabaseCardinality.DynamicMultiple); + + // Conjoined is one physical database, so there is exactly one shared descriptor. + usage.Database.Databases.Count.ShouldBe(1); + } + + [Fact] + public async Task conjoined_descriptor_surfaces_registry_tenant_ids() + { + var tenantA = "descriptor_" + Guid.NewGuid().ToString("N").Substring(0, 8); + var tenantB = "descriptor_" + Guid.NewGuid().ToString("N").Substring(0, 8); + + await theSource.AddTenantAsync(tenantA, CancellationToken.None); + await theSource.AddTenantAsync(tenantB, CancellationToken.None); + + var usage = await createUsageAsync(); + + // Tenant ids from the wolverine_tenants registry ride on the single shared-database + // descriptor rather than fanning out into per-tenant database entries. + // Tenant ids are stored lower-cased by the registry; the ids created above are already + // lower-case, so a direct membership check is exact. + var tenantIds = usage.Database.MainDatabase!.TenantIds; + tenantIds.ShouldContain(tenantA); + tenantIds.ShouldContain(tenantB); + + await theSource.RemoveTenantAsync(tenantA); + await theSource.RemoveTenantAsync(tenantB); + } +} + +public class conjoined_descriptor_with_postgresql : ConjoinedDescriptorCompliance +{ + public conjoined_descriptor_with_postgresql() : base(DatabaseEngine.PostgreSQL) + { + } +} + +public class conjoined_descriptor_with_sqlserver : ConjoinedDescriptorCompliance +{ + public conjoined_descriptor_with_sqlserver() : base(DatabaseEngine.SqlServer) + { + } +} diff --git a/src/Persistence/Wolverine.EntityFrameworkCore/Internals/DbContextUsageSource.cs b/src/Persistence/Wolverine.EntityFrameworkCore/Internals/DbContextUsageSource.cs index 39ac49580..bbfe236e8 100644 --- a/src/Persistence/Wolverine.EntityFrameworkCore/Internals/DbContextUsageSource.cs +++ b/src/Persistence/Wolverine.EntityFrameworkCore/Internals/DbContextUsageSource.cs @@ -96,14 +96,24 @@ public TenantedDbContextUsageSource(IServiceProvider services) { var builder = _services.GetRequiredService>(); + // Conjoined multi-tenancy is one physical database shared by a dynamic + // list of tenants, so it doesn't fan out into a per-tenant database list + // the way the connection-string / data-source builders do. Detect it + // explicitly (GH-3536) so it advertises a distinct "Conjoined" style and + // carries its tenant ids on the single shared DatabaseDescriptor rather + // than looking identical to a plain single-DB context. + var isConjoined = builder is ConjoinedDbContextBuilder; + // Tenancy-style discriminator from the registered IDbContextBuilder // implementation type — keeps the badge in operator vocabulary. - var tenancyStyle = builder.GetType().Name switch - { - var n when n.StartsWith("TenantedDbContextBuilderByDbDataSource") => "DbDataSource", - var n when n.StartsWith("TenantedDbContextBuilderByConnectionString") => "ConnectionString", - _ => "Single" - }; + var tenancyStyle = isConjoined + ? "Conjoined" + : builder.GetType().Name switch + { + var n when n.StartsWith("TenantedDbContextBuilderByDbDataSource") => "DbDataSource", + var n when n.StartsWith("TenantedDbContextBuilderByConnectionString") => "ConnectionString", + _ => "Single" + }; // Read the model + change-tracker config from the main context // (representative of every tenant's context). @@ -116,6 +126,15 @@ var n when n.StartsWith("TenantedDbContextBuilderByConnectionString") => "Connec .Select(DatabaseDescriptorFactory.FromDbContext) .ToList(); + if (isConjoined && tenantDatabases.Count > 0) + { + // Surface the tenant ids from the wolverine_tenants registry onto the + // single shared-database descriptor. Best-effort: a registry read + // hiccup must degrade to an empty tenant list, not null the whole + // snapshot. GH-3536. + await ApplyConjoinedTenantIdsAsync(tenantDatabases[0]); + } + try { return DbContextUsageFactory.Build( @@ -141,6 +160,36 @@ var n when n.StartsWith("TenantedDbContextBuilderByConnectionString") => "Connec return null; } } + + // Loads the active tenant ids for a conjoined DbContext from the wolverine_tenants + // registry (via the concrete ConjoinedTenantSource that is also registered as the + // IDynamicTenantSource) and stamps them onto the shared-database descriptor. + // Isolated in its own try/catch so a registry read failure degrades to no tenant ids + // rather than nulling the entire DbContextUsage. GH-3536. + private async Task ApplyConjoinedTenantIdsAsync(DatabaseDescriptor sharedDatabase) + { + try + { + var tenantSource = _services.GetService>(); + if (tenantSource == null) + { + return; + } + + await tenantSource.RefreshAsync(); + + var tenantIds = tenantSource.AllActiveByTenant() + .Select(x => x.TenantId) + .OrderBy(x => x, StringComparer.OrdinalIgnoreCase) + .ToList(); + + sharedDatabase.TenantIds.AddRange(tenantIds); + } + catch + { + // Best-effort — leave TenantIds empty if the registry can't be read. + } + } } ///