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

Python · 12 lines
@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

Python · 2 lines
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

Python · 2 lines
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 scheme CacheManager builds its exact-match keys via QueryNormalizer.exact_cache_key() (a SHA-256 hash that already incorporates tenant_id), not via namespace_key(). These two namespacing utilities exist side by side but are not both exercised on the default CacheManager code path — namespace_key/extract_tenant are 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:

  1. Owner (requesting_tenant == entry.tenant_id) always has access.
  2. Otherwise, allowed if entry.is_shared and self.can_read_shared(requesting_tenant).
  3. 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

Python · 22 lines
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 pages