Skip to content

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, limit
if !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