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.
+ }
+ }
}
///