06 — Multi-Tenancy
On PostgreSQL, Cloacina isolates tenants by schema: each tenant gets its own schema, so there is no cross-tenant access and no tenant filtering in your code. You opt in with a single runner constructor — the workflow code is unchanged.
Prerequisite: a running PostgreSQLSchema-based multi-tenancy is PostgreSQL-only — the earlier tutorials’ SQLite backend has no schemas. This tutorial assumes a reachable Postgres instance and usespostgresql://cloacina:cloacina@localhost:5432/cloacina; adjust the URL to yours. The Admin-API section additionally needs a role that canCREATE SCHEMA/CREATE ROLE. See Database Backends.
with_schema creates (or reuses) a tenant’s schema, runs migrations inside it,
and scopes every read and write to that schema. The same workflow runs against
each tenant in complete isolation.
use cloacina::runner::DefaultRunner;
use cloacina::executor::WorkflowStatus;
use cloacina::{task, workflow, Context, TaskError};
use serde_json::json;
use std::collections::HashMap;
#[workflow(
name = "customer_processing",
description = "Process customer data in isolated tenant environment"
)]
pub mod customer_processing {
use super::*;
#[task]
pub async fn process_customer_data(
context: &mut Context<serde_json::Value>,
) -> Result<(), TaskError> {
let tenant_id = context
.get("tenant_id")
.and_then(|v| v.as_str())
.unwrap_or("default")
.to_string();
context.insert("processed_records", json!(1250))?;
context.insert("processing_completed", json!(true))?;
Ok(())
}
}
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let database_url = "postgresql://cloacina:cloacina@localhost:5432/cloacina";
let mut tenant_runners = HashMap::new();
for tenant_id in ["acme_corp", "globex_inc", "initech"] {
// One schema per tenant — created on first use.
let runner = DefaultRunner::with_schema(database_url, tenant_id).await?;
tenant_runners.insert(tenant_id.to_string(), runner);
}
for (tenant_id, runner) in &tenant_runners {
let mut context = Context::new();
context.insert("tenant_id", json!(tenant_id))?;
let result = runner.execute("customer_processing", context).await?;
if matches!(result.status, WorkflowStatus::Completed) {
let records = result
.final_context
.get("processed_records")
.and_then(|v| v.as_i64())
.unwrap_or(0);
println!("Tenant {} completed: {} records", tenant_id, records);
}
}
for (_tenant_id, runner) in tenant_runners {
runner.shutdown().await?;
}
Ok(())
}
import cloaca
database_url = "postgresql://cloacina:cloacina@localhost:5432/cloacina"
with cloaca.WorkflowBuilder("customer_processing") as builder:
builder.description("Process customer data in isolated tenant environment")
@cloaca.task()
def process_customer_data(context):
tenant_id = context.get("tenant_id")
context.set("processed_records", 1250)
context.set("processing_completed", True)
return context
# One schema per tenant — created on first use.
tenant_runners = {
tid: cloaca.DefaultRunner.with_schema(database_url, tid)
for tid in ["acme_corp", "globex_inc", "initech"]
}
for tenant_id, runner in tenant_runners.items():
context = cloaca.Context({"tenant_id": tenant_id})
result = runner.execute("customer_processing", context)
if result.status == "Completed":
records = result.final_context.get("processed_records")
print(f"Tenant {tenant_id} completed: {records} records")
for runner in tenant_runners.values():
runner.shutdown()
Each tenant’s tables live under its own schema (acme_corp.task_executions,
globex_inc.task_executions, …), so no query can cross tenants. This needs
PostgreSQL — see Database backends.
For stronger isolation, the Database Admin API provisions a tenant with its own PostgreSQL user (auto-generated password) that can only see its own schema. Connect a normal runner with the returned connection string.
use cloacina::database::{Database, DatabaseAdmin, TenantConfig};
use cloacina::runner::DefaultRunner;
let admin_db = Database::new(admin_database_url, "cloacina", 10);
let admin = DatabaseAdmin::new(admin_db);
let tenant_config = TenantConfig {
schema_name: "tenant_demo".to_string(),
username: "demo_user".to_string(),
password: String::new(), // Auto-generate secure password
};
let credentials = admin.create_tenant(tenant_config).await?;
// Runner scoped to the tenant's dedicated credentials.
let tenant_runner = DefaultRunner::new(&credentials.connection_string).await?;
import cloaca
admin = cloaca.DatabaseAdmin("postgresql://admin:admin_password@localhost:5432/myapp")
tenant_config = cloaca.TenantConfig(
schema_name="tenant_acme_corp",
username="acme_corp_user",
password="" # Auto-generate secure password
)
credentials = admin.create_tenant(tenant_config)
# Runner scoped to the tenant's dedicated credentials.
tenant_runner = cloaca.DefaultRunner(credentials.connection_string)
The dedicated user gives database-level access control and a per-tenant audit trail. For the full design rationale, see Multi-tenancy.
A single with_schema call gives each tenant an isolated PostgreSQL schema, and
the Admin API adds dedicated credentials on top. Next, react to external events.
- Previous: 05 — Cron scheduling
- Next: 07 — Event triggers
- Deep dive: Multi-tenancy