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] });
session.SaveChanges();
}
// 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] });
session.SaveChanges();
}
// 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" });
session.SaveChanges();
}
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();
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).