cubesys: durable WAL + ConcurrentStore (NDJSON, group-commit fsync, recovery log)
- Add persist.rs: std-only NDJSON snapshot of the HashBackend store (no serde) for the durable checkpoint + load_into_store replay. - Add store.rs: ConcurrentStore = Mutex<HashBackend> live store + WAL (newline-delimited JSON, group-commit fsync, idempotent seq-numbered replay) + durable JSON checkpoint + bg flusher + startup replay. - Recovery events (checkpoint failure, WAL fsync failure, WAL replay) are written to a recovery.ndjson you asked to keep as the written backup log, so any fall-back to JSON is recorded 'in writing'. - Refactor cube-server to thread-per-connection over ConcurrentStore. - query_doc_type / scan_prefix / linked_to / delete_raw added. Verified: ./check (fmt, 7 unit tests, clippy -D warnings) all green; ./check stress drove 22,080 prog+run pairs (~368/s) over 60s, daemon survived, latency prog~9us/run~13us mean.
This commit is contained in:
+71
-59
@@ -1,19 +1,22 @@
|
||||
//! Shared CUBELinux-2 system command interpreter.
|
||||
//!
|
||||
//! This module holds the *single* implementation of the cube command language
|
||||
//! (`prog`, `write`, `run`, `ls`, `stat`, `seal`, `open`). It is used by every
|
||||
//! front-end — the local `cube` REPL, the `cubec` socket client, and the
|
||||
//! `cube-server` daemon — so the behaviour can never drift between them.
|
||||
//! (`prog`, `write`, `run`, `ls`, `stat`, `seal`, `open`, `query`). It is used
|
||||
//! by every front-end — the local `cube` REPL, the `cubec` socket client, and
|
||||
//! the `cube-server` daemon — so the behaviour can never drift between them.
|
||||
//!
|
||||
//! A [`Session`] wraps one [`CubeStore`] backend (currently the in-memory
|
||||
//! [`HashBackend`]) and executes one command line at a time against it. The
|
||||
//! daemon holds a single long-lived `Session`; the REPL holds a transient one.
|
||||
//! A [`Session`] wraps one [`ConcurrentStore`] (a mutex-wrapped `CubeStore`
|
||||
//! with a write-ahead log + scheduled checkpoint behind it). Because the store
|
||||
//! is concurrent and durable, the daemon can serve many connections at once and
|
||||
//! survives restarts. Each `exec` takes and returns an `Arc<ConcurrentStore>`
|
||||
//! so the server can hand a cloned handle to each worker thread.
|
||||
|
||||
use crate::store::ConcurrentStore;
|
||||
use cubecode::{CodeCell, Kind, Op, Vm};
|
||||
use cubecoords::CubeHeader;
|
||||
use cubecrypt::{CubeEnv, KeySlot, Selector, TransformId};
|
||||
use cubestore::{CubeStore, HashBackend};
|
||||
use std::collections::BTreeMap;
|
||||
use std::sync::Arc;
|
||||
use std::time::Instant;
|
||||
|
||||
/// Per-command latency accumulator (cumulative; the daemon reports these via
|
||||
@@ -25,10 +28,12 @@ struct CmdStat {
|
||||
max_ns: u128,
|
||||
}
|
||||
|
||||
/// One cube command session: a store plus the command interpreter.
|
||||
/// One cube command session: a concurrent, durable store plus the interpreter.
|
||||
/// The REPL holds a transient one; the daemon shares a single `Arc<Session>`
|
||||
/// across all connection threads.
|
||||
pub struct Session {
|
||||
store: CubeStore<HashBackend>,
|
||||
/// Total commands executed since this session started (telemetry).
|
||||
store: Arc<ConcurrentStore>,
|
||||
/// Total commands executed under this session (telemetry).
|
||||
calls: u64,
|
||||
/// Per top-level command latency histogram (command name -> stats).
|
||||
per_cmd: BTreeMap<String, CmdStat>,
|
||||
@@ -41,36 +46,35 @@ impl Default for Session {
|
||||
}
|
||||
|
||||
impl Session {
|
||||
/// A fresh, empty session over an in-memory store.
|
||||
/// A fresh, empty session over an in-memory (non-durable) store.
|
||||
pub fn new() -> Self {
|
||||
Session {
|
||||
store: CubeStore::new(HashBackend::new()),
|
||||
store: Arc::new(ConcurrentStore::memory()),
|
||||
calls: 0,
|
||||
per_cmd: BTreeMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Access the underlying store (used by persistence to snapshot/restore).
|
||||
pub fn store(&self) -> &CubeStore<HashBackend> {
|
||||
&self.store
|
||||
/// A session over a durable, concurrent store (used by the daemon).
|
||||
pub fn with_store(store: Arc<ConcurrentStore>) -> Self {
|
||||
Session {
|
||||
store,
|
||||
calls: 0,
|
||||
per_cmd: BTreeMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Mutable access to the underlying store (used by persistence to load).
|
||||
pub fn store_mut(&mut self) -> &mut CubeStore<HashBackend> {
|
||||
&mut self.store
|
||||
/// Shared handle to the underlying concurrent store.
|
||||
pub fn store(&self) -> Arc<ConcurrentStore> {
|
||||
self.store.clone()
|
||||
}
|
||||
|
||||
/// Snapshot of the session's telemetry: total commands serviced, per-command
|
||||
/// latency distribution (mean/max in µs), and the occupancy of each `C`
|
||||
/// namespace (number of records whose class axis equals `c`).
|
||||
///
|
||||
/// This is the "substantive" telemetry the daemon exposes — not just a
|
||||
/// health ping. A monitoring pass can sample `stats` repeatedly and derive
|
||||
/// request rates and latency histograms from the cumulative counters.
|
||||
pub fn stats(&self) -> String {
|
||||
let mut lines = Vec::new();
|
||||
lines.push(format!("total commands serviced: {}", self.calls));
|
||||
// per-command latency distribution
|
||||
lines.push("per-command latency (µs, mean / max / count):".into());
|
||||
if self.per_cmd.is_empty() {
|
||||
lines.push(" (no commands timed yet)".into());
|
||||
@@ -88,7 +92,6 @@ impl Session {
|
||||
));
|
||||
}
|
||||
}
|
||||
// per-C namespace occupancy (C axis 0 = Null control space)
|
||||
let keys = self.store.keys();
|
||||
let mut by_c: BTreeMap<u8, usize> = BTreeMap::new();
|
||||
for k in &keys {
|
||||
@@ -113,8 +116,6 @@ impl Session {
|
||||
let t0 = Instant::now();
|
||||
let cmd_name = line.split_whitespace().next().unwrap_or("").to_string();
|
||||
let result = self.exec_inner(line);
|
||||
// record telemetry regardless of ok/err (a failed command is still a
|
||||
// serviced command and worth timing).
|
||||
self.calls += 1;
|
||||
let st = self.per_cmd.entry(cmd_name).or_default();
|
||||
let elapsed = t0.elapsed().as_nanos();
|
||||
@@ -130,12 +131,25 @@ impl Session {
|
||||
fn exec_inner(&mut self, line: &str) -> Result<String, String> {
|
||||
let mut it = line.split_whitespace();
|
||||
let cmd = it.next().ok_or_else(|| "empty line".to_string())?;
|
||||
let store = &self.store;
|
||||
match cmd {
|
||||
"stats" => {
|
||||
// Substantive telemetry: command volume + latency distribution
|
||||
// + per-C-namespace record occupancy. This is what makes the
|
||||
// daemon measurable, not merely "healthy".
|
||||
Ok(self.stats())
|
||||
"stats" => Ok(self.stats()),
|
||||
"query" => {
|
||||
let dt = it
|
||||
.next()
|
||||
.ok_or_else(|| "query needs <doc_type>".to_string())?;
|
||||
let coords = store.query_doc_type(dt);
|
||||
if coords.is_empty() {
|
||||
Ok(format!("query {dt} -> (no matches)"))
|
||||
} else {
|
||||
let names: Vec<String> =
|
||||
coords.iter().map(|c| c.pack_u32().to_string()).collect();
|
||||
Ok(format!(
|
||||
"query {dt} -> {} matches: {}",
|
||||
coords.len(),
|
||||
names.join(" ")
|
||||
))
|
||||
}
|
||||
}
|
||||
"prog" => {
|
||||
let path = it.next().ok_or_else(|| "prog needs <path>".to_string())?;
|
||||
@@ -154,9 +168,9 @@ impl Session {
|
||||
return Err("prog: no ops given".to_string());
|
||||
}
|
||||
let name = path.rsplit('/').next().unwrap_or(path);
|
||||
let coord =
|
||||
crate::store_code_cell(&mut self.store, path, Kind::Fn, name, &[], &ops)
|
||||
.map_err(|e| e.to_string())?;
|
||||
let coord = store
|
||||
.put_code_cell(path, Kind::Fn, name, &[], &ops)
|
||||
.map_err(|e| e.to_string())?;
|
||||
Ok(format!(
|
||||
"wrote program {path} -> coord {} ({} ops)",
|
||||
coord.pack_u32(),
|
||||
@@ -172,15 +186,17 @@ impl Session {
|
||||
let code = cubecode::decode(&bytes)
|
||||
.map_err(|e| format!("bytecode decode error: {e:?}"))?;
|
||||
let name = path.rsplit('/').next().unwrap_or(path);
|
||||
let coord =
|
||||
crate::store_code_cell(&mut self.store, path, Kind::Fn, name, &[], &code)
|
||||
.map_err(|e| e.to_string())?;
|
||||
let coord = store
|
||||
.put_code_cell(path, Kind::Fn, name, &[], &code)
|
||||
.map_err(|e| e.to_string())?;
|
||||
Ok(format!("wrote {path} -> coord {}", coord.pack_u32()))
|
||||
}
|
||||
"run" => {
|
||||
let path = it.next().ok_or_else(|| "run needs <path>".to_string())?;
|
||||
let cell = crate::load_code_cell(&self.store, path).map_err(|e| e.to_string())?;
|
||||
let mut vm = Vm::new(self.store.clone());
|
||||
let _coord = crate::path_to_czyx(path).map_err(|e| e.to_string())?;
|
||||
let sn = store.read_snapshot();
|
||||
let cell = crate::load_code_cell(&sn, path).map_err(|e| e.to_string())?;
|
||||
let mut vm = Vm::new(sn);
|
||||
let res = vm.run(cell.label);
|
||||
let mut out = format!("run {path} => {res:?}");
|
||||
if !vm.output().is_empty() {
|
||||
@@ -193,7 +209,7 @@ impl Session {
|
||||
}
|
||||
"ls" => {
|
||||
let dir = it.next().ok_or_else(|| "ls needs <dir>".to_string())?;
|
||||
let fs = cubefs::CubeFs::new(self.store.clone());
|
||||
let fs = cubefs::CubeFs::new(store.read_snapshot());
|
||||
let entries = fs.readdir(dir).map_err(|e| format!("ls {dir}: {e:?}"))?;
|
||||
if entries.is_empty() {
|
||||
Ok(format!("ls {dir} -> (empty)"))
|
||||
@@ -204,7 +220,7 @@ impl Session {
|
||||
}
|
||||
"stat" => {
|
||||
let path = it.next().ok_or_else(|| "stat needs <path>".to_string())?;
|
||||
let fs = cubefs::CubeFs::new(self.store.clone());
|
||||
let fs = cubefs::CubeFs::new(store.read_snapshot());
|
||||
let a = fs
|
||||
.getattr(path)
|
||||
.map_err(|e| format!("stat {path}: {e:?}"))?;
|
||||
@@ -227,13 +243,8 @@ impl Session {
|
||||
let transform = parse_transform(tf)
|
||||
.ok_or_else(|| "unknown transform (none|gcm|chacha|xts)".to_string())?;
|
||||
|
||||
// Ensure key material exists at the Null-cube key cell.
|
||||
if self.store.get_record(&kc).is_none() {
|
||||
self.store.put_record(
|
||||
kc,
|
||||
&CubeHeader::new(),
|
||||
b"demo-key-material-32-bytes-long!!",
|
||||
);
|
||||
if store.get_record(&kc).is_none() {
|
||||
store.put_raw(kc, b"demo-key-material-32-bytes-long!!".to_vec());
|
||||
}
|
||||
let env = CubeEnv::new(
|
||||
vec![KeySlot {
|
||||
@@ -245,28 +256,29 @@ impl Session {
|
||||
);
|
||||
|
||||
if cmd == "seal" {
|
||||
let (h, body) = self
|
||||
.store
|
||||
let (h, body) = store
|
||||
.get_record(&coord)
|
||||
.ok_or_else(|| format!("seal: no record at {path}"))?;
|
||||
env.put_encrypted(&mut self.store, coord, Selector::Slot(0), &body, h)
|
||||
store
|
||||
.with_mut(|s| env.put_encrypted(s, coord, Selector::Slot(0), &body, h))
|
||||
.map_err(|e| format!("seal: {e:?}"))?;
|
||||
// Log the re-written (encrypted) record to the WAL.
|
||||
if let Some(v) = store.get_raw(&coord) {
|
||||
store.log_put(coord, v);
|
||||
}
|
||||
Ok(format!("sealed {path} under key {} ({tf})", kc.pack_u32()))
|
||||
} else {
|
||||
let (_, envelope) = self
|
||||
.store
|
||||
let (_, envelope) = store
|
||||
.get_record(&coord)
|
||||
.ok_or_else(|| format!("open: no record at {path}"))?;
|
||||
let pt = env
|
||||
.open(&self.store, Selector::Slot(0), &envelope)
|
||||
.open(&store.read_snapshot(), Selector::Slot(0), &envelope)
|
||||
.map_err(|e| format!("open: {e:?}"))?;
|
||||
let cell = CodeCell::from_record(coord, &CubeHeader::new(), &pt)
|
||||
.ok_or_else(|| "open: decrypted body is not valid bytecode".to_string())?;
|
||||
// The VM runs code located by coordinate, so to execute a
|
||||
// sealed record we decrypt it back into a plaintext record,
|
||||
// then run.
|
||||
self.store.put_record(coord, &CubeHeader::new(), &pt);
|
||||
let mut vm = Vm::new(self.store.clone());
|
||||
store.put_raw(coord, pt);
|
||||
let sn = store.read_snapshot();
|
||||
let mut vm = Vm::new(sn);
|
||||
let res = vm.run(cell.label);
|
||||
Ok(format!(
|
||||
"open+run {path} (key {}) => {res:?}",
|
||||
|
||||
Reference in New Issue
Block a user