cache.tenant_manager
On this page
Overview
The multi-tenant isolation layer. Per the module docstring, its responsibilities are: register/deregister tenants, enforce memory/entry-count/RPM quotas, provide per-tenant namespace key prefixes, support optional shared-cache reads with security filtering, and expose hooks for tenant-level usage events (billing/monitoring).
TenantRegistration
@dataclass
class TenantRegistration:
tenant_id: str
display_name: str = ""
memory_quota_mb: float = 512.0
max_entries: int = 10_000
request_quota_rpm: int = 1_000
allow_shared_read: bool = False
allow_shared_write: bool = False
custom_ttl_s: Optional[float] = None
created_at: float = field(default_factory=time.time)
metadata: Dict[str, str] = field(default_factory=dict)
The input to TenantManager.register(). Note that
custom_ttl_s and metadata are accepted and stored on the registration record but, in the
analyzed source, are not read anywhere else — CacheManager.put()'s TTL comes from its
own ttl_s parameter / CacheConfig.default_ttl_s, not from a tenant's custom_ttl_s. See
Known Issues.
QuotaExceeded
class QuotaExceeded(Exception):
def __init__(self, tenant_id: str, reason: str) -> None
Raised by every quota-check method below. Message format:
"[QuotaExceeded][tenant={tenant_id}] {reason}". Exposes .tenant_id and .reason.
TenantManager
class TenantManager:
def __init__(self, config: TenantConfig) -> None
Construction
Initializes an empty registration dict, an empty quota-state dict, a None quota-event
hook, and a threading.RLock. Then immediately registers a "default" tenant using
config's default quota values — every TenantManager therefore always has at least one
usable tenant out of the box, supporting single-tenant deployments without any explicit
registration call.
register(self, reg: TenantRegistration) -> None
Stores the registration and creates a fresh
TenantQuotaState for it (re-registering an existing
tenant_id logs a warning and replaces both). Logs an INFO line with the tenant's quota
summary.
deregister(self, tenant_id: str) -> None
Removes the registration and quota state. Raises ValueError if tenant_id == "default" — the default tenant can never be removed.
is_registered(self, tenant_id: str) -> bool
Simple membership check against the registrations dict.
get_registration(self, tenant_id: str) -> TenantRegistration
Raises KeyError ("Tenant not registered: {tenant_id}") if the tenant is unknown —
this is the one lookup method that does not auto-register (contrast with _get_state
below).
Quota enforcement
check_and_charge_request(self, tenant_id: str) -> None
Calls state.charge_request() (see
TenantQuotaState); if it returns False (RPM quota
exhausted), invokes the quota-event hook (if set, with (tenant_id, "rpm_exceeded")) and
raises QuotaExceeded.
check_memory_quota(self, tenant_id: str) -> None
Raises QuotaExceeded if not state.memory_ok.
check_entry_quota(self, tenant_id: str) -> None
Raises QuotaExceeded if not state.entries_ok.
charge_memory(self, tenant_id: str, size_bytes: int) -> None / release_memory(...)
Add/subtract size_bytes / (1024*1024) MB from state.memory_used_mb, clamped at 0.0 on
release (never goes negative).
increment_entries(self, tenant_id: str) -> None / decrement_entries(...)
Adjust state.entry_count by ±1, clamped at 0 on decrement.
Namespace / key management
namespace_key(self, tenant_id: str, key: str) -> str
Returns f"{tenant_id}::{key}".
extract_tenant(self, namespaced_key: str) -> str
Inverse of the above: splits on the first "::" and returns the prefix, or "default" if
no "::" is present.
[!NOTE] Not used by
CacheManager's own key schemeCacheManagerbuilds its exact-match keys viaQueryNormalizer.exact_cache_key()(a SHA-256 hash that already incorporatestenant_id), not vianamespace_key(). These two namespacing utilities exist side by side but are not both exercised on the defaultCacheManagercode path —namespace_key/extract_tenantare available as public utilities for callers building their own key schemes.
Cross-tenant shared cache
can_read_shared(self, tenant_id: str) -> bool / can_write_shared(self, tenant_id: str) -> bool
Look up the tenant's registration and return its allow_shared_read /
allow_shared_write flag; return False for an unregistered tenant (note: unlike most
other lookups in this class, these two do not auto-register — they simply return
False via reg.allow_shared_read if reg else False).
is_accessible(self, requesting_tenant: str, entry: IntelligentCacheEntry) -> bool
Purpose: the actual, enforced tenant-isolation check used by
CacheManager.get().
Logic:
- Owner (
requesting_tenant == entry.tenant_id) always has access. - Otherwise, allowed if
entry.is_shared and self.can_read_shared(requesting_tenant). - Otherwise, denied.
This mirrors SecurityGuard.enforce_tenant_access exactly in
logic (owner-or-shared), but returns a bool instead of raising — and, per
Known Issues, it is TenantManager.is_accessible (not
SecurityGuard.enforce_tenant_access) that CacheManager actually calls.
Monitoring / stats
set_quota_event_hook(self, hook: Callable[[str, str], None]) -> None
Registers a callback invoked as hook(tenant_id, "rpm_exceeded") whenever an RPM quota is
exceeded via check_and_charge_request. This is the only quota-event type wired to the
hook in the analyzed source — memory- and entry-count quota breaches do not invoke it.
get_tenant_stats(self, tenant_id: str) -> Dict[str, object]
Returns a dict of tenant_id, memory_used_mb, memory_quota_mb, memory_pct,
entry_count, max_entries, requests_this_min, rpm_quota for one tenant (calling
reset_rpm_if_needed() first so the RPM figure is current).
get_all_tenant_stats(self) -> List[Dict[str, object]]
Calls get_tenant_stats for every registered tenant.
list_tenant_ids(self) -> List[str]
Returns all registered tenant IDs.
_get_state(self, tenant_id: str) -> TenantQuotaState (private)
The auto-registration path: if a tenant has no quota state, logs a warning and calls
register() with a fresh TenantRegistration built from TenantConfig's default
values (default_memory_quota_mb, default_max_entries, default_request_quota_rpm) — not
allow_shared_read/allow_shared_write, which are left at their dataclass defaults
(False). This is the mechanism by which CacheManager.get()/put() transparently support
brand-new tenant IDs without an explicit register_tenant() call first.
Examples
from zerda.cache import TenantManager, TenantRegistration, QuotaExceeded
from zerda.cache import TenantConfig
mgr = TenantManager(TenantConfig())
mgr.register(TenantRegistration(
tenant_id="acme",
memory_quota_mb=100,
max_entries=10,
request_quota_rpm=5,
))
for _ in range(5):
mgr.check_and_charge_request("acme") # OK
try:
mgr.check_and_charge_request("acme") # 6th request this minute
except QuotaExceeded as e:
print(e.reason) # "RPM quota 5 exceeded"
print(mgr.get_tenant_stats("acme"))
print(mgr.namespace_key("acme", "user:42")) # "acme::user:42"
Thread Safety
All mutating methods acquire self._lock (threading.RLock). Read-only helpers like
is_registered, can_read_shared, can_write_shared, and is_accessible do not
acquire the lock — under concurrent registration/deregistration this is a narrow window for
a stale read, though dict.get() itself is atomic in CPython.
Related APIs
TenantConfig— default quota valuesTenantQuotaState— the mutable per-tenant state this class ownsCacheManager— the primary consumer (get/put/register_tenant/flush_tenant)SecurityGuard— the parallel, security-layer isolation check