Multi-Tenanted Documents
Marten supports multi-tenancy to provide data isolation between tenants, aka groups of users. In effect, this allows scoping storage operations, such as persisting and loading data, so that no tenant can access data of others. Marten provides multi-tenancy at the logical level, by associating data records with a tenant identifier. In addition, multi-tenancy through separate databases or schemas is planned.
By default, Marten operates in single-tenancy mode (TenancyStyle.Single
) with multi-tenancy disabled.
Once configured for multi-tenancy, Marten exposes it via sessions (IQuerySession
, IDocumentSession
) scoped to specific tenants, as well as various overloads to saving operations that accept a tenant identifier.
Scoping Sessions to Tenancy
The following sample demonstrates scoping a document session to tenancy identified as tenant1. With multi-tenancy enabled, the persisted User
objects are then associated with the tenancy of the session.
// Write some User documents to tenant "tenant1"
using (var session = theStore.LightweightSession("tenant1"))
{
session.Store(new User { Id = "u1", UserName = "Bill", Roles = new[] { "admin" } });
session.Store(new User { Id = "u2", UserName = "Lindsey", Roles = new string[0] });
await session.SaveChangesAsync();
}
// Write some User documents to tenant "tenant1"
using (var session = theStore.LightweightSession("tenant1"))
{
session.Store(new User { Id = "u1", UserName = "Bill", Roles = new[] { "admin" } });
session.Store(new User { Id = "u2", UserName = "Lindsey", Roles = new string[0] });
await session.SaveChangesAsync();
}
// Write some User documents to tenant "tenant1"
using (var session = store.LightweightSession("tenant1"))
{
session.Store(new User { UserName = "Bill" });
session.Store(new User { UserName = "Lindsey" });
await session.SaveChangesAsync();
}
As with storing, the load operations respect tenancy of the session.
// When you query for data from the "tenant1" tenant,
// you only get data for that tenant
using (var query = store.QuerySession("tenant1"))
{
query.Query<User>()
.Select(x => x.UserName)
.ToList()
.ShouldHaveTheSameElementsAs("Bill", "Lindsey");
}
Lastly, unlike reading operations, IDocumentSession.Store
offers an overload to explicitly pass in a tenant identifier, bypassing any tenancy associated with the session. Similar overload for tenancy exists for IDocumentStore.BulkInsert
.
Default Tenancy
With multi-tenancy enabled, Marten associates each record with a tenancy. If no explicit tenancy is specified, either via policies, mappings, scoped sessions or overloads, Marten will default to Tenancy.DefaultTenantId
with a constant value of *DEFAULT*
.
The following sample demonstrates persisting documents as non-tenanted, under default tenant and other named tenants then querying them back in a session scoped to a specific named tenant and default tenant.
using var store = DocumentStore.For(opts =>
{
opts.DatabaseSchemaName = "mixed_multi_tenants";
opts.Connection(ConnectionSource.ConnectionString);
opts.Schema.For<Target>().MultiTenanted(); // tenanted
opts.Schema.For<User>(); // non-tenanted
opts.Schema.For<Issue>().MultiTenanted(); // tenanted
});
store.Advanced.Clean.DeleteAllDocuments();
// Add documents to tenant Green
var greens = Target.GenerateRandomData(10).ToArray();
store.BulkInsert("Green", greens);
// Add documents to tenant Red
var reds = Target.GenerateRandomData(11).ToArray();
store.BulkInsert("Red", reds);
// Add non-tenanted documents
// User is non-tenanted in schema
var user1 = new User { UserName = "Frank" };
var user2 = new User { UserName = "Bill" };
store.BulkInsert(new[] { user1, user2 });
// Add documents to default tenant
// Note that schema for Issue is multi-tenanted hence documents will get added
// to default tenant if tenant is not passed in the bulk insert operation
var issue1 = new Issue { Title = "Test issue1" };
var issue2 = new Issue { Title = "Test issue2" };
store.BulkInsert(new[] { issue1, issue2 });
// Create a session with tenant Green
using (var session = store.QuerySession("Green"))
{
// Query tenanted document as the tenant passed in session
session.Query<Target>().Count().ShouldBe(10);
// Query non-tenanted documents
session.Query<User>().Count().ShouldBe(2);
// Query documents in default tenant from a session using tenant Green
session.Query<Issue>().Count(x => x.TenantIsOneOf(Tenancy.DefaultTenantId)).ShouldBe(2);
// Query documents from tenant Red from a session using tenant Green
session.Query<Target>().Count(x => x.TenantIsOneOf("Red")).ShouldBe(11);
}
// create a session without passing any tenant, session will use default tenant
using (var session = store.QuerySession())
{
// Query non-tenanted documents
session.Query<User>().Count().ShouldBe(2);
// Query documents in default tenant
// Note that session is using default tenant
session.Query<Issue>().Count().ShouldBe(2);
// Query documents on tenant Green
session.Query<Target>().Count(x => x.TenantIsOneOf("Green")).ShouldBe(10);
// Query documents on tenant Red
session.Query<Target>().Count(x => x.TenantIsOneOf("Red")).ShouldBe(11);
}
using var store = DocumentStore.For(opts =>
{
opts.DatabaseSchemaName = "mixed_multi_tenants";
opts.Connection(ConnectionSource.ConnectionString);
opts.Schema.For<Target>().MultiTenanted(); // tenanted
opts.Schema.For<User>(); // non-tenanted
opts.Schema.For<Issue>().MultiTenanted(); // tenanted
});
store.Advanced.Clean.DeleteAllDocuments();
// Add documents to tenant Green
var greens = Target.GenerateRandomData(10).ToArray();
store.BulkInsert("Green", greens);
// Add documents to tenant Red
var reds = Target.GenerateRandomData(11).ToArray();
store.BulkInsert("Red", reds);
// Add non-tenanted documents
// User is non-tenanted in schema
var user1 = new User { UserName = "Frank" };
var user2 = new User { UserName = "Bill" };
store.BulkInsert(new[] { user1, user2 });
// Add documents to default tenant
// Note that schema for Issue is multi-tenanted hence documents will get added
// to default tenant if tenant is not passed in the bulk insert operation
var issue1 = new Issue { Title = "Test issue1" };
var issue2 = new Issue { Title = "Test issue2" };
store.BulkInsert(new[] { issue1, issue2 });
// Create a session with tenant Green
using (var session = store.QuerySession("Green"))
{
// Query tenanted document as the tenant passed in session
session.Query<Target>().Count().ShouldBe(10);
// Query non-tenanted documents
session.Query<User>().Count().ShouldBe(2);
// Query documents in default tenant from a session using tenant Green
session.Query<Issue>().Count(x => x.TenantIsOneOf(Tenancy.DefaultTenantId)).ShouldBe(2);
// Query documents from tenant Red from a session using tenant Green
session.Query<Target>().Count(x => x.TenantIsOneOf("Red")).ShouldBe(11);
}
// create a session without passing any tenant, session will use default tenant
using (var session = store.QuerySession())
{
// Query non-tenanted documents
session.Query<User>().Count().ShouldBe(2);
// Query documents in default tenant
// Note that session is using default tenant
session.Query<Issue>().Count().ShouldBe(2);
// Query documents on tenant Green
session.Query<Target>().Count(x => x.TenantIsOneOf("Green")).ShouldBe(10);
// Query documents on tenant Red
session.Query<Target>().Count(x => x.TenantIsOneOf("Red")).ShouldBe(11);
}
In some cases, You may want to disable using the default tenant for storing documents, set StoreOptions.DefaultTenantUsageEnabled
to false
. With this option disabled, Tenant (non-default tenant) should be passed via method argument or SessionOptions
when creating a session using document store. Marten will throw an exception DefaultTenantUsageDisabledException
if a session is created using default tenant.
Querying Multi-Tenanted Documents
Inside the LINQ provider, when you open a session for a specific tenant like so:
// When you query for data from the "tenant1" tenant,
// you only get data for that tenant
using (var query = store.QuerySession("tenant1"))
{
query.Query<User>()
.Select(x => x.UserName)
.ToList()
.ShouldHaveTheSameElementsAs("Bill", "Lindsey");
}
Marten will automatically filter the LINQ query for the current tenant if the current document type is tenanted. However, if you want to query across multiple tenants or across documents for any tenant, you're still in luck with the TenantIsOneOf()
LINQ filter:
// query data for a selected list of tenants
var actual = await query.Query<Target>().Where(x => x.TenantIsOneOf("Green", "Red") && x.Flag)
.OrderBy(x => x.Id).Select(x => x.Id).ToListAsync();
// query data for a selected list of tenants
var actual = await query.Query<Target>().Where(x => x.TenantIsOneOf("Green", "Red") && x.Flag)
.OrderBy(x => x.Id).Select(x => x.Id).ToListAsync();
Or the AnyTenant()
filter:
// query data across all tenants
var actual = query.Query<Target>().Where(x => x.AnyTenant() && x.Flag)
.OrderBy(x => x.Id).Select(x => x.Id).ToArray();
// query data across all tenants
var actual = query.Query<Target>().Where(x => x.AnyTenant() && x.Flag)
.OrderBy(x => x.Id).Select(x => x.Id).ToArray();
Configuring Tenancy
The three levels of tenancy that Marten supports are expressed in the enum TenancyStyle
with effective values of:
Single
, no multi-tenancyConjoined
, multi-tenancy through tenant idSeparate
, multi-tenancy through separate databases or schemas
Tenancy can be configured at the store level, applying to all documents or, at the most fine-grained level, on individual documents.
Partitioning by Tenant 7.26
WARNING
This is an "opt in" model so as to not impact existing users. Moving from non-partitioned to partitioned tables may require some system downtime as this requires some potentially destructive changes to the database as Marten will have to copy, drop, and recreate the document storage.
If you are using conjoined multi-tenancy with Marten, you may be able to achieve a significant performance gain by opting into PostgreSQL table partitioning based on the tenant id. This can be a way to achieve greater scalability without having to take on the extra deployment complexity that comes with multi-tenancy through separate databases. In effect, this lets PostgreSQL store data in smaller, tenant specific table partitions.
To enable table partitioning by the tenant id for all document types, use this syntax:
storeOptions.Policies.AllDocumentsAreMultiTenantedWithPartitioning(x =>
{
// Selectively by LIST partitioning
x.ByList()
// Adding explicit table partitions for specific tenant ids
.AddPartition("t1", "T1")
.AddPartition("t2", "T2");
// OR Use LIST partitioning, but allow the partition tables to be
// controlled outside of Marten by something like pg_partman
// https://github.com/pgpartman/pg_partman
x.ByExternallyManagedListPartitions();
// OR Just spread out the tenant data by tenant id through
// HASH partitioning
// This is using three different partitions with the supplied
// suffix names
x.ByHash("one", "two", "three");
// OR Partition by tenant id based on ranges of tenant id values
x.ByRange()
.AddRange("north_america", "na", "nazzzzzzzzzz")
.AddRange("asia", "a", "azzzzzzzz");
// OR use RANGE partitioning with the actual partitions managed
// externally
x.ByExternallyManagedRangePartitions();
});
To enable partitioning for a specific document type, use this option:
var store = DocumentStore.For(opts =>
{
opts.Connection("some connection string");
opts.Schema.For<User>().MultiTenantedWithPartitioning(x =>
{
x.ByExternallyManagedListPartitions();
});
});
And lastly, if you need to use a mix of tenanted and global document types, but still want to use a consistent partitioning scheme for the document types that are tenanted, you have this option:
var store = DocumentStore.For(opts =>
{
opts.Connection("some connection string");
// This document type is global, so no tenancy
opts.Schema.For<Region>().SingleTenanted();
// We want these document types to be tenanted
opts.Schema.For<Invoice>().MultiTenanted();
opts.Schema.For<User>().MultiTenanted();
// Apply table partitioning by tenant id to each document type
// that is using conjoined multi-tenancy
opts.Policies.PartitionMultiTenantedDocuments(x =>
{
x.ByExternallyManagedListPartitions();
});
});
Tenancy Through Policies
Tenancy can be configured through Document Policies, accessible via StoreOptions.Policies
. The following sample demonstrates setting the default tenancy to TenancyStyle.Conjoined
for all documents.
storeOptions.Policies.AllDocumentsAreMultiTenanted();
// Shorthand for
// storeOptions.Policies.ForAllDocuments(_ => _.TenancyStyle = TenancyStyle.Conjoined);
Tenancy At Document Level & Policy Overrides
Tenancy can be configured at a document level through document mappings. This also enables overriding store-level configurations applied through Document Policies. The following sample demonstrates setting, through StoreOptions
the tenancy for Target
to TenancyStyle.Conjoined
, making it deviate from the configured default policy of TenancyStyle.Single
.
storeOptions.Policies.ForAllDocuments(x => x.TenancyStyle = TenancyStyle.Single);
storeOptions.Schema.For<Target>().MultiTenanted();
You can also do it the other way round, having the default set to TenancyStyle.Conjoined
and overriding it to TenancyStyle.Single
for Target
.
storeOptions.Policies.ForAllDocuments(x => x.TenancyStyle = TenancyStyle.Conjoined);
storeOptions.Schema.For<Target>().SingleTenanted();
Marten-Managed Table Partitioning by Tenant 7.28
Man, that's a mouthful! So here's the situation. You have a large number of tenants, use the "conjoined" tenancy model, and also want to use the table partitioning support as a way to improve performance in large databases. Marten has an option where you can store the valid tenant ids and what named partition that tenant id should be stored in to a database table (also Marten managed, because that's how we roll!). By using this Marten controlled storage, Marten is able to dynamically create the right table partitions for each known tenant id for each known, "conjoined"/multi-tenanted document storage.
Here's a sample of using this feature. First, the configuration is:
var builder = Host.CreateApplicationBuilder();
builder.Services.AddMarten(opts =>
{
opts.Connection(builder.Configuration.GetConnectionString("marten"));
// Make all document types use "conjoined" multi-tenancy -- unless explicitly marked with
// [SingleTenanted] or explicitly configured via the fluent interfce
// to be single-tenanted
opts.Policies.AllDocumentsAreMultiTenanted();
// It's required to explicitly tell Marten which database schema to put
// the mt_tenant_partitions table
opts.Policies.PartitionMultiTenantedDocumentsUsingMartenManagement("tenants");
});
The tenant to partition name mapping will be stored in a table created by Marten called mt_tenant_partitions
with two columns:
partition_name
-- really the partition table suffix name. If you want a one to one relationship, this is the tenant idpartition_value
-- the value of the tenant id
Before the application is initialized, it's possible to load or delete data directly into these tables. At runtime, you can add new tenant id partitions with this helper API on IDocumentStore.Advanced
:
await theStore
.Advanced
// This is ensuring that there are tenant id partitions for all multi-tenanted documents
// with the named tenant ids
.AddMartenManagedTenantsAsync(CancellationToken.None, "a1", "a2", "a3");
The API above will try to add any missing table partitions to all known document types. There is also a separate overload that will take a Dictionary<string, string>
argument that maps tenant ids to a named partition suffix. This might be valuable if you frequently query for multiple tenants at one time. We think that the 1 to 1 tenant id to partition model is a good default approach though.
TIP
Just like with the codegen-ahead model, you may want to tell Marten about all possible document types upfront so that it is better able to add the partitions for each tenant id as needed.
To exempt document types from having partitioned tables, such as for tables you expect to be so small that there's no value and maybe even harm by partitioning, you can use either an attribute on the document type:
[DoNotPartition]
public class DocThatShouldBeExempted1
{
public Guid Id { get; set; }
}
or exempt a single document type through the fluent interface:
opts.Schema.For<DocThatShouldBeExempted2>().DoNotPartition();
Implementation Details
At the moment, Marten implements two modes of tenancy, namely single tenancy and conjoined multi-tenancy.
Conjoined Tenancy
The conjoined (TenancyStyle.Conjoined
) multi-tenancy in Marten is implemented by associating each record with a tenant identifier. As such, Marten does not guarantee or enforce data isolation via database access privileges.
Effects On Schema
Once enabled, TenancyStyle.Conjoined
introduces a tenant_id
column to Marten tables. This column, of type varchar
with the default value of *DEFAULT*
(default tenancy), holds the tenant identifier associated with the record. Furthermore, Marten creates an index on this column by default.
A unique index may optionally be scoped per tenant (see unique indexes).