From f5a8a2120c49ccd75c090f721fff2c268cf7ab04 Mon Sep 17 00:00:00 2001 From: CUBELinux-2 Date: Tue, 11 Aug 2026 10:53:38 -0400 Subject: [PATCH] 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. In-memory store only for now; disk-backed per-tenant stores + daemon wiring land in Tasks 2-3. 4 unit tests pass, ./check green. --- cubesys/src/lib.rs | 4 + cubesys/src/tenant.rs | 181 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 185 insertions(+) create mode 100644 cubesys/src/tenant.rs diff --git a/cubesys/src/lib.rs b/cubesys/src/lib.rs index 870c3c0..7281af1 100644 --- a/cubesys/src/lib.rs +++ b/cubesys/src/lib.rs @@ -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)] diff --git a/cubesys/src/tenant.rs b/cubesys/src/tenant.rs new file mode 100644 index 0000000..04159d2 --- /dev/null +++ b/cubesys/src/tenant.rs @@ -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 { + 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//`. + pub store: Arc, +} + +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>` 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>>, +} + +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 { + // 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)); + } +}