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
14 changes: 7 additions & 7 deletions Directory.Packages.props
Original file line number Diff line number Diff line change
Expand Up @@ -127,13 +127,13 @@
<PackageVersion Include="System.Diagnostics.DiagnosticSource" Version="9.0.5" />
<PackageVersion Include="System.Net.NameResolution" Version="4.3.0" />
<PackageVersion Include="System.Threading.Tasks.Dataflow" Version="9.0.5" />
<PackageVersion Include="Weasel.Core" Version="9.19.0" />
<PackageVersion Include="Weasel.EntityFrameworkCore" Version="9.19.0" />
<PackageVersion Include="Weasel.MySql" Version="9.19.0" />
<PackageVersion Include="Weasel.Oracle" Version="9.19.0" />
<PackageVersion Include="Weasel.Postgresql" Version="9.19.0" />
<PackageVersion Include="Weasel.SqlServer" Version="9.19.0" />
<PackageVersion Include="Weasel.Sqlite" Version="9.19.0" />
<PackageVersion Include="Weasel.Core" Version="9.20.0" />
<PackageVersion Include="Weasel.EntityFrameworkCore" Version="9.20.0" />
<PackageVersion Include="Weasel.MySql" Version="9.20.0" />
<PackageVersion Include="Weasel.Oracle" Version="9.20.0" />
<PackageVersion Include="Weasel.Postgresql" Version="9.20.0" />
<PackageVersion Include="Weasel.SqlServer" Version="9.20.0" />
<PackageVersion Include="Weasel.Sqlite" Version="9.20.0" />
<PackageVersion Include="xunit" Version="2.9.3" />
<PackageVersion Include="xunit.assemblyfixture" Version="2.2.0" />
<PackageVersion Include="xunit.runner.visualstudio" Version="2.8.2" />
Expand Down
56 changes: 52 additions & 4 deletions docs/guide/durability/efcore/multi-tenancy.md
Original file line number Diff line number Diff line change
Expand Up @@ -572,7 +572,12 @@ opts.Services.AddDbContextWithWolverineManagedConjoinedTenancy<ConjoinedTenancy.

// Weasel-managed physical partitioning: one partition (or shared
// bucket) per tenant on every non-saga ITenanted entity table
tenancy => tenancy.PartitionPerTenant());
tenancy => tenancy.PartitionPerTenant(partitioning =>
{
// Opt in before registering two tenants against one suffix.
// Without this a shared suffix is rejected outright
partitioning.AllowPartitionSharing = true;
}));
```
<sup><a href='https://github.com/JasperFx/wolverine/blob/main/src/Persistence/EfCoreTests.MultiTenancy/MultiTenancyDocumentationSamples.cs#L257-L265' title='Snippet source file'>snippet source</a> | <a href='#snippet-sample_conjoined_tenancy_with_partitioning' title='Start of snippet'>anchor</a></sup>
<!-- endSnippet -->
Expand All @@ -582,7 +587,7 @@ With partitioning enabled:
* On PostgreSQL, every non-saga `ITenanted` entity table becomes `PARTITION BY LIST (tenant_id)` with one partition per tenant, managed through a `wolverine_tenant_partitions` control table in the durability schema
* On SQL Server (which can only range-partition over a compact value), entities gain an `int tenant_ordinal` column stamped automatically by Wolverine, and tables are `RANGE RIGHT` partitioned over the ordinal with a registry table mapping tenant ids to ordinals
* The composite `(tenant, id)` primary key exists **only in the database** — your EF model keeps its own single key, so `FindAsync()`, `Attach()`, and saga loads keep exactly the same call shapes
* Multiple small tenants can share one physical partition ("bucketing") by registering them with the same partition suffix — the answer to SQL Server's partition count ceiling and to "small tenants don't deserve their own partition"
* Multiple small tenants can share one physical partition ("bucketing") by registering them with the same partition suffix — the answer to SQL Server's partition count ceiling and to "small tenants don't deserve their own partition". See [Tenant Bucketing](#tenant-bucketing) below
* Partitioned conjoined contexts require `UseEntityFrameworkCoreWolverineManagedMigrations()` — EF migrations cannot express the partition DDL

Manage tenants through `IConjoinedTenantPartitions<TDbContext>`:
Expand All @@ -596,8 +601,10 @@ var partitions = host.Services
// Each tenant gets its own physical partition
await partitions.AddTenantAsync("tenant1");

// Or share one partition between small tenants ("bucketing") --
// requires AllowPartitionSharing on the partitioning options
// Or share one partition between small tenants ("bucketing") by registering
// them against the same suffix -- requires AllowPartitionSharing above.
// Members can be added one at a time as tenants onboard; the bucket is
// resolved from storage, so they land in the same physical partition
await partitions.AddTenantAsync("small-tenant-a", "shared_bucket");
await partitions.AddTenantAsync("small-tenant-b", "shared_bucket");

Expand All @@ -611,6 +618,47 @@ Note that with partitioning enabled, a tenant's partition must exist before rows
Sagas are deliberately **not** partitioned in this release — they keep the conjoined query filtering and tenant
stamping, but stay in unpartitioned tables so saga identity is untouched.

### Tenant Bucketing <Badge type="tip" text="6.24" />

Giving every tenant its own physical partition stops scaling somewhere — SQL Server caps a table at 15,000
partitions, and long before that a few thousand nearly-empty partitions cost more in planning time than they
save in scans. *Bucketing* is the escape hatch: register several small tenants against the same partition
suffix and they share one physical partition, while large tenants keep theirs to themselves.

Bucketing is opt-in. Set `AllowPartitionSharing` on the partitioning options, then pass the same suffix for
every member of a bucket:

```cs
tenancy => tenancy.PartitionPerTenant(p => p.AllowPartitionSharing = true);

// ...

// big tenants keep a partition each
await partitions.AddTenantAsync("enterprise-customer");

// small ones share -- registered together, or one at a time as they sign up
await partitions.AddTenantAsync("small-tenant-a", "shared_bucket");
await partitions.AddTenantAsync("small-tenant-b", "shared_bucket");
```

Members can be registered together or in completely separate calls; the bucket is resolved from storage, so
a tenant onboarding a release later still lands in the partition its bucket already owns.

Dropping one member of a bucket removes only that tenant's rows — the remaining members keep the partition and
their data. The partition itself is released only when its last member is dropped.

::: warning
`AllowPartitionSharing` is off by default, and passing a shared suffix without it fails fast rather than
quietly giving each tenant its own partition. Leave it off unless you actually want tenants sharing storage:
a shared partition means partition pruning no longer isolates those tenants from each other, and a
partition-level operation touches every member.
:::

::: tip
Bucketing required Weasel 9.20.0 (`JasperFx/weasel#391`). On earlier versions a shared suffix did not
actually produce a shared partition on either engine — see [GH-3683](https://github.com/JasperFx/wolverine/issues/3683).
:::

### Partition Status Reporting <Badge type="tip" text="6.24" />

Partition DDL is applied one table at a time with failures isolated, so a batch registration can partially
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -114,15 +114,17 @@ public async Task InitializeAsync()
opts.Services.AddDbContextWithWolverineManagedConjoinedTenancy<PartitionedItemsDbContext>(
(builder, connectionString) => builder.UseNpgsql(connectionString.Value),
AutoCreate.CreateOrUpdate,
tenancy => tenancy.PartitionPerTenant());
// Bucketing is opt-in; enabling it here does not change the behavior of the
// non-bucketed tests, which still register tenants with no suffix
tenancy => tenancy.PartitionPerTenant(p => p.AllowPartitionSharing = true));
}
else
{
opts.PersistMessagesWithSqlServer(Servers.SqlServerConnectionString, "conjoined_part_wolverine");
opts.Services.AddDbContextWithWolverineManagedConjoinedTenancy<PartitionedItemsDbContext>(
(builder, connectionString) => builder.UseSqlServer(connectionString.Value),
AutoCreate.CreateOrUpdate,
tenancy => tenancy.PartitionPerTenant());
tenancy => tenancy.PartitionPerTenant(p => p.AllowPartitionSharing = true));
}

opts.UseEntityFrameworkCoreTransactions();
Expand Down Expand Up @@ -282,6 +284,118 @@ await theHost.ExecuteAndWaitAsync(c =>
(await green.Items.ToListAsync()).Single().Id.ShouldBe(id);
}

[Fact]
public async Task bucketed_tenants_registered_separately_share_one_partition()
{
// GH-3683 / weasel#391: registering bucket members ONE AT A TIME is the documented shape and the
// natural tenant-onboarding shape, and it silently did not work on either engine. PostgreSQL's
// second member was swallowed by CREATE TABLE IF NOT EXISTS so its first write failed with 23514;
// SQL Server's registry had no bucket key, so each call quietly allocated a separate ordinal and
// the tenants never actually shared the partition that bucketing exists to give them.
await thePartitions.AddTenantAsync("smalla", "shared_bucket");
await thePartitions.AddTenantAsync("smallb", "shared_bucket");

// Both members read and write...
var aId = Guid.NewGuid();
var bId = Guid.NewGuid();
await theHost.ExecuteAndWaitAsync(c =>
c.InvokeForTenantAsync("smalla", new CreatePartitionedItem(aId, "a")));
await theHost.ExecuteAndWaitAsync(c =>
c.InvokeForTenantAsync("smallb", new CreatePartitionedItem(bId, "b")));

var a = await theBuilder.BuildAsync("smalla", CancellationToken.None);
(await a.Items.ToListAsync()).Single().Id.ShouldBe(aId);

var b = await theBuilder.BuildAsync("smallb", CancellationToken.None);
(await b.Items.ToListAsync()).Single().Id.ShouldBe(bId);

// ...and they genuinely share ONE physical partition, which is the entire point
(await distinctPartitionCountAsync(["smalla", "smallb"])).ShouldBe(1);
}

[Fact]
public async Task bucketed_tenants_registered_together_share_one_partition()
{
await thePartitions.AddTenantsAsync(new Dictionary<string, string?>
{
["smalla"] = "shared_bucket",
["smallb"] = "shared_bucket"
});

(await distinctPartitionCountAsync(["smalla", "smallb"])).ShouldBe(1);
}

[Fact]
public async Task dropping_one_bucket_member_leaves_the_others_working()
{
// The co-tenant data-loss defect found alongside GH-3683: on PostgreSQL the by-value drop resolved
// the tenant to its suffix and dropped BY SUFFIX, taking every co-tenant's rows with it.
await thePartitions.AddTenantAsync("smalla", "shared_bucket");
await thePartitions.AddTenantAsync("smallb", "shared_bucket");

var survivorId = Guid.NewGuid();
await theHost.ExecuteAndWaitAsync(c =>
c.InvokeForTenantAsync("smalla", new CreatePartitionedItem(Guid.NewGuid(), "doomed")));
await theHost.ExecuteAndWaitAsync(c =>
c.InvokeForTenantAsync("smallb", new CreatePartitionedItem(survivorId, "survivor")));

await thePartitions.DropTenantAsync("smalla", deleteData: true);

// The survivor keeps its rows...
var b = await theBuilder.BuildAsync("smallb", CancellationToken.None);
(await b.Items.ToListAsync()).Single().Id.ShouldBe(survivorId);

// ...and can still write
var moreId = Guid.NewGuid();
await theHost.ExecuteAndWaitAsync(c =>
c.InvokeForTenantAsync("smallb", new CreatePartitionedItem(moreId, "more")));

b = await theBuilder.BuildAsync("smallb", CancellationToken.None);
(await b.Items.ToListAsync()).Select(x => x.Id).OrderBy(x => x)
.ShouldBe(new[] { survivorId, moreId }.OrderBy(x => x));
}

/// <summary>
/// How many distinct physical partitions the given tenants occupy. PostgreSQL partitions by list on
/// the tenant id, so the partition is the child table the row lands in; SQL Server partitions over a
/// compact ordinal, so it is the registered ordinal
/// </summary>
private async Task<int> distinctPartitionCountAsync(string[] tenantIds)
{
if (_engine == DatabaseEngine.PostgreSQL)
{
await using var conn = new NpgsqlConnection(Servers.PostgresConnectionString);
await conn.OpenAsync();
await using var cmd = conn.CreateCommand();
cmd.CommandText = @"
select count(distinct c.relname)
from pg_class c
join pg_inherits i on i.inhrelid = c.oid
join pg_class parent on i.inhparent = parent.oid
join pg_namespace ns on parent.relnamespace = ns.oid
where ns.nspname = 'conjoined_part' and parent.relname = 'partitioned_items'
and pg_get_expr(c.relpartbound, c.oid) like any (@patterns)";
var parameter = cmd.CreateParameter();
parameter.ParameterName = "patterns";
parameter.Value = tenantIds.Select(x => $"%'{x}'%").ToArray();
cmd.Parameters.Add(parameter);
return (int)(long)(await cmd.ExecuteScalarAsync())!;
}

await using var sqlConn = new SqlConnection(Servers.SqlServerConnectionString);
await sqlConn.OpenAsync();
await using var sqlCmd = sqlConn.CreateCommand();
var names = tenantIds.Select((_, i) => $"@t{i}").ToArray();
for (var i = 0; i < tenantIds.Length; i++)
{
sqlCmd.Parameters.AddWithValue($"@t{i}", tenantIds[i]);
}

sqlCmd.CommandText =
$"SELECT COUNT(DISTINCT ordinal) FROM conjoined_part_wolverine.wolverine_tenant_partitions WHERE tenant_id IN ({string.Join(", ", names)})";
return (int)(await sqlCmd.ExecuteScalarAsync())!;
}

[Fact]
public async Task physical_partition_exists_per_tenant()
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -261,7 +261,12 @@ public async Task conjoined_partitioned_postgresql()

// Weasel-managed physical partitioning: one partition (or shared
// bucket) per tenant on every non-saga ITenanted entity table
tenancy => tenancy.PartitionPerTenant());
tenancy => tenancy.PartitionPerTenant(partitioning =>
{
// Opt in before registering two tenants against one suffix.
// Without this a shared suffix is rejected outright
partitioning.AllowPartitionSharing = true;
}));
#endregion
});
}
Expand All @@ -275,8 +280,10 @@ public static async Task conjoined_tenant_management(IHost host)
// Each tenant gets its own physical partition
await partitions.AddTenantAsync("tenant1");

// Or share one partition between small tenants ("bucketing") --
// requires AllowPartitionSharing on the partitioning options
// Or share one partition between small tenants ("bucketing") by registering
// them against the same suffix -- requires AllowPartitionSharing above.
// Members can be added one at a time as tenants onboard; the bucket is
// resolved from storage, so they land in the same physical partition
await partitions.AddTenantAsync("small-tenant-a", "shared_bucket");
await partitions.AddTenantAsync("small-tenant-b", "shared_bucket");

Expand Down
Loading