diff --git a/src/Weasel.Core/MultiTenancy/ExplicitTenantAssignment.cs b/src/Weasel.Core/MultiTenancy/ExplicitTenantAssignment.cs new file mode 100644 index 0000000..90e3001 --- /dev/null +++ b/src/Weasel.Core/MultiTenancy/ExplicitTenantAssignment.cs @@ -0,0 +1,20 @@ +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using JasperFx.MultiTenancy; + +namespace Weasel.Core.MultiTenancy; + +/// +/// Assignment strategy that always throws for unrecognized tenants. +/// Requires all tenants to be explicitly pre-assigned to a database +/// via the admin API before first use. +/// +public class ExplicitTenantAssignment : ITenantAssignmentStrategy +{ + public ValueTask AssignTenantToDatabaseAsync( + string tenantId, IReadOnlyList availableDatabases) + { + throw new UnknownTenantIdException(tenantId); + } +} diff --git a/src/Weasel.Core/MultiTenancy/HashTenantAssignment.cs b/src/Weasel.Core/MultiTenancy/HashTenantAssignment.cs new file mode 100644 index 0000000..d9518bf --- /dev/null +++ b/src/Weasel.Core/MultiTenancy/HashTenantAssignment.cs @@ -0,0 +1,44 @@ +using System; +using System.Collections.Generic; +using System.Threading.Tasks; + +namespace Weasel.Core.MultiTenancy; + +/// +/// Assigns tenants to databases using a deterministic hash of the tenant ID. +/// Uses a stable FNV-1a hash for consistent, well-distributed assignment. +/// +public class HashTenantAssignment : ITenantAssignmentStrategy +{ + public ValueTask AssignTenantToDatabaseAsync( + string tenantId, IReadOnlyList availableDatabases) + { + if (availableDatabases.Count == 0) + { + throw new InvalidOperationException( + "No available (non-full) databases in the pool to assign tenant to"); + } + + var hash = StableHash(tenantId); + var index = (int)(hash % (uint)availableDatabases.Count); + + return new ValueTask(availableDatabases[index].DatabaseId); + } + + /// + /// FNV-1a 32-bit hash — deterministic, fast, no external dependencies + /// + internal static uint StableHash(string value) + { + unchecked + { + uint hash = 2166136261; + foreach (var c in value) + { + hash ^= c; + hash *= 16777619; + } + return hash; + } + } +} diff --git a/src/Weasel.Core/MultiTenancy/IDatabaseSizingStrategy.cs b/src/Weasel.Core/MultiTenancy/IDatabaseSizingStrategy.cs new file mode 100644 index 0000000..cdb081e --- /dev/null +++ b/src/Weasel.Core/MultiTenancy/IDatabaseSizingStrategy.cs @@ -0,0 +1,18 @@ +using System.Collections.Generic; +using System.Threading.Tasks; + +namespace Weasel.Core.MultiTenancy; + +/// +/// Pluggable strategy for determining which database in a pool is the "smallest" +/// and should receive the next tenant assignment. The default implementation +/// uses tenant count, but implementations could query actual row counts, +/// disk usage, or other metrics. +/// +public interface IDatabaseSizingStrategy +{ + /// + /// Find the smallest database from the available (non-full) databases + /// + ValueTask FindSmallestDatabaseAsync(IReadOnlyList databases); +} diff --git a/src/Weasel.Core/MultiTenancy/ITenantAssignmentStrategy.cs b/src/Weasel.Core/MultiTenancy/ITenantAssignmentStrategy.cs new file mode 100644 index 0000000..41e19ff --- /dev/null +++ b/src/Weasel.Core/MultiTenancy/ITenantAssignmentStrategy.cs @@ -0,0 +1,24 @@ +using System.Collections.Generic; +using System.Threading.Tasks; + +namespace Weasel.Core.MultiTenancy; + +/// +/// Strategy for determining which database a new tenant should be assigned to. +/// Implementations are called under an advisory lock, so they do not need to +/// handle concurrency themselves. +/// +public interface ITenantAssignmentStrategy +{ + /// + /// Determine which database a new tenant should be assigned to. + /// + /// The tenant being assigned + /// Non-full databases in the pool + /// The database_id to assign the tenant to + /// + /// Thrown if no suitable database is available + /// + ValueTask AssignTenantToDatabaseAsync( + string tenantId, IReadOnlyList availableDatabases); +} diff --git a/src/Weasel.Core/MultiTenancy/ITenantDatabasePool.cs b/src/Weasel.Core/MultiTenancy/ITenantDatabasePool.cs new file mode 100644 index 0000000..2b3c0ca --- /dev/null +++ b/src/Weasel.Core/MultiTenancy/ITenantDatabasePool.cs @@ -0,0 +1,43 @@ +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; + +namespace Weasel.Core.MultiTenancy; + +/// +/// Abstraction for managing a pool of databases used in sharded multi-tenancy. +/// Implementations handle the persistence of database registry and tenant assignments. +/// +public interface ITenantDatabasePool +{ + /// + /// List all databases in the pool with their current state + /// + ValueTask> ListDatabasesAsync(CancellationToken ct); + + /// + /// Add a new database to the pool + /// + ValueTask AddDatabaseAsync(string databaseId, string connectionString, CancellationToken ct); + + /// + /// Mark a database as full so no new tenants are assigned to it + /// + ValueTask MarkDatabaseFullAsync(string databaseId, CancellationToken ct); + + /// + /// Find which database a tenant is assigned to, or null if not yet assigned + /// + ValueTask FindDatabaseForTenantAsync(string tenantId, CancellationToken ct); + + /// + /// Assign a tenant to a specific database. Does not create partitions — + /// that is the responsibility of the caller (e.g., Marten's ShardedTenancy). + /// + ValueTask AssignTenantAsync(string tenantId, string databaseId, CancellationToken ct); + + /// + /// Remove a tenant assignment + /// + ValueTask RemoveTenantAsync(string tenantId, CancellationToken ct); +} diff --git a/src/Weasel.Core/MultiTenancy/PooledDatabase.cs b/src/Weasel.Core/MultiTenancy/PooledDatabase.cs new file mode 100644 index 0000000..9b5cd94 --- /dev/null +++ b/src/Weasel.Core/MultiTenancy/PooledDatabase.cs @@ -0,0 +1,11 @@ +namespace Weasel.Core.MultiTenancy; + +/// +/// Represents a database in the sharded tenancy pool +/// +public record PooledDatabase( + string DatabaseId, + string ConnectionString, + bool IsFull, + int TenantCount +); diff --git a/src/Weasel.Core/MultiTenancy/SmallestTenantAssignment.cs b/src/Weasel.Core/MultiTenancy/SmallestTenantAssignment.cs new file mode 100644 index 0000000..3c8d8e5 --- /dev/null +++ b/src/Weasel.Core/MultiTenancy/SmallestTenantAssignment.cs @@ -0,0 +1,45 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; + +namespace Weasel.Core.MultiTenancy; + +/// +/// Assigns tenants to the database with the fewest tenants (or custom sizing metric). +/// Uses a pluggable for determining "smallest". +/// Defaults to sorting by . +/// +public class SmallestTenantAssignment : ITenantAssignmentStrategy +{ + private readonly IDatabaseSizingStrategy _sizingStrategy; + + public SmallestTenantAssignment(IDatabaseSizingStrategy? sizingStrategy = null) + { + _sizingStrategy = sizingStrategy ?? new TenantCountSizingStrategy(); + } + + public ValueTask AssignTenantToDatabaseAsync( + string tenantId, IReadOnlyList availableDatabases) + { + if (availableDatabases.Count == 0) + { + throw new InvalidOperationException( + "No available (non-full) databases in the pool to assign tenant to"); + } + + return _sizingStrategy.FindSmallestDatabaseAsync(availableDatabases); + } +} + +/// +/// Default sizing strategy that picks the database with the lowest tenant count. +/// +public class TenantCountSizingStrategy : IDatabaseSizingStrategy +{ + public ValueTask FindSmallestDatabaseAsync(IReadOnlyList databases) + { + var smallest = databases.OrderBy(d => d.TenantCount).First(); + return new ValueTask(smallest.DatabaseId); + } +} diff --git a/src/Weasel.Postgresql/Tables/DatabasePoolTable.cs b/src/Weasel.Postgresql/Tables/DatabasePoolTable.cs new file mode 100644 index 0000000..e52df8e --- /dev/null +++ b/src/Weasel.Postgresql/Tables/DatabasePoolTable.cs @@ -0,0 +1,21 @@ +using Weasel.Core; + +namespace Weasel.Postgresql.Tables; + +/// +/// Schema definition for the mt_database_pool table used by sharded multi-tenancy. +/// Tracks the available databases in the pool with their capacity status. +/// +public class DatabasePoolTable : Table +{ + public const string TableName = "mt_database_pool"; + + public DatabasePoolTable(string schemaName) + : base(new DbObjectName(schemaName, TableName)) + { + AddColumn("database_id").AsPrimaryKey(); + AddColumn("connection_string").NotNull(); + AddColumn("is_full").NotNull().DefaultValueByExpression("false"); + AddColumn("tenant_count").NotNull().DefaultValue(0); + } +} diff --git a/src/Weasel.Postgresql/Tables/TenantAssignmentTable.cs b/src/Weasel.Postgresql/Tables/TenantAssignmentTable.cs new file mode 100644 index 0000000..b969403 --- /dev/null +++ b/src/Weasel.Postgresql/Tables/TenantAssignmentTable.cs @@ -0,0 +1,28 @@ +using Weasel.Core; + +namespace Weasel.Postgresql.Tables; + +/// +/// Schema definition for the mt_tenant_assignments table used by sharded multi-tenancy. +/// Maps tenant IDs to their assigned database in the pool. +/// +public class TenantAssignmentTable : Table +{ + public const string TableName = "mt_tenant_assignments"; + + public TenantAssignmentTable(string schemaName) + : base(new DbObjectName(schemaName, TableName)) + { + AddColumn("tenant_id").AsPrimaryKey(); + AddColumn("database_id").NotNull(); + AddColumn("assigned_at", "timestamptz").NotNull().DefaultValueByExpression("now()"); + + // Foreign key to the database pool table + ForeignKeys.Add(new ForeignKey("fk_tenant_assignment_database") + { + LinkedTable = new DbObjectName(schemaName, DatabasePoolTable.TableName), + ColumnNames = new[] { "database_id" }, + LinkedNames = new[] { "database_id" } + }); + } +}