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:
@@ -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)]
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user