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.
|
/// log (group-committed fsync) + scheduled database checkpoint + recovery log.
|
||||||
/// This is what turns the in-memory store into a real, crash-safe database.
|
/// This is what turns the in-memory store into a real, crash-safe database.
|
||||||
pub mod store;
|
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.
|
/// The error type for system-level operations that span packages.
|
||||||
#[derive(Clone, Eq, PartialEq, Debug)]
|
#[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