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
20 changes: 20 additions & 0 deletions src/Weasel.Core/MultiTenancy/ExplicitTenantAssignment.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
using System;
using System.Collections.Generic;
using System.Threading.Tasks;
using JasperFx.MultiTenancy;

namespace Weasel.Core.MultiTenancy;

/// <summary>
/// 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.
/// </summary>
public class ExplicitTenantAssignment : ITenantAssignmentStrategy
{
public ValueTask<string> AssignTenantToDatabaseAsync(
string tenantId, IReadOnlyList<PooledDatabase> availableDatabases)
{
throw new UnknownTenantIdException(tenantId);
}
}
44 changes: 44 additions & 0 deletions src/Weasel.Core/MultiTenancy/HashTenantAssignment.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
using System;
using System.Collections.Generic;
using System.Threading.Tasks;

namespace Weasel.Core.MultiTenancy;

/// <summary>
/// Assigns tenants to databases using a deterministic hash of the tenant ID.
/// Uses a stable FNV-1a hash for consistent, well-distributed assignment.
/// </summary>
public class HashTenantAssignment : ITenantAssignmentStrategy
{
public ValueTask<string> AssignTenantToDatabaseAsync(
string tenantId, IReadOnlyList<PooledDatabase> 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<string>(availableDatabases[index].DatabaseId);
}

/// <summary>
/// FNV-1a 32-bit hash — deterministic, fast, no external dependencies
/// </summary>
internal static uint StableHash(string value)
{
unchecked
{
uint hash = 2166136261;
foreach (var c in value)
{
hash ^= c;
hash *= 16777619;
}
return hash;
}
}
}
18 changes: 18 additions & 0 deletions src/Weasel.Core/MultiTenancy/IDatabaseSizingStrategy.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
using System.Collections.Generic;
using System.Threading.Tasks;

namespace Weasel.Core.MultiTenancy;

/// <summary>
/// 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.
/// </summary>
public interface IDatabaseSizingStrategy
{
/// <summary>
/// Find the smallest database from the available (non-full) databases
/// </summary>
ValueTask<string> FindSmallestDatabaseAsync(IReadOnlyList<PooledDatabase> databases);
}
24 changes: 24 additions & 0 deletions src/Weasel.Core/MultiTenancy/ITenantAssignmentStrategy.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
using System.Collections.Generic;
using System.Threading.Tasks;

namespace Weasel.Core.MultiTenancy;

/// <summary>
/// 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.
/// </summary>
public interface ITenantAssignmentStrategy
{
/// <summary>
/// Determine which database a new tenant should be assigned to.
/// </summary>
/// <param name="tenantId">The tenant being assigned</param>
/// <param name="availableDatabases">Non-full databases in the pool</param>
/// <returns>The database_id to assign the tenant to</returns>
/// <exception cref="System.InvalidOperationException">
/// Thrown if no suitable database is available
/// </exception>
ValueTask<string> AssignTenantToDatabaseAsync(
string tenantId, IReadOnlyList<PooledDatabase> availableDatabases);
}
43 changes: 43 additions & 0 deletions src/Weasel.Core/MultiTenancy/ITenantDatabasePool.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;

namespace Weasel.Core.MultiTenancy;

/// <summary>
/// Abstraction for managing a pool of databases used in sharded multi-tenancy.
/// Implementations handle the persistence of database registry and tenant assignments.
/// </summary>
public interface ITenantDatabasePool
{
/// <summary>
/// List all databases in the pool with their current state
/// </summary>
ValueTask<IReadOnlyList<PooledDatabase>> ListDatabasesAsync(CancellationToken ct);

/// <summary>
/// Add a new database to the pool
/// </summary>
ValueTask AddDatabaseAsync(string databaseId, string connectionString, CancellationToken ct);

/// <summary>
/// Mark a database as full so no new tenants are assigned to it
/// </summary>
ValueTask MarkDatabaseFullAsync(string databaseId, CancellationToken ct);

/// <summary>
/// Find which database a tenant is assigned to, or null if not yet assigned
/// </summary>
ValueTask<string?> FindDatabaseForTenantAsync(string tenantId, CancellationToken ct);

/// <summary>
/// Assign a tenant to a specific database. Does not create partitions —
/// that is the responsibility of the caller (e.g., Marten's ShardedTenancy).
/// </summary>
ValueTask AssignTenantAsync(string tenantId, string databaseId, CancellationToken ct);

/// <summary>
/// Remove a tenant assignment
/// </summary>
ValueTask RemoveTenantAsync(string tenantId, CancellationToken ct);
}
11 changes: 11 additions & 0 deletions src/Weasel.Core/MultiTenancy/PooledDatabase.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
namespace Weasel.Core.MultiTenancy;

/// <summary>
/// Represents a database in the sharded tenancy pool
/// </summary>
public record PooledDatabase(
string DatabaseId,
string ConnectionString,
bool IsFull,
int TenantCount
);
45 changes: 45 additions & 0 deletions src/Weasel.Core/MultiTenancy/SmallestTenantAssignment.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;

namespace Weasel.Core.MultiTenancy;

/// <summary>
/// Assigns tenants to the database with the fewest tenants (or custom sizing metric).
/// Uses a pluggable <see cref="IDatabaseSizingStrategy"/> for determining "smallest".
/// Defaults to sorting by <see cref="PooledDatabase.TenantCount"/>.
/// </summary>
public class SmallestTenantAssignment : ITenantAssignmentStrategy
{
private readonly IDatabaseSizingStrategy _sizingStrategy;

public SmallestTenantAssignment(IDatabaseSizingStrategy? sizingStrategy = null)
{
_sizingStrategy = sizingStrategy ?? new TenantCountSizingStrategy();
}

public ValueTask<string> AssignTenantToDatabaseAsync(
string tenantId, IReadOnlyList<PooledDatabase> availableDatabases)
{
if (availableDatabases.Count == 0)
{
throw new InvalidOperationException(
"No available (non-full) databases in the pool to assign tenant to");
}

return _sizingStrategy.FindSmallestDatabaseAsync(availableDatabases);
}
}

/// <summary>
/// Default sizing strategy that picks the database with the lowest tenant count.
/// </summary>
public class TenantCountSizingStrategy : IDatabaseSizingStrategy
{
public ValueTask<string> FindSmallestDatabaseAsync(IReadOnlyList<PooledDatabase> databases)
{
var smallest = databases.OrderBy(d => d.TenantCount).First();
return new ValueTask<string>(smallest.DatabaseId);
}
}
21 changes: 21 additions & 0 deletions src/Weasel.Postgresql/Tables/DatabasePoolTable.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
using Weasel.Core;

namespace Weasel.Postgresql.Tables;

/// <summary>
/// Schema definition for the mt_database_pool table used by sharded multi-tenancy.
/// Tracks the available databases in the pool with their capacity status.
/// </summary>
public class DatabasePoolTable : Table
{
public const string TableName = "mt_database_pool";

public DatabasePoolTable(string schemaName)
: base(new DbObjectName(schemaName, TableName))
{
AddColumn<string>("database_id").AsPrimaryKey();
AddColumn<string>("connection_string").NotNull();
AddColumn<bool>("is_full").NotNull().DefaultValueByExpression("false");
AddColumn<int>("tenant_count").NotNull().DefaultValue(0);
}
}
28 changes: 28 additions & 0 deletions src/Weasel.Postgresql/Tables/TenantAssignmentTable.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
using Weasel.Core;

namespace Weasel.Postgresql.Tables;

/// <summary>
/// Schema definition for the mt_tenant_assignments table used by sharded multi-tenancy.
/// Maps tenant IDs to their assigned database in the pool.
/// </summary>
public class TenantAssignmentTable : Table
{
public const string TableName = "mt_tenant_assignments";

public TenantAssignmentTable(string schemaName)
: base(new DbObjectName(schemaName, TableName))
{
AddColumn<string>("tenant_id").AsPrimaryKey();
AddColumn<string>("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" }
});
}
}
Loading