Multi-Tenant Quotas Tutorial
Multi-Tenant Quotas Tutorial
Available since: v3.2.0
Build: default — no feature flag required
Modules: heliosdb_lite::tenant (TenantManager, ResourceLimits, QuotaTracking), heliosdb_lite::quota::UserQuotaManager, heliosdb_lite::multi_tenant::quotas
UVP
When ten tenants share one Lite process, one runaway query shouldn’t crater the others. The tenant layer enforces three quotas in real time — connection count, storage bytes, and queries-per-second — with high-water marks, sliding windows, and per-tier defaults (free, starter, pro, enterprise, unlimited). Pair it with RLS_POLICY_ADVANCED for the data-isolation half. The result is the SaaS multi-tenant story collapsed into one binary: per-tenant guard rails, per-tenant CDC streams, per-tenant branches, all behind the same TenantManager.
Prerequisites
- HeliosDB Lite v3.2+
- Rust 1.75+
- Familiarity with RLS_POLICY_ADVANCED for the isolation pairing
- 15 minutes
1. The Quota Surface
pub struct ResourceLimits { pub max_storage_bytes: u64, pub max_connections: usize, pub max_qps: usize, // queries per second}Defaults are deliberately conservative: 100 GB storage, 50 connections, 1000 QPS. Each tenant can override via plan assignment.
pub struct QuotaTracking { // Connections. pub active_connections: usize, pub connections_hwm: usize, // high-water mark pub connections_total_samples: u64, pub connections_sample_count: u64,
// Storage. pub storage_bytes_used: u64,
// QPS (current sliding window). pub queries_this_window: usize, // ... more fields tracked in src/tenant/mod.rs}2. Plans and Tiers
Plan ships with five built-ins (free, starter, pro, enterprise, unlimited) plus your own custom tiers. Every plan carries a ResourceLimits and a PlanFeatures flag set:
pub struct PlanFeatures { pub rls_enabled: bool, pub cdc_enabled: bool, pub migrations_enabled: bool, pub custom_quotas_enabled: bool, pub all_isolation_modes: bool, // vs SharedSchema only}free has CDC off, migrations off, and SharedSchema-only isolation. starter adds CDC. pro and above unlock everything.
3. Registering a Tenant
use heliosdb_lite::tenant::{TenantManager, IsolationMode};
let manager = TenantManager::new();
// Default plan = "free".let acme = manager.register_tenant( "acme".to_string(), IsolationMode::SharedSchema,);
// Or pin to a specific plan.let beta = manager.register_tenant_with_plan( "beta".to_string(), IsolationMode::SharedSchema, "starter",);
println!("{} -> plan {}", acme.name, acme.plan_id);println!("limit: {} connections", acme.limits.max_connections);Quota tracking is initialised on registration. The tenant’s id is a Uuid you’ll want to stash in your application’s session record.
4. Per-Request Context
Set the tenant context once when a request lands; every quota check and RLS evaluation inside that scope sees it:
use heliosdb_lite::tenant::TenantContext;
let ctx = TenantContext { tenant_id: acme.id, user_id: "alice@acme.com".to_string(), roles: vec!["analyst".to_string()], isolation_mode: IsolationMode::SharedSchema,};manager.set_current_context(ctx);
// ... handle the request ...
// (Implicit cleanup on next set_current_context, or do it explicitly:)// manager.set_current_context(default_ctx_for_anonymous());The context is request-scoped on the manager. Do not share a single manager across unrelated request streams without explicit context handoff.
5. Checking Quotas
TenantManager::check_quota returns a boolean for a named resource:
if manager.check_quota(acme.id, "connections") { // Acquire a connection slot for this tenant.} else { return Err(QuotaExceeded);}For finer-grained workloads — per-user RPM rather than per-tenant QPS — use quota::UserQuotaManager:
use heliosdb_lite::quota::UserQuotaManager;
let user_quotas = UserQuotaManager::new(60); // default 60 RPM
user_quotas.set_quota("alice@acme.com", 1000); // override for power user
match user_quotas.check_quota("alice@acme.com") { Ok(()) => proceed(), Err(e) => deny(e),}The UserQuotaManager uses a sliding 60-second window; it resets automatically once a window elapses.
6. The QPS Rate Limiter
multi_tenant::quotas::RateLimiter is the more general counterpart — any window size, any limit shape:
use std::time::Duration;use heliosdb_lite::multi_tenant::quotas::RateLimiter;
let limiter = RateLimiter::new(Duration::from_secs(60)); // 1-minute windows
let allowed = limiter.check("acme", 10_000); // tenant_id, limitif !allowed { return TooManyRequests;}
let remaining = limiter.remaining("acme", 10_000);The TenantUsage struct in the same module aggregates everything you’ll surface in a billing dashboard:
pub struct TenantUsage { pub storage_bytes: u64, pub table_count: u32, pub total_rows: u64, pub vector_store_count: u32, pub vector_count: u64, pub branch_count: u32, pub queries_in_period: u64, pub active_connections: u32, pub api_requests_in_period: u64, pub egress_bytes: u64, pub ingress_bytes: u64, pub compute_ms: u64, pub last_updated: u64,}7. Pairing With RLS
Quota gates resources; RLS gates data. Run them together for full multi-tenant isolation:
use heliosdb_lite::tenant::{TenantManager, IsolationMode, RLSCommand};
let manager = TenantManager::new();let tenant = manager.register_tenant_with_plan( "acme".to_string(), IsolationMode::SharedSchema, "pro",);
// Quota: handled automatically by check_quota() on the hot path.// Isolation: pin every table to current_tenant() via RLS.manager.create_rls_policy( "documents".to_string(), "tenant_isolation".to_string(), "tenant_id = current_tenant()".to_string(), RLSCommand::All, "tenant_id = current_tenant()".to_string(), Some("tenant_id = current_tenant()".to_string()),);Now a request from alice@acme.com can only touch acme rows AND can only consume acme’s slice of resources.
8. Plan Lifecycle
Plans aren’t immutable. You can change a tenant’s plan, downgrade everyone on a deprecated plan, or delete a plan and force-fallback every tenant:
// Move one tenant up.manager.change_tenant_plan(acme.id, "pro").unwrap();
// Bulk-downgrade.manager.downgrade_tenants("starter", "free").unwrap();
// Delete a plan; all its tenants fall back to the configured default// (typically "unlimited" -- the protected fallback that cannot itself// be deleted).let (deleted, fallback_id, downgraded) = manager.delete_plan_and_downgrade("legacy_pro").unwrap();println!( "deleted {}, fallback {}, {} tenants moved", deleted.id, fallback_id, downgraded.len());The unlimited plan is marked is_default = true and cannot be deleted — it’s the safety net under every downgrade.
9. Inspecting State
// Snapshot per-tenant quotas (for a system view).for (user, rpm, current_count, window_start) in user_quotas.snapshot() { println!("{user}: {current_count}/{rpm}");}
// List every tenant.for t in manager.list_tenants() { println!("{:>10} plan={} qps={}", t.name, t.plan_id, t.limits.max_qps);}Where Next
- RLS_POLICY_ADVANCED — the data-isolation half of multi-tenancy.
- CDC_TUTORIAL — per-tenant change feeds (gated by
PlanFeatures::cdc_enabled). - DATABASE_BRANCHING_TUTORIAL — per-tenant branches gated by
max_branchesquota. - ZKE_TUTORIAL — per-tenant key derivation on shared infrastructure.