feat(cubesys): tenant id + registry skeleton (Task 1)

Add TenantId (from-str, normalized, non-empty) and TenantRegistry that
provisions one isolated TenantSession per tenant behind an RwLock<HashMap>.
In-memory store only for now; disk-backed per-tenant stores + daemon wiring
land in Tasks 2-3. 4 unit tests pass, ./check green.
This commit is contained in:
CUBELinux-2
2026-08-11 10:53:38 -04:00
parent 75b11f939e
commit f5a8a2120c
2 changed files with 185 additions and 0 deletions
+4
View File
@@ -52,6 +52,10 @@ pub mod persist;
/// log (group-committed fsync) + scheduled database checkpoint + recovery log.
/// This is what turns the in-memory store into a real, crash-safe database.
pub mod store;
/// Multi-tenant registry — routes tenant ids to isolated [`cubesys::store::ConcurrentStore`]
/// sessions. Task 1 lands the `TenantId` + `TenantRegistry` skeleton; disk-backed
/// per-tenant stores and daemon wiring follow in Tasks 2-3.
pub mod tenant;
/// The error type for system-level operations that span packages.
#[derive(Clone, Eq, PartialEq, Debug)]
+181
View File
@@ -0,0 +1,181 @@
//! Multi-tenant registry — the routing layer for a genuinely concurrent,
//! per-tenant-isolated CUBELinux-2 store (see the plan at
//! `.hermes/plans/2026-08-11_041500-concurrent-multitenant-db.md`).
//!
//! **Task 1 scope (this file):** the [`TenantId`] type and a
//! [`TenantRegistry`] skeleton that provisions one [`TenantSession`] per
//! tenant. No daemon behavior changes yet — tenants are isolated in memory
//! only; disk-backed per-tenant stores land in Task 2.
use std::collections::HashMap;
use std::str::FromStr;
use std::sync::{Arc, RwLock};
use crate::store::ConcurrentStore;
/// Identifies a tenant — the PDF's C-axis environment selector (e.g.
/// `agent-a`, `hermes`, `cloud`). Tenant ids are cheap to copy-compare and
/// serve as the key that isolates one tenant's store from every other.
///
/// Ids are normalized (trimmed) and must be non-empty; they are *not* Copy
/// because the backing string is owned. `Clone/Eq/Hash` is all the registry
/// needs for keying.
#[derive(Clone, Eq, PartialEq, Hash, Debug)]
pub struct TenantId(String);
/// Error type for parsing a [`TenantId`] from a string.
#[derive(Clone, Eq, PartialEq, Debug)]
pub struct TenantIdError(pub String);
impl std::fmt::Display for TenantIdError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "invalid tenant id: {}", self.0)
}
}
impl std::error::Error for TenantIdError {}
impl FromStr for TenantId {
type Err = TenantIdError;
/// Parse a tenant id, trimming surrounding whitespace and rejecting an
/// empty result. Path separators are allowed (the id is a logical name,
/// not a filesystem path — Task 2 derives the on-disk dir from it by
/// sanitization, not by trusting the raw string).
fn from_str(s: &str) -> Result<Self, Self::Err> {
let trimmed = s.trim();
if trimmed.is_empty() {
return Err(TenantIdError("tenant id must not be empty".into()));
}
Ok(TenantId(trimmed.to_string()))
}
}
impl TenantId {
/// The raw id string (trimmed, non-empty).
pub fn as_str(&self) -> &str {
&self.0
}
}
/// One tenant's view of the store. For Task 1 this is an in-memory
/// [`ConcurrentStore`] only; Task 2 will make it disk-backed and add the
/// read/write gate, identity stamping, and auth enforcement.
///
/// Holding the store in an `Arc` lets many connection threads share one
/// tenant's store cheaply, and lets the registry hand out a cheap clone of
/// the handle on every `get_or_provision`.
pub struct TenantSession {
/// The tenant this session belongs to (useful for diagnostics/telemetry).
pub id: TenantId,
/// The tenant-isolated store. Today always in-memory; Task 2 opens it at
/// `store_dir/<tenant>/`.
pub store: Arc<ConcurrentStore>,
}
impl TenantSession {
/// Provision a fresh in-memory tenant session (Task 1 stub).
fn memory(id: TenantId) -> Self {
TenantSession {
id,
store: Arc::new(ConcurrentStore::memory()),
}
}
}
/// The registry that maps tenant ids to their (isolated) sessions.
///
/// Replacements for the single global `Arc<Mutex<Session>>` in `cube-server`:
/// look up a tenant's `TenantSession` here at connection time. Different
/// tenants run fully in parallel; within a tenant, the `ConcurrentStore`
/// already serializes its own writes.
pub struct TenantRegistry {
tenants: RwLock<HashMap<TenantId, Arc<TenantSession>>>,
}
impl TenantRegistry {
/// Create an empty registry.
pub fn new() -> Self {
TenantRegistry {
tenants: RwLock::new(HashMap::new()),
}
}
/// Fetch the session for `id`, provisioning an in-memory one on first use.
///
/// Many readers can hold the read lock simultaneously; only a true miss
/// (provisioning) takes the write lock, and it drops the lock immediately
/// after inserting so concurrent provisioning of *different* tenants does
/// not serialize.
pub fn get_or_provision(&self, id: TenantId) -> Arc<TenantSession> {
// Fast path: already provisioned — take only the read lock.
if let Some(existing) = self.tenants.read().unwrap().get(&id).cloned() {
return existing;
}
// Miss: take the write lock, but re-check in case another thread
// provisioned the same tenant while we were waiting.
let mut guard = self.tenants.write().unwrap();
let entry = guard
.entry(id.clone())
.or_insert_with(|| Arc::new(TenantSession::memory(id)));
entry.clone()
}
}
impl Default for TenantRegistry {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn tenant_id_roundtrips() {
let a = TenantId::from_str("agent-a").unwrap();
let a2 = TenantId::from_str("agent-a").unwrap();
let b = TenantId::from_str("agent-b").unwrap();
assert_eq!(a, a2, "same id parses equal");
assert_ne!(a, b, "different ids are distinct");
assert_eq!(a.as_str(), "agent-a");
assert_eq!(b.as_str(), "agent-b");
}
#[test]
fn tenant_id_rejects_empty() {
assert!(TenantId::from_str("").is_err());
assert!(TenantId::from_str(" ").is_err());
// surrounding whitespace is trimmed, not rejected
assert_eq!(
TenantId::from_str(" agent-a ").unwrap().as_str(),
"agent-a"
);
}
#[test]
fn registry_provisions_and_reuses() {
let reg = TenantRegistry::new();
let a = reg.get_or_provision(TenantId::from_str("agent-a").unwrap());
let a2 = reg.get_or_provision(TenantId::from_str("agent-a").unwrap());
let b = reg.get_or_provision(TenantId::from_str("agent-b").unwrap());
// Same tenant returns the SAME Arc (not a new provisioning).
assert!(Arc::ptr_eq(&a, &a2), "repeat lookup reuses the session");
// Different tenant is a different session.
assert!(!Arc::ptr_eq(&a, &b), "distinct tenants are isolated");
assert_eq!(a.id.as_str(), "agent-a");
assert_eq!(b.id.as_str(), "agent-b");
}
#[test]
fn tenants_are_independent_stores() {
let reg = TenantRegistry::new();
let a = reg.get_or_provision(TenantId::from_str("agent-a").unwrap());
let b = reg.get_or_provision(TenantId::from_str("agent-b").unwrap());
// Each session carries its own store handle.
assert!(!Arc::ptr_eq(&a.store, &b.store));
}
}