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,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;

/// <summary>
/// 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.
/// </summary>
[Collection("multi-tenancy")]
public abstract class ConjoinedDescriptorCompliance : IAsyncLifetime
{
private readonly DatabaseEngine _engine;
protected IHost theHost = null!;
protected IDynamicTenantSource<string> 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<ConjoinedItemHandler>();

if (_engine == DatabaseEngine.PostgreSQL)
{
opts.PersistMessagesWithPostgresql(Servers.PostgresConnectionString, "conjoined_descriptor");
opts.Services.AddDbContextWithWolverineManagedConjoinedTenancy<ConjoinedItemsDbContext>(
(builder, connectionString) => builder.UseNpgsql(connectionString.Value),
AutoCreate.CreateOrUpdate);
}
else
{
opts.PersistMessagesWithSqlServer(Servers.SqlServerConnectionString, "conjoined_descriptor");
opts.Services.AddDbContextWithWolverineManagedConjoinedTenancy<ConjoinedItemsDbContext>(
(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<IDynamicTenantSource<string>>();
}

public async Task DisposeAsync()
{
await theHost.StopAsync();
theHost.Dispose();
}

private async Task<DbContextUsage> createUsageAsync()
{
var source = theHost.Services.GetServices<IDbContextUsageSource>()
.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)
{
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -96,14 +96,24 @@ public TenantedDbContextUsageSource(IServiceProvider services)
{
var builder = _services.GetRequiredService<IDbContextBuilder<T>>();

// 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<T>;

// 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).
Expand All @@ -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(
Expand All @@ -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<T> that is also registered as the
// IDynamicTenantSource<string>) 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<ConjoinedTenantSource<T>>();
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.
}
}
}

/// <summary>
Expand Down