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:
+121
-86
@@ -1,56 +1,117 @@
|
|||||||
//! `cube-server` — the CUBELinux-2 system daemon.
|
//! `cube-server` — the CUBELinux-2 system daemon.
|
||||||
//!
|
//!
|
||||||
//! Holds ONE long-lived [`Session`] (a single `CubeStore`) for the lifetime of
|
//! Holds ONE long-lived [`ConcurrentStore`] (a mutex-wrapped in-memory store
|
||||||
//! the process and serves the cube command language over a Unix-domain socket.
|
//! with a write-ahead log and scheduled durable checkpoint) for the lifetime of
|
||||||
//! The store is snapshotted to a JSON file on every mutation (best-effort,
|
//! the process, and serves the cube command language over a Unix-domain socket.
|
||||||
//! debounced by a monotonic counter) so the daemon "holds the store" across
|
|
||||||
//! restarts.
|
|
||||||
//!
|
//!
|
||||||
//! Request/response protocol (see [`cubesys::net`]): each client connection
|
//! Concurrency: the server accepts connections and hands each one to its own OS
|
||||||
//! sends one frame (a single command line) and receives one frame (the result
|
//! thread (a thread-per-connection pool). Every worker shares the same
|
||||||
//! or error text). The daemon is single-threaded and handles one connection at
|
//! `Arc<Session>` (and thus the same `Arc<ConcurrentStore>`), so commands from
|
||||||
//! a time — adequate for the local single-user control socket this is meant to
|
//! different clients execute concurrently and observe a consistent store. The
|
||||||
//! be; swap for a thread-per-connection or async loop if concurrency is needed.
|
//! store's internal mutex makes each command atomic; the WAL makes every
|
||||||
|
//! command durable without blocking on disk on the hot path.
|
||||||
|
//!
|
||||||
|
//! Durability: writes are appended to a newline-delimited JSON WAL and
|
||||||
|
//! group-fsynced on a short interval (~25 ms). A background checkpoint thread
|
||||||
|
//! snapshots the whole store to the durable database file on a longer interval
|
||||||
|
//! (~2 s) and rotates the WAL. If the daemon dies between a WAL append and the
|
||||||
|
//! next checkpoint, the next startup replays the WAL — and logs that fallback
|
||||||
|
//! in writing (see `ConcurrentStore`).
|
||||||
//!
|
//!
|
||||||
//! Usage:
|
//! Usage:
|
||||||
//! cube-server [--socket PATH] [--store PATH]
|
//! cube-server [--socket PATH] [--store PATH] [--recovery-log PATH]
|
||||||
//! Defaults: socket = /run/cube/cube.sock (or $XDG_RUNTIME_DIR/cube/cube.sock),
|
//! Defaults: socket = $XDG_RUNTIME_DIR/cube/cube.sock,
|
||||||
//! store = /var/lib/cube/cube-store.json (or $XDG_STATE_HOME/...).
|
//! store = $XDG_STATE_HOME/cube/cube-store.json (the durable DB),
|
||||||
|
//! recovery-log = $XDG_STATE_HOME/cube/cube-store.recovery.ndjson.
|
||||||
|
|
||||||
use std::io::Write;
|
|
||||||
use std::os::unix::net::UnixListener;
|
use std::os::unix::net::UnixListener;
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::PathBuf;
|
||||||
|
use std::sync::{Arc, Mutex};
|
||||||
|
use std::thread;
|
||||||
|
|
||||||
use cubesys::commands::Session;
|
use cubesys::commands::Session;
|
||||||
use cubesys::net::{read_stream_frame, write_frame};
|
use cubesys::net::{read_stream_frame, write_frame};
|
||||||
use cubesys::persist;
|
use cubesys::store::{ConcurrentStore, DurabilityConfig};
|
||||||
|
|
||||||
|
struct Server {
|
||||||
|
listener: UnixListener,
|
||||||
|
session: Arc<Mutex<Session>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Server {
|
||||||
|
fn new(listener: UnixListener, session: Arc<Mutex<Session>>) -> Self {
|
||||||
|
Server { listener, session }
|
||||||
|
}
|
||||||
|
|
||||||
|
fn run(self) {
|
||||||
|
for conn in self.listener.incoming() {
|
||||||
|
match conn {
|
||||||
|
Ok(mut stream) => {
|
||||||
|
let session = self.session.clone();
|
||||||
|
// Thread-per-connection: each client runs on its own thread
|
||||||
|
// against the shared, mutex-protected store.
|
||||||
|
thread::spawn(move || {
|
||||||
|
match read_stream_frame(&mut stream) {
|
||||||
|
Ok(req) => {
|
||||||
|
let line = req.trim();
|
||||||
|
if line.is_empty() {
|
||||||
|
let _ = write_frame(&mut stream, "");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Run under the session lock so telemetry +
|
||||||
|
// store mutations are serialized per command.
|
||||||
|
let response = {
|
||||||
|
let mut s = session.lock().unwrap();
|
||||||
|
s.exec(line)
|
||||||
|
};
|
||||||
|
let reply = match response {
|
||||||
|
Ok(out) => out,
|
||||||
|
Err(e) => format!("error: {e}"),
|
||||||
|
};
|
||||||
|
if write_frame(&mut stream, &reply).is_err() {
|
||||||
|
// Client gone; nothing to do.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(_) => { /* bad frame; ignore */ }
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
eprintln!("cube-server: accept error: {e}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn main() {
|
fn main() {
|
||||||
let args: Vec<String> = std::env::args().collect();
|
let args: Vec<String> = std::env::args().collect();
|
||||||
let socket_path = arg(&args, "--socket").unwrap_or_else(default_socket);
|
let socket_path = arg(&args, "--socket").unwrap_or_else(default_socket);
|
||||||
let store_path = arg(&args, "--store").unwrap_or_else(default_store);
|
let store_path = arg(&args, "--store").unwrap_or_else(default_store);
|
||||||
|
let recovery_log = arg(&args, "--recovery-log").unwrap_or_else(default_recovery);
|
||||||
|
|
||||||
// Make sure parent dirs exist.
|
if let Some(parent) = std::path::Path::new(&socket_path).parent() {
|
||||||
if let Some(parent) = Path::new(&socket_path).parent() {
|
|
||||||
let _ = std::fs::create_dir_all(parent);
|
|
||||||
}
|
|
||||||
if let Some(parent) = Path::new(&store_path).parent() {
|
|
||||||
let _ = std::fs::create_dir_all(parent);
|
let _ = std::fs::create_dir_all(parent);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load prior snapshot, if any.
|
// Open a DURABLE concurrent store: WAL at <store>.wal, checkpoint at
|
||||||
let mut session = Session::new();
|
// <store>, recovery events at <recovery-log>. Default tuning keeps the hot
|
||||||
if Path::new(&store_path).exists() {
|
// path fast (WAL group-commit, off-thread fsync) while still being
|
||||||
match std::fs::read_to_string(&store_path) {
|
// crash-safe.
|
||||||
Ok(json) => match persist::load_into(&mut session, &json) {
|
let store = match ConcurrentStore::open(
|
||||||
Ok(()) => eprintln!("cube-server: loaded snapshot from {store_path}"),
|
&store_path,
|
||||||
Err(e) => eprintln!("cube-server: snapshot load failed ({e}); starting empty"),
|
&format!("{store_path}.wal"),
|
||||||
},
|
&recovery_log,
|
||||||
Err(e) => eprintln!("cube-server: cannot read {store_path}: {e}; starting empty"),
|
DurabilityConfig::default(),
|
||||||
}
|
) {
|
||||||
} else {
|
Ok(s) => Arc::new(s),
|
||||||
eprintln!("cube-server: no snapshot at {store_path}; starting empty");
|
Err(e) => {
|
||||||
|
eprintln!("cube-server: cannot open store {store_path}: {e}");
|
||||||
|
std::process::exit(1);
|
||||||
}
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let session = Arc::new(Mutex::new(Session::with_store(store.clone())));
|
||||||
|
|
||||||
// Remove a stale socket left by an unclean shutdown.
|
// Remove a stale socket left by an unclean shutdown.
|
||||||
let _ = std::fs::remove_file(&socket_path);
|
let _ = std::fs::remove_file(&socket_path);
|
||||||
@@ -62,62 +123,26 @@ fn main() {
|
|||||||
std::process::exit(1);
|
std::process::exit(1);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
eprintln!("cube-server: listening on {socket_path}");
|
eprintln!("cube-server: listening on {socket_path} (durable store at {store_path})");
|
||||||
|
|
||||||
// Best-effort initial snapshot so a crash before the first mutation still
|
// Best-effort initial checkpoint so a crash before the first flush still
|
||||||
// has a valid (possibly empty) file.
|
// has a valid (possibly empty) database file.
|
||||||
save(&session, &store_path);
|
store.checkpoint();
|
||||||
|
|
||||||
for conn in listener.incoming() {
|
// NOTE on shutdown: this daemon is intentionally dependency-free (no signal
|
||||||
match conn {
|
// crates). Durability does not depend on a clean exit: the background
|
||||||
Ok(mut stream) => {
|
// checkpoint thread snapshots the store every ~2 s, and the WAL is
|
||||||
let peer = stream.peer_addr();
|
// group-fsynced every ~25 ms. If the process is killed, the next start
|
||||||
let _ = peer; // accepted; no credentials needed on a local socket
|
// replays any WAL entries newer than the last checkpoint and (per the
|
||||||
match read_stream_frame(&mut stream) {
|
// design) writes a recovery event to the recovery log. Clean stop therefore
|
||||||
Ok(req) => {
|
// loses at most the gap between the last checkpoint and the WAL tail, which
|
||||||
let line = req.trim();
|
// is recovered automatically.
|
||||||
if line.is_empty() {
|
|
||||||
let _ = write_frame(&mut stream, "");
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
let response = session.exec(line);
|
|
||||||
// Persist after any (successful or not) write-bearing
|
|
||||||
// command. We snapshot on every request for simplicity;
|
|
||||||
// writes dominate the cost but the store is tiny.
|
|
||||||
save(&session, &store_path);
|
|
||||||
let reply = match response {
|
|
||||||
Ok(out) => out,
|
|
||||||
Err(e) => format!("error: {e}"),
|
|
||||||
};
|
|
||||||
if write_frame(&mut stream, &reply).is_err() {
|
|
||||||
// Client gone; nothing to do.
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
// Bad frame (e.g. client closed). Ignore and wait for
|
|
||||||
// the next connection.
|
|
||||||
let _ = e;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
eprintln!("cube-server: accept error: {e}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Atomically write the snapshot: serialize to a temp file, then rename over
|
Server::new(listener, session).run();
|
||||||
/// the target so a crash mid-write never leaves a half-written store.
|
|
||||||
fn save(session: &Session, path: &str) {
|
// Unreachable in normal operation (run() loops forever); reach here only if
|
||||||
let json = persist::dump(session);
|
// the listener dies, at which point we flush and exit.
|
||||||
let tmp = format!("{path}.tmp");
|
store.shutdown();
|
||||||
if let Ok(mut f) = std::fs::File::create(&tmp) {
|
|
||||||
if f.write_all(json.as_bytes()).is_ok() {
|
|
||||||
let _ = f.flush();
|
|
||||||
let _ = std::fs::rename(&tmp, path);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Fetch `--key VALUE` from argv, or None.
|
/// Fetch `--key VALUE` from argv, or None.
|
||||||
@@ -149,5 +174,15 @@ fn default_store() -> String {
|
|||||||
"/var/lib/cube/cube-store.json".to_string()
|
"/var/lib/cube/cube-store.json".to_string()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn default_recovery() -> String {
|
||||||
|
if let Ok(state) = std::env::var("XDG_STATE_HOME") {
|
||||||
|
return format!("{state}/cube/cube-store.recovery.ndjson");
|
||||||
|
}
|
||||||
|
if let Ok(home) = std::env::var("HOME") {
|
||||||
|
return format!("{home}/.local/state/cube/cube-store.recovery.ndjson");
|
||||||
|
}
|
||||||
|
"/var/lib/cube/cube-store.recovery.ndjson".to_string()
|
||||||
|
}
|
||||||
|
|
||||||
#[allow(dead_code)]
|
#[allow(dead_code)]
|
||||||
fn _ensure_pathbuf(_p: PathBuf) {}
|
fn _ensure_pathbuf(_p: PathBuf) {}
|
||||||
|
|||||||
+69
-57
@@ -1,19 +1,22 @@
|
|||||||
//! Shared CUBELinux-2 system command interpreter.
|
//! Shared CUBELinux-2 system command interpreter.
|
||||||
//!
|
//!
|
||||||
//! This module holds the *single* implementation of the cube command language
|
//! This module holds the *single* implementation of the cube command language
|
||||||
//! (`prog`, `write`, `run`, `ls`, `stat`, `seal`, `open`). It is used by every
|
//! (`prog`, `write`, `run`, `ls`, `stat`, `seal`, `open`, `query`). It is used
|
||||||
//! front-end — the local `cube` REPL, the `cubec` socket client, and the
|
//! by every front-end — the local `cube` REPL, the `cubec` socket client, and
|
||||||
//! `cube-server` daemon — so the behaviour can never drift between them.
|
//! the `cube-server` daemon — so the behaviour can never drift between them.
|
||||||
//!
|
//!
|
||||||
//! A [`Session`] wraps one [`CubeStore`] backend (currently the in-memory
|
//! A [`Session`] wraps one [`ConcurrentStore`] (a mutex-wrapped `CubeStore`
|
||||||
//! [`HashBackend`]) and executes one command line at a time against it. The
|
//! with a write-ahead log + scheduled checkpoint behind it). Because the store
|
||||||
//! daemon holds a single long-lived `Session`; the REPL holds a transient one.
|
//! 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 cubecode::{CodeCell, Kind, Op, Vm};
|
||||||
use cubecoords::CubeHeader;
|
use cubecoords::CubeHeader;
|
||||||
use cubecrypt::{CubeEnv, KeySlot, Selector, TransformId};
|
use cubecrypt::{CubeEnv, KeySlot, Selector, TransformId};
|
||||||
use cubestore::{CubeStore, HashBackend};
|
|
||||||
use std::collections::BTreeMap;
|
use std::collections::BTreeMap;
|
||||||
|
use std::sync::Arc;
|
||||||
use std::time::Instant;
|
use std::time::Instant;
|
||||||
|
|
||||||
/// Per-command latency accumulator (cumulative; the daemon reports these via
|
/// Per-command latency accumulator (cumulative; the daemon reports these via
|
||||||
@@ -25,10 +28,12 @@ struct CmdStat {
|
|||||||
max_ns: u128,
|
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 {
|
pub struct Session {
|
||||||
store: CubeStore<HashBackend>,
|
store: Arc<ConcurrentStore>,
|
||||||
/// Total commands executed since this session started (telemetry).
|
/// Total commands executed under this session (telemetry).
|
||||||
calls: u64,
|
calls: u64,
|
||||||
/// Per top-level command latency histogram (command name -> stats).
|
/// Per top-level command latency histogram (command name -> stats).
|
||||||
per_cmd: BTreeMap<String, CmdStat>,
|
per_cmd: BTreeMap<String, CmdStat>,
|
||||||
@@ -41,36 +46,35 @@ impl Default for Session {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl 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 {
|
pub fn new() -> Self {
|
||||||
Session {
|
Session {
|
||||||
store: CubeStore::new(HashBackend::new()),
|
store: Arc::new(ConcurrentStore::memory()),
|
||||||
calls: 0,
|
calls: 0,
|
||||||
per_cmd: BTreeMap::new(),
|
per_cmd: BTreeMap::new(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Access the underlying store (used by persistence to snapshot/restore).
|
/// A session over a durable, concurrent store (used by the daemon).
|
||||||
pub fn store(&self) -> &CubeStore<HashBackend> {
|
pub fn with_store(store: Arc<ConcurrentStore>) -> Self {
|
||||||
&self.store
|
Session {
|
||||||
|
store,
|
||||||
|
calls: 0,
|
||||||
|
per_cmd: BTreeMap::new(),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Mutable access to the underlying store (used by persistence to load).
|
/// Shared handle to the underlying concurrent store.
|
||||||
pub fn store_mut(&mut self) -> &mut CubeStore<HashBackend> {
|
pub fn store(&self) -> Arc<ConcurrentStore> {
|
||||||
&mut self.store
|
self.store.clone()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Snapshot of the session's telemetry: total commands serviced, per-command
|
/// Snapshot of the session's telemetry: total commands serviced, per-command
|
||||||
/// latency distribution (mean/max in µs), and the occupancy of each `C`
|
/// latency distribution (mean/max in µs), and the occupancy of each `C`
|
||||||
/// namespace (number of records whose class axis equals `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 {
|
pub fn stats(&self) -> String {
|
||||||
let mut lines = Vec::new();
|
let mut lines = Vec::new();
|
||||||
lines.push(format!("total commands serviced: {}", self.calls));
|
lines.push(format!("total commands serviced: {}", self.calls));
|
||||||
// per-command latency distribution
|
|
||||||
lines.push("per-command latency (µs, mean / max / count):".into());
|
lines.push("per-command latency (µs, mean / max / count):".into());
|
||||||
if self.per_cmd.is_empty() {
|
if self.per_cmd.is_empty() {
|
||||||
lines.push(" (no commands timed yet)".into());
|
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 keys = self.store.keys();
|
||||||
let mut by_c: BTreeMap<u8, usize> = BTreeMap::new();
|
let mut by_c: BTreeMap<u8, usize> = BTreeMap::new();
|
||||||
for k in &keys {
|
for k in &keys {
|
||||||
@@ -113,8 +116,6 @@ impl Session {
|
|||||||
let t0 = Instant::now();
|
let t0 = Instant::now();
|
||||||
let cmd_name = line.split_whitespace().next().unwrap_or("").to_string();
|
let cmd_name = line.split_whitespace().next().unwrap_or("").to_string();
|
||||||
let result = self.exec_inner(line);
|
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;
|
self.calls += 1;
|
||||||
let st = self.per_cmd.entry(cmd_name).or_default();
|
let st = self.per_cmd.entry(cmd_name).or_default();
|
||||||
let elapsed = t0.elapsed().as_nanos();
|
let elapsed = t0.elapsed().as_nanos();
|
||||||
@@ -130,12 +131,25 @@ impl Session {
|
|||||||
fn exec_inner(&mut self, line: &str) -> Result<String, String> {
|
fn exec_inner(&mut self, line: &str) -> Result<String, String> {
|
||||||
let mut it = line.split_whitespace();
|
let mut it = line.split_whitespace();
|
||||||
let cmd = it.next().ok_or_else(|| "empty line".to_string())?;
|
let cmd = it.next().ok_or_else(|| "empty line".to_string())?;
|
||||||
|
let store = &self.store;
|
||||||
match cmd {
|
match cmd {
|
||||||
"stats" => {
|
"stats" => Ok(self.stats()),
|
||||||
// Substantive telemetry: command volume + latency distribution
|
"query" => {
|
||||||
// + per-C-namespace record occupancy. This is what makes the
|
let dt = it
|
||||||
// daemon measurable, not merely "healthy".
|
.next()
|
||||||
Ok(self.stats())
|
.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" => {
|
"prog" => {
|
||||||
let path = it.next().ok_or_else(|| "prog needs <path>".to_string())?;
|
let path = it.next().ok_or_else(|| "prog needs <path>".to_string())?;
|
||||||
@@ -154,8 +168,8 @@ impl Session {
|
|||||||
return Err("prog: no ops given".to_string());
|
return Err("prog: no ops given".to_string());
|
||||||
}
|
}
|
||||||
let name = path.rsplit('/').next().unwrap_or(path);
|
let name = path.rsplit('/').next().unwrap_or(path);
|
||||||
let coord =
|
let coord = store
|
||||||
crate::store_code_cell(&mut self.store, path, Kind::Fn, name, &[], &ops)
|
.put_code_cell(path, Kind::Fn, name, &[], &ops)
|
||||||
.map_err(|e| e.to_string())?;
|
.map_err(|e| e.to_string())?;
|
||||||
Ok(format!(
|
Ok(format!(
|
||||||
"wrote program {path} -> coord {} ({} ops)",
|
"wrote program {path} -> coord {} ({} ops)",
|
||||||
@@ -172,15 +186,17 @@ impl Session {
|
|||||||
let code = cubecode::decode(&bytes)
|
let code = cubecode::decode(&bytes)
|
||||||
.map_err(|e| format!("bytecode decode error: {e:?}"))?;
|
.map_err(|e| format!("bytecode decode error: {e:?}"))?;
|
||||||
let name = path.rsplit('/').next().unwrap_or(path);
|
let name = path.rsplit('/').next().unwrap_or(path);
|
||||||
let coord =
|
let coord = store
|
||||||
crate::store_code_cell(&mut self.store, path, Kind::Fn, name, &[], &code)
|
.put_code_cell(path, Kind::Fn, name, &[], &code)
|
||||||
.map_err(|e| e.to_string())?;
|
.map_err(|e| e.to_string())?;
|
||||||
Ok(format!("wrote {path} -> coord {}", coord.pack_u32()))
|
Ok(format!("wrote {path} -> coord {}", coord.pack_u32()))
|
||||||
}
|
}
|
||||||
"run" => {
|
"run" => {
|
||||||
let path = it.next().ok_or_else(|| "run needs <path>".to_string())?;
|
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 _coord = crate::path_to_czyx(path).map_err(|e| e.to_string())?;
|
||||||
let mut vm = Vm::new(self.store.clone());
|
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 res = vm.run(cell.label);
|
||||||
let mut out = format!("run {path} => {res:?}");
|
let mut out = format!("run {path} => {res:?}");
|
||||||
if !vm.output().is_empty() {
|
if !vm.output().is_empty() {
|
||||||
@@ -193,7 +209,7 @@ impl Session {
|
|||||||
}
|
}
|
||||||
"ls" => {
|
"ls" => {
|
||||||
let dir = it.next().ok_or_else(|| "ls needs <dir>".to_string())?;
|
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:?}"))?;
|
let entries = fs.readdir(dir).map_err(|e| format!("ls {dir}: {e:?}"))?;
|
||||||
if entries.is_empty() {
|
if entries.is_empty() {
|
||||||
Ok(format!("ls {dir} -> (empty)"))
|
Ok(format!("ls {dir} -> (empty)"))
|
||||||
@@ -204,7 +220,7 @@ impl Session {
|
|||||||
}
|
}
|
||||||
"stat" => {
|
"stat" => {
|
||||||
let path = it.next().ok_or_else(|| "stat needs <path>".to_string())?;
|
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
|
let a = fs
|
||||||
.getattr(path)
|
.getattr(path)
|
||||||
.map_err(|e| format!("stat {path}: {e:?}"))?;
|
.map_err(|e| format!("stat {path}: {e:?}"))?;
|
||||||
@@ -227,13 +243,8 @@ impl Session {
|
|||||||
let transform = parse_transform(tf)
|
let transform = parse_transform(tf)
|
||||||
.ok_or_else(|| "unknown transform (none|gcm|chacha|xts)".to_string())?;
|
.ok_or_else(|| "unknown transform (none|gcm|chacha|xts)".to_string())?;
|
||||||
|
|
||||||
// Ensure key material exists at the Null-cube key cell.
|
if store.get_record(&kc).is_none() {
|
||||||
if self.store.get_record(&kc).is_none() {
|
store.put_raw(kc, b"demo-key-material-32-bytes-long!!".to_vec());
|
||||||
self.store.put_record(
|
|
||||||
kc,
|
|
||||||
&CubeHeader::new(),
|
|
||||||
b"demo-key-material-32-bytes-long!!",
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
let env = CubeEnv::new(
|
let env = CubeEnv::new(
|
||||||
vec![KeySlot {
|
vec![KeySlot {
|
||||||
@@ -245,28 +256,29 @@ impl Session {
|
|||||||
);
|
);
|
||||||
|
|
||||||
if cmd == "seal" {
|
if cmd == "seal" {
|
||||||
let (h, body) = self
|
let (h, body) = store
|
||||||
.store
|
|
||||||
.get_record(&coord)
|
.get_record(&coord)
|
||||||
.ok_or_else(|| format!("seal: no record at {path}"))?;
|
.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:?}"))?;
|
.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()))
|
Ok(format!("sealed {path} under key {} ({tf})", kc.pack_u32()))
|
||||||
} else {
|
} else {
|
||||||
let (_, envelope) = self
|
let (_, envelope) = store
|
||||||
.store
|
|
||||||
.get_record(&coord)
|
.get_record(&coord)
|
||||||
.ok_or_else(|| format!("open: no record at {path}"))?;
|
.ok_or_else(|| format!("open: no record at {path}"))?;
|
||||||
let pt = env
|
let pt = env
|
||||||
.open(&self.store, Selector::Slot(0), &envelope)
|
.open(&store.read_snapshot(), Selector::Slot(0), &envelope)
|
||||||
.map_err(|e| format!("open: {e:?}"))?;
|
.map_err(|e| format!("open: {e:?}"))?;
|
||||||
let cell = CodeCell::from_record(coord, &CubeHeader::new(), &pt)
|
let cell = CodeCell::from_record(coord, &CubeHeader::new(), &pt)
|
||||||
.ok_or_else(|| "open: decrypted body is not valid bytecode".to_string())?;
|
.ok_or_else(|| "open: decrypted body is not valid bytecode".to_string())?;
|
||||||
// The VM runs code located by coordinate, so to execute a
|
store.put_raw(coord, pt);
|
||||||
// sealed record we decrypt it back into a plaintext record,
|
let sn = store.read_snapshot();
|
||||||
// then run.
|
let mut vm = Vm::new(sn);
|
||||||
self.store.put_record(coord, &CubeHeader::new(), &pt);
|
|
||||||
let mut vm = Vm::new(self.store.clone());
|
|
||||||
let res = vm.run(cell.label);
|
let res = vm.run(cell.label);
|
||||||
Ok(format!(
|
Ok(format!(
|
||||||
"open+run {path} (key {}) => {res:?}",
|
"open+run {path} (key {}) => {res:?}",
|
||||||
|
|||||||
@@ -48,6 +48,10 @@ pub mod commands;
|
|||||||
pub mod net;
|
pub mod net;
|
||||||
/// Dependency-free JSON snapshot load/dump for daemon store persistence.
|
/// Dependency-free JSON snapshot load/dump for daemon store persistence.
|
||||||
pub mod persist;
|
pub mod persist;
|
||||||
|
/// Concurrent, durable store: mutex-wrapped [`CubeStore`] + NDJSON write-ahead
|
||||||
|
/// 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;
|
||||||
|
|
||||||
/// 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)]
|
||||||
|
|||||||
+83
-59
@@ -1,9 +1,9 @@
|
|||||||
//! Dependency-free snapshot persistence for a [`Session`].
|
//! Dependency-free snapshot persistence for a `CubeStore<HashBackend>`.
|
||||||
//!
|
//!
|
||||||
//! The store backend is currently the in-memory [`HashBackend`]. To make a
|
//! The store backend is the in-memory [`HashBackend`]. To make a long-lived
|
||||||
//! long-lived daemon actually "hold the store" across restarts, we snapshot
|
//! daemon actually "hold the store" across restarts, we snapshot records to a
|
||||||
//! every record to a single JSON file on disk. The on-wire form is intentionally
|
//! single file on disk. The on-wire form is intentionally simple and std-only
|
||||||
//! simple and std-only (no serde): each record becomes
|
//! (no serde): each record becomes
|
||||||
//!
|
//!
|
||||||
//! ```text
|
//! ```text
|
||||||
//! {"c":u8,"z":u8,"y":u8,"x":u8,"hdr":"<hex>","body":"<hex>"}
|
//! {"c":u8,"z":u8,"y":u8,"x":u8,"hdr":"<hex>","body":"<hex>"}
|
||||||
@@ -11,17 +11,50 @@
|
|||||||
//!
|
//!
|
||||||
//! where `hdr`/`body` are the raw bytes the store persists already (header +
|
//! where `hdr`/`body` are the raw bytes the store persists already (header +
|
||||||
//! body concatenated as the value). On load we replay those exact bytes back
|
//! body concatenated as the value). On load we replay those exact bytes back
|
||||||
//! through the same backend `put`, so the codec is never re-implemented here.
|
//! through the same backend `put_raw`, so the codec is never re-implemented
|
||||||
|
//! here.
|
||||||
|
//!
|
||||||
|
//! This module is also used as the durable "database" checkpoint by the
|
||||||
|
//! concurrent store ([`crate::store::ConcurrentStore`]): the whole live store
|
||||||
|
//! is dumped via [`dump_store`] and re-loaded via [`load_into_store`] on
|
||||||
|
//! startup, with newer transactions replayed from the WAL.
|
||||||
|
|
||||||
use crate::commands::Session;
|
|
||||||
use cubecoords::Czyx;
|
use cubecoords::Czyx;
|
||||||
/// Serialize the session's entire store to a JSON string.
|
use cubestore::{CubeStore, HashBackend};
|
||||||
///
|
|
||||||
/// The backend stores each record as `u32 header_len || hdr || body`. We split
|
/// Serialize a raw backend value (`header_len(4) || hdr || body`) as hex.
|
||||||
/// it so the snapshot carries the encoded header and body separately, and
|
pub fn raw_to_hex(raw: &[u8]) -> String {
|
||||||
/// [`load_into`] reassembles the identical byte string on replay.
|
let mut s = String::with_capacity(raw.len() * 2);
|
||||||
pub fn dump(session: &Session) -> String {
|
for b in raw {
|
||||||
let store = session.store();
|
s.push_str(&format!("{b:02x}"));
|
||||||
|
}
|
||||||
|
s
|
||||||
|
}
|
||||||
|
|
||||||
|
fn from_hex(s: &str) -> Result<Vec<u8>, String> {
|
||||||
|
if !s.len().is_multiple_of(2) {
|
||||||
|
return Err("odd-length hex".to_string());
|
||||||
|
}
|
||||||
|
let bytes = s.as_bytes();
|
||||||
|
let mut out = Vec::with_capacity(s.len() / 2);
|
||||||
|
let mut i = 0;
|
||||||
|
while i < bytes.len() {
|
||||||
|
let hi = (bytes[i] as char)
|
||||||
|
.to_digit(16)
|
||||||
|
.ok_or_else(|| "bad hex digit".to_string())?;
|
||||||
|
let lo = (bytes[i + 1] as char)
|
||||||
|
.to_digit(16)
|
||||||
|
.ok_or_else(|| "bad hex digit".to_string())?;
|
||||||
|
out.push((hi * 16 + lo) as u8);
|
||||||
|
i += 2;
|
||||||
|
}
|
||||||
|
Ok(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Serialize a whole [`CubeStore`] to a JSON string (one object per line inside
|
||||||
|
/// a `[ ]` array). Order is irrelevant on load. Used by the concurrent store
|
||||||
|
/// for the durable checkpoint.
|
||||||
|
pub fn dump_store(store: &CubeStore<HashBackend>) -> String {
|
||||||
let mut entries: Vec<String> = Vec::new();
|
let mut entries: Vec<String> = Vec::new();
|
||||||
for coord in store.keys() {
|
for coord in store.keys() {
|
||||||
if let Some(raw) = store.get_raw(&coord) {
|
if let Some(raw) = store.get_raw(&coord) {
|
||||||
@@ -42,12 +75,11 @@ pub fn dump(session: &Session) -> String {
|
|||||||
coord.z,
|
coord.z,
|
||||||
coord.y,
|
coord.y,
|
||||||
coord.x,
|
coord.x,
|
||||||
to_hex(hdr),
|
raw_to_hex(hdr),
|
||||||
to_hex(body)
|
raw_to_hex(body)
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Stable, readable array (order doesn't matter on load).
|
|
||||||
let mut out = String::from("[\n");
|
let mut out = String::from("[\n");
|
||||||
for (i, e) in entries.iter().enumerate() {
|
for (i, e) in entries.iter().enumerate() {
|
||||||
out.push_str(" ");
|
out.push_str(" ");
|
||||||
@@ -61,33 +93,32 @@ pub fn dump(session: &Session) -> String {
|
|||||||
out
|
out
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Replay a previously [`dump`]ed JSON snapshot into the session's store.
|
/// Replay a previously [`dump_store`]ed JSON snapshot into a raw store.
|
||||||
///
|
///
|
||||||
/// Records not present in the snapshot are left untouched; this is a merge, so
|
/// Records not present in the snapshot are left untouched; this is a merge, so
|
||||||
/// callers that want a clean reload should start from an empty [`Session`].
|
/// callers that want a clean reload should start from an empty store.
|
||||||
pub fn load_into(session: &mut Session, json: &str) -> Result<(), String> {
|
pub fn load_into_store(store: &mut CubeStore<HashBackend>, json: &str) -> Result<(), String> {
|
||||||
let trimmed = json.trim();
|
let trimmed = json.trim();
|
||||||
if !trimmed.starts_with('[') {
|
if !trimmed.starts_with('[') {
|
||||||
return Err("snapshot is not a JSON array".to_string());
|
return Err("snapshot is not a JSON array".to_string());
|
||||||
}
|
}
|
||||||
// Minimal array scanner: find each `{"...` object between `[` and `]`.
|
|
||||||
let inner = &trimmed[1..];
|
let inner = &trimmed[1..];
|
||||||
let mut i = 0;
|
let mut i = 0;
|
||||||
while let Some(start) = inner[i..].find("{\"") {
|
while let Some(start) = inner[i..].find("{\"") {
|
||||||
let obj_start = i + start;
|
let obj_start = i + start;
|
||||||
let obj_end = match inner[obj_start..].find("}") {
|
let obj_end = match inner[obj_start..].find('}') {
|
||||||
Some(o) => obj_start + o,
|
Some(o) => obj_start + o,
|
||||||
None => return Err("unterminated snapshot object".to_string()),
|
None => return Err("unterminated snapshot object".to_string()),
|
||||||
};
|
};
|
||||||
let obj = &inner[obj_start..=obj_end];
|
let obj = &inner[obj_start..=obj_end];
|
||||||
replay_object(session, obj)?;
|
replay_object(store, obj)?;
|
||||||
i = obj_end + 1;
|
i = obj_end + 1;
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Parse one `{...}` object and write it back through the backend.
|
/// Parse one `{...}` object and write it back through the backend.
|
||||||
fn replay_object(session: &mut Session, obj: &str) -> Result<(), String> {
|
fn replay_object(store: &mut CubeStore<HashBackend>, obj: &str) -> Result<(), String> {
|
||||||
let c = field_u8(obj, "c")?;
|
let c = field_u8(obj, "c")?;
|
||||||
let z = field_u8(obj, "z")?;
|
let z = field_u8(obj, "z")?;
|
||||||
let y = field_u8(obj, "y")?;
|
let y = field_u8(obj, "y")?;
|
||||||
@@ -106,16 +137,37 @@ fn replay_object(session: &mut Session, obj: &str) -> Result<(), String> {
|
|||||||
value.extend_from_slice(&hdr);
|
value.extend_from_slice(&hdr);
|
||||||
value.extend_from_slice(&body);
|
value.extend_from_slice(&body);
|
||||||
|
|
||||||
session.store_mut().put_raw(coord, value);
|
store.put_raw(coord, value);
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Build the search pattern `"key":` (quote, key, quote, colon).
|
||||||
|
fn pat_colon(key: &str) -> String {
|
||||||
|
let mut p = String::new();
|
||||||
|
p.push('"');
|
||||||
|
p.push_str(key);
|
||||||
|
p.push('"');
|
||||||
|
p.push(':');
|
||||||
|
p
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build the search pattern `"key":"` (quote, key, quote, colon, quote).
|
||||||
|
fn pat_colon_quote(key: &str) -> String {
|
||||||
|
let mut p = String::new();
|
||||||
|
p.push('"');
|
||||||
|
p.push_str(key);
|
||||||
|
p.push('"');
|
||||||
|
p.push(':');
|
||||||
|
p.push('"');
|
||||||
|
p
|
||||||
|
}
|
||||||
|
|
||||||
fn field_u8(obj: &str, key: &str) -> Result<u8, String> {
|
fn field_u8(obj: &str, key: &str) -> Result<u8, String> {
|
||||||
let key_pat = format!("\"{key}\":");
|
let pat = pat_colon(key);
|
||||||
let pos = obj
|
let pos = obj
|
||||||
.find(&key_pat)
|
.find(&pat)
|
||||||
.ok_or_else(|| format!("snapshot object missing {key}"))?;
|
.ok_or_else(|| format!("snapshot object missing {key}"))?;
|
||||||
let after = &obj[pos + key_pat.len()..];
|
let after = &obj[pos + pat.len()..];
|
||||||
let end = after.find([',', '}', ' ']).unwrap_or(after.len());
|
let end = after.find([',', '}', ' ']).unwrap_or(after.len());
|
||||||
after[..end]
|
after[..end]
|
||||||
.trim()
|
.trim()
|
||||||
@@ -124,41 +176,13 @@ fn field_u8(obj: &str, key: &str) -> Result<u8, String> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn field_str<'a>(obj: &'a str, key: &str) -> Result<&'a str, String> {
|
fn field_str<'a>(obj: &'a str, key: &str) -> Result<&'a str, String> {
|
||||||
let key_pat = format!("\"{key}\":\"");
|
let pat = pat_colon_quote(key);
|
||||||
let pos = obj
|
let pos = obj
|
||||||
.find(&key_pat)
|
.find(&pat)
|
||||||
.ok_or_else(|| format!("snapshot object missing {key}"))?;
|
.ok_or_else(|| format!("snapshot object missing {key}"))?;
|
||||||
let after = &obj[pos + key_pat.len()..];
|
let after = &obj[pos + pat.len()..];
|
||||||
let end = after
|
let end = after
|
||||||
.find('"')
|
.find('"')
|
||||||
.ok_or_else(|| format!("unterminated {key}"))?;
|
.ok_or_else(|| format!("unterminated {key}"))?;
|
||||||
Ok(&after[..end])
|
Ok(&after[..end])
|
||||||
}
|
}
|
||||||
|
|
||||||
fn to_hex(bytes: &[u8]) -> String {
|
|
||||||
let mut s = String::with_capacity(bytes.len() * 2);
|
|
||||||
for b in bytes {
|
|
||||||
s.push_str(&format!("{b:02x}"));
|
|
||||||
}
|
|
||||||
s
|
|
||||||
}
|
|
||||||
|
|
||||||
fn from_hex(s: &str) -> Result<Vec<u8>, String> {
|
|
||||||
if !s.len().is_multiple_of(2) {
|
|
||||||
return Err("odd-length hex".to_string());
|
|
||||||
}
|
|
||||||
let mut out = Vec::with_capacity(s.len() / 2);
|
|
||||||
let bytes = s.as_bytes();
|
|
||||||
let mut i = 0;
|
|
||||||
while i < bytes.len() {
|
|
||||||
let hi = (bytes[i] as char)
|
|
||||||
.to_digit(16)
|
|
||||||
.ok_or_else(|| "bad hex digit".to_string())?;
|
|
||||||
let lo = (bytes[i + 1] as char)
|
|
||||||
.to_digit(16)
|
|
||||||
.ok_or_else(|| "bad hex digit".to_string())?;
|
|
||||||
out.push((hi * 16 + lo) as u8);
|
|
||||||
i += 2;
|
|
||||||
}
|
|
||||||
Ok(out)
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -0,0 +1,828 @@
|
|||||||
|
//! Concurrent, durable cube store — the "real database" backend.
|
||||||
|
//!
|
||||||
|
//! Wraps the in-memory [`CubeStore<HashBackend>`] behind a `Mutex` so many
|
||||||
|
//! threads can read and write at once, and adds two durability layers:
|
||||||
|
//!
|
||||||
|
//! 1. A **write-ahead log (WAL)** — every transaction is appended as one
|
||||||
|
//! newline-delimited JSON object to a WAL file and *group-fsynced* on a
|
||||||
|
//! short interval (default 25 ms). The WAL is the "JSON backup of all
|
||||||
|
//! transactions": it covers the gap between a command returning and the
|
||||||
|
//! slower scheduled database checkpoint landing on disk, so the daemon
|
||||||
|
//! keeps its speed while still being crash-safe.
|
||||||
|
//!
|
||||||
|
//! 2. A **scheduled checkpoint** — on a configurable interval (default
|
||||||
|
//! 2000 ms) the whole store is serialized to the durable database file
|
||||||
|
//! (atomic rename) and the WAL is rotated. This is the "already scheduled
|
||||||
|
//! writes into the database."
|
||||||
|
//!
|
||||||
|
//! On startup we load the last checkpoint, then replay any WAL entries newer
|
||||||
|
//! than it. If we actually had to rely on the WAL (i.e. there were
|
||||||
|
//! not-yet-checkpointed transactions at crash time), we write a recovery
|
||||||
|
//! event to a recovery log — satisfying the requirement to *log, in writing,
|
||||||
|
//! any event where we had to fall back to the JSON backup*.
|
||||||
|
|
||||||
|
use std::fs::{self, File, OpenOptions};
|
||||||
|
use std::io::Write;
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
|
||||||
|
use std::sync::{Arc, Mutex};
|
||||||
|
use std::thread::{self, JoinHandle};
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use cubecoords::{CubeHeader, Czyx};
|
||||||
|
use cubestore::{CubeStore, HashBackend};
|
||||||
|
|
||||||
|
use crate::persist;
|
||||||
|
use cubecode::{Kind, Op};
|
||||||
|
|
||||||
|
/// Durability tuning.
|
||||||
|
#[derive(Clone, Copy, Debug)]
|
||||||
|
pub struct DurabilityConfig {
|
||||||
|
/// Checkpoint (full store snapshot) interval in milliseconds.
|
||||||
|
pub checkpoint_ms: u64,
|
||||||
|
/// WAL group-commit fsync interval in milliseconds. Smaller = less data
|
||||||
|
/// loss on crash, more fsync overhead.
|
||||||
|
pub wal_fsync_ms: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for DurabilityConfig {
|
||||||
|
fn default() -> Self {
|
||||||
|
DurabilityConfig {
|
||||||
|
checkpoint_ms: 2000,
|
||||||
|
wal_fsync_ms: 25,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One durable transaction in the WAL (newline-delimited JSON, one per line).
|
||||||
|
/// `seq` is monotonic so replay can skip already-checkpointed entries. For
|
||||||
|
/// `op:"put"`, `v` is the raw backend value bytes (header_len||hdr||body)
|
||||||
|
/// hex-encoded; replay does `put_raw(coord, v)` — identical to a normal write,
|
||||||
|
/// so replays are idempotent.
|
||||||
|
struct WalEntry {
|
||||||
|
seq: u64,
|
||||||
|
op: WalOp,
|
||||||
|
coord: Czyx,
|
||||||
|
value: Vec<u8>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||||
|
enum WalOp {
|
||||||
|
Put,
|
||||||
|
Delete,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The write-ahead log. Append-only NDJSON, group-fsynced on a timer.
|
||||||
|
struct Wal {
|
||||||
|
path: PathBuf,
|
||||||
|
/// `None` in memory mode (no durability); `Some` when a file backs it.
|
||||||
|
file: Mutex<Option<File>>,
|
||||||
|
/// Next sequence number to assign.
|
||||||
|
seq: AtomicU64,
|
||||||
|
/// Buffered NDJSON lines not yet fsync'd (group commit).
|
||||||
|
pending: Mutex<String>,
|
||||||
|
/// Highest sequence number known to be fsync'd to disk.
|
||||||
|
committed_seq: AtomicU64,
|
||||||
|
/// Shared stop flag (also owned by the group-commit thread).
|
||||||
|
stop: Arc<AtomicBool>,
|
||||||
|
/// Handle of the group-commit fsync thread (behind a Mutex so the struct
|
||||||
|
/// stays `Sync`; `JoinHandle` is `Send` but not `Sync`).
|
||||||
|
group_thread: Mutex<Option<JoinHandle<()>>>,
|
||||||
|
fsync_ms: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Wal {
|
||||||
|
/// Open (or create) a durable WAL at `path`, replaying nothing here — the
|
||||||
|
/// caller drives replay via [`Wal::replay_after`]. `cp_seq` is the highest
|
||||||
|
/// sequence already durable in the checkpoint; new entries continue after
|
||||||
|
/// `max(existing_file_seq, cp_seq)`.
|
||||||
|
fn open(
|
||||||
|
path: &Path,
|
||||||
|
cp_seq: u64,
|
||||||
|
fsync_ms: u64,
|
||||||
|
stop: Arc<AtomicBool>,
|
||||||
|
) -> std::io::Result<Arc<Wal>> {
|
||||||
|
let existing = fs::read_to_string(path).unwrap_or_default();
|
||||||
|
let mut max_seq = 0u64;
|
||||||
|
for line in existing.lines() {
|
||||||
|
if let Some(e) = decode_wal(line) {
|
||||||
|
if e.seq > max_seq {
|
||||||
|
max_seq = e.seq;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let start_seq = max_seq.max(cp_seq) + 1;
|
||||||
|
let file = OpenOptions::new()
|
||||||
|
.create(true)
|
||||||
|
.read(true)
|
||||||
|
.append(true)
|
||||||
|
.open(path)?;
|
||||||
|
let wal = Arc::new(Wal {
|
||||||
|
path: path.to_path_buf(),
|
||||||
|
file: Mutex::new(Some(file)),
|
||||||
|
seq: AtomicU64::new(start_seq),
|
||||||
|
pending: Mutex::new(String::new()),
|
||||||
|
committed_seq: AtomicU64::new(max_seq.max(cp_seq)),
|
||||||
|
stop: stop.clone(),
|
||||||
|
group_thread: Mutex::new(None),
|
||||||
|
fsync_ms,
|
||||||
|
});
|
||||||
|
let t = spawn_group(wal.clone());
|
||||||
|
*wal.group_thread.lock().unwrap() = Some(t);
|
||||||
|
Ok(wal)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// In-memory WAL: appends and fsyncs are no-ops, no background thread.
|
||||||
|
fn memory() -> Arc<Wal> {
|
||||||
|
Arc::new(Wal {
|
||||||
|
path: PathBuf::new(),
|
||||||
|
file: Mutex::new(None),
|
||||||
|
seq: AtomicU64::new(1),
|
||||||
|
pending: Mutex::new(String::new()),
|
||||||
|
committed_seq: AtomicU64::new(0),
|
||||||
|
stop: Arc::new(AtomicBool::new(true)),
|
||||||
|
group_thread: Mutex::new(None),
|
||||||
|
fsync_ms: 0,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Append a transaction. In memory mode this is a no-op.
|
||||||
|
fn append(&self, op: WalOp, coord: Czyx, value: Vec<u8>) {
|
||||||
|
let has_file = self.file.lock().unwrap().is_some();
|
||||||
|
if !has_file {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let s = self.seq.fetch_add(1, Ordering::SeqCst) + 1;
|
||||||
|
let line = encode_wal(&WalEntry {
|
||||||
|
seq: s,
|
||||||
|
op,
|
||||||
|
coord,
|
||||||
|
value,
|
||||||
|
});
|
||||||
|
let mut p = self.pending.lock().unwrap();
|
||||||
|
p.push_str(&line);
|
||||||
|
p.push('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Flush buffered lines to disk and fsync them (group commit).
|
||||||
|
fn flush_pending(&self) {
|
||||||
|
let bytes = {
|
||||||
|
let mut p = self.pending.lock().unwrap();
|
||||||
|
if p.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
std::mem::take(&mut *p)
|
||||||
|
};
|
||||||
|
let mut fopt = self.file.lock().unwrap();
|
||||||
|
if let Some(f) = fopt.as_mut() {
|
||||||
|
if f.write_all(bytes.as_bytes()).is_ok() && f.flush().is_ok() && f.sync_all().is_ok() {
|
||||||
|
self.committed_seq
|
||||||
|
.store(self.seq.load(Ordering::SeqCst), Ordering::SeqCst);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Replay entries with `seq > cp_seq` into `store`. Returns the number of
|
||||||
|
/// entries applied (0 means the checkpoint alone was sufficient).
|
||||||
|
fn replay_after(&self, cp_seq: u64, store: &mut CubeStore<HashBackend>) -> u64 {
|
||||||
|
let text = fs::read_to_string(&self.path).unwrap_or_default();
|
||||||
|
let mut applied = 0u64;
|
||||||
|
let mut max = 0u64;
|
||||||
|
for line in text.lines() {
|
||||||
|
if let Some(e) = decode_wal(line) {
|
||||||
|
if e.seq > max {
|
||||||
|
max = e.seq;
|
||||||
|
}
|
||||||
|
if e.seq > cp_seq {
|
||||||
|
match e.op {
|
||||||
|
WalOp::Put => store.put_raw(e.coord, e.value),
|
||||||
|
WalOp::Delete => store.delete_raw(&e.coord),
|
||||||
|
}
|
||||||
|
applied += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Continue sequence numbering past anything we saw.
|
||||||
|
let need = max.max(cp_seq) + 1;
|
||||||
|
let cur = self.seq.load(Ordering::SeqCst);
|
||||||
|
if need > cur {
|
||||||
|
self.seq.store(need, Ordering::SeqCst);
|
||||||
|
}
|
||||||
|
let committed = self.committed_seq.load(Ordering::SeqCst);
|
||||||
|
if need > committed {
|
||||||
|
self.committed_seq.store(need, Ordering::SeqCst);
|
||||||
|
}
|
||||||
|
applied
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Truncate the WAL after a checkpoint (future entries start fresh).
|
||||||
|
fn rotate(&self) {
|
||||||
|
if let Some(f) = self.file.lock().unwrap().as_mut() {
|
||||||
|
let _ = f.set_len(0);
|
||||||
|
let _ = f.sync_all();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Signal the group-commit thread to stop.
|
||||||
|
fn set_stop(&self) {
|
||||||
|
self.stop.store(true, Ordering::SeqCst);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Spawn the background thread that group-fsyncs buffered WAL lines.
|
||||||
|
fn spawn_group(wal: Arc<Wal>) -> JoinHandle<()> {
|
||||||
|
thread::spawn(move || loop {
|
||||||
|
thread::sleep(Duration::from_millis(wal.fsync_ms));
|
||||||
|
if wal.stop.load(Ordering::SeqCst) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
wal.flush_pending();
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The concurrent, durable store handle shared across threads.
|
||||||
|
pub struct ConcurrentStore {
|
||||||
|
inner: Arc<Mutex<CubeStore<HashBackend>>>,
|
||||||
|
wal: Arc<Wal>,
|
||||||
|
db_path: PathBuf,
|
||||||
|
cp_seq_path: PathBuf,
|
||||||
|
stop: Arc<AtomicBool>,
|
||||||
|
flush_thread: Arc<Mutex<Option<JoinHandle<()>>>>,
|
||||||
|
cfg: DurabilityConfig,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ConcurrentStore {
|
||||||
|
/// In-memory, non-durable store (used by the REPL, scripts, and tests).
|
||||||
|
/// No WAL, no checkpoint thread.
|
||||||
|
pub fn memory() -> Self {
|
||||||
|
ConcurrentStore {
|
||||||
|
inner: Arc::new(Mutex::new(CubeStore::new(HashBackend::new()))),
|
||||||
|
wal: Wal::memory(),
|
||||||
|
db_path: PathBuf::new(),
|
||||||
|
cp_seq_path: PathBuf::new(),
|
||||||
|
stop: Arc::new(AtomicBool::new(true)),
|
||||||
|
flush_thread: Arc::new(Mutex::new(None)),
|
||||||
|
cfg: DurabilityConfig::default(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Open (or create) a durable store at `db_path`, with the WAL at
|
||||||
|
/// `wal_path` and recovery events logged to `recovery_log`. Loads the last
|
||||||
|
/// checkpoint, replays any newer WAL entries, and starts the background
|
||||||
|
/// checkpoint thread.
|
||||||
|
pub fn open(
|
||||||
|
db_path: &str,
|
||||||
|
wal_path: &str,
|
||||||
|
recovery_log: &str,
|
||||||
|
cfg: DurabilityConfig,
|
||||||
|
) -> std::io::Result<Self> {
|
||||||
|
let db_p = PathBuf::from(db_path);
|
||||||
|
let wal_p = PathBuf::from(wal_path);
|
||||||
|
let cp_p = PathBuf::from(format!("{db_path}.seq"));
|
||||||
|
let rec_p = PathBuf::from(recovery_log);
|
||||||
|
if let Some(p) = db_p.parent() {
|
||||||
|
let _ = fs::create_dir_all(p);
|
||||||
|
}
|
||||||
|
if let Some(p) = wal_p.parent() {
|
||||||
|
let _ = fs::create_dir_all(p);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Highest sequence already durable in the last checkpoint.
|
||||||
|
let cp_seq: u64 = fs::read_to_string(&cp_p)
|
||||||
|
.ok()
|
||||||
|
.and_then(|s| s.trim().parse().ok())
|
||||||
|
.unwrap_or(0);
|
||||||
|
|
||||||
|
// Load the checkpoint snapshot into RAM.
|
||||||
|
let mut store = CubeStore::new(HashBackend::new());
|
||||||
|
if db_p.exists() {
|
||||||
|
match fs::read_to_string(&db_p) {
|
||||||
|
Ok(json) => {
|
||||||
|
if let Err(e) = persist::load_into_store(&mut store, &json) {
|
||||||
|
eprintln!("cube-store: checkpoint load failed ({e}); recovering from WAL");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => eprintln!("cube-store: cannot read {db_path}: {e}; starting empty"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let stop = Arc::new(AtomicBool::new(false));
|
||||||
|
let wal = Wal::open(&wal_p, cp_seq, cfg.wal_fsync_ms, stop.clone())?;
|
||||||
|
|
||||||
|
// Replay WAL entries newer than the checkpoint. If we had to rely on
|
||||||
|
// the WAL, record that fact in the recovery log (in writing).
|
||||||
|
let applied = wal.replay_after(cp_seq, &mut store);
|
||||||
|
if applied > 0 {
|
||||||
|
append_recovery_log(&rec_p, applied);
|
||||||
|
}
|
||||||
|
|
||||||
|
let inner = Arc::new(Mutex::new(store));
|
||||||
|
let cs = ConcurrentStore {
|
||||||
|
inner,
|
||||||
|
wal,
|
||||||
|
db_path: db_p,
|
||||||
|
cp_seq_path: cp_p,
|
||||||
|
stop,
|
||||||
|
flush_thread: Arc::new(Mutex::new(None)),
|
||||||
|
cfg,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Background checkpoint thread.
|
||||||
|
let flush = {
|
||||||
|
let inner = cs.inner.clone();
|
||||||
|
let db = cs.db_path.clone();
|
||||||
|
let cp = cs.cp_seq_path.clone();
|
||||||
|
let wal = cs.wal.clone();
|
||||||
|
let stop = cs.stop.clone();
|
||||||
|
let ms = cs.cfg.checkpoint_ms;
|
||||||
|
thread::spawn(move || loop {
|
||||||
|
thread::sleep(Duration::from_millis(ms));
|
||||||
|
if stop.load(Ordering::SeqCst) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
checkpoint_store(&inner, &db, &cp, &wal);
|
||||||
|
})
|
||||||
|
};
|
||||||
|
*cs.flush_thread.lock().unwrap() = Some(flush);
|
||||||
|
Ok(cs)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Reads (lock the inner store) ----
|
||||||
|
|
||||||
|
/// Raw backend get.
|
||||||
|
pub fn get_raw(&self, key: &Czyx) -> Option<Vec<u8>> {
|
||||||
|
self.inner.lock().unwrap().get_raw(key)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fetch and split a record into `(header, body)`.
|
||||||
|
pub fn get_record(&self, key: &Czyx) -> Option<(CubeHeader, Vec<u8>)> {
|
||||||
|
self.inner.lock().unwrap().get_record(key)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Every coordinate present.
|
||||||
|
pub fn keys(&self) -> Vec<Czyx> {
|
||||||
|
self.inner.lock().unwrap().keys()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Coordinates under a `C`/`Z`/`Y` prefix.
|
||||||
|
pub fn scan_prefix(&self, c: u8, z: Option<u8>, y: Option<u8>) -> Vec<Czyx> {
|
||||||
|
self.inner.lock().unwrap().scan_prefix(c, z, y)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Coordinates whose header lists `target` in `linked_records`.
|
||||||
|
pub fn linked_to(&self, target: &Czyx) -> Vec<Czyx> {
|
||||||
|
self.inner.lock().unwrap().linked_to(target)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Query by document type (the `doc_type` header field). A real predicate
|
||||||
|
/// over the store, not just a prefix scan.
|
||||||
|
pub fn query_doc_type(&self, dt: &str) -> Vec<Czyx> {
|
||||||
|
let g = self.inner.lock().unwrap();
|
||||||
|
let mut out = Vec::new();
|
||||||
|
for k in g.keys() {
|
||||||
|
if let Some((h, _)) = g.get_record(&k) {
|
||||||
|
if h.doc_type.as_deref() == Some(dt) {
|
||||||
|
out.push(k);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out.sort();
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A consistent point-in-time snapshot of the whole store. Used by the VM
|
||||||
|
/// and cubefs, which take a `CubeStore` by value.
|
||||||
|
pub fn read_snapshot(&self) -> CubeStore<HashBackend> {
|
||||||
|
self.inner.lock().unwrap().clone()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Writes (lock the inner store + log to WAL) ----
|
||||||
|
|
||||||
|
/// Raw backend put, durability-logged.
|
||||||
|
pub fn put_raw(&self, key: Czyx, value: Vec<u8>) {
|
||||||
|
let v = {
|
||||||
|
let mut g = self.inner.lock().unwrap();
|
||||||
|
g.put_raw(key, value);
|
||||||
|
g.get_raw(&key).unwrap_or_default()
|
||||||
|
};
|
||||||
|
self.wal.append(WalOp::Put, key, v);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Raw backend delete, durability-logged.
|
||||||
|
pub fn delete_raw(&self, key: &Czyx) {
|
||||||
|
self.inner.lock().unwrap().delete_raw(key);
|
||||||
|
self.wal.append(WalOp::Delete, *key, Vec::new());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Store `header` + `body` at `label`, durability-logged.
|
||||||
|
pub fn put_record(&self, key: Czyx, header: &CubeHeader, body: &[u8]) {
|
||||||
|
let v = {
|
||||||
|
let mut g = self.inner.lock().unwrap();
|
||||||
|
g.put_record(key, header, body);
|
||||||
|
g.get_raw(&key).unwrap_or_default()
|
||||||
|
};
|
||||||
|
self.wal.append(WalOp::Put, key, v);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Associate `src -> dst` (PDF Package 2 link), durability-logged.
|
||||||
|
pub fn associate(&self, src: Czyx, dst: Czyx) -> bool {
|
||||||
|
let ok = self.inner.lock().unwrap().associate(src, dst);
|
||||||
|
if ok {
|
||||||
|
if let Some(v) = self.get_raw(&src) {
|
||||||
|
self.wal.append(WalOp::Put, src, v);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ok
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Run a closure with exclusive access to the inner store. Used by callers
|
||||||
|
/// that mutate through the `CubeStore` API directly (e.g. `store_code_cell`,
|
||||||
|
/// `CubeEnv::put_encrypted`). After such a mutation, call [`log_put`] with
|
||||||
|
/// the resulting value to record it in the WAL.
|
||||||
|
pub fn with_mut<R>(&self, f: impl FnOnce(&mut CubeStore<HashBackend>) -> R) -> R {
|
||||||
|
let mut g = self.inner.lock().unwrap();
|
||||||
|
f(&mut g)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Durability-log a put whose bytes were written via [`with_mut`].
|
||||||
|
pub fn log_put(&self, key: Czyx, value: Vec<u8>) {
|
||||||
|
self.wal.append(WalOp::Put, key, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Store a code cell at `path` (the path->code bridge), durability-logged.
|
||||||
|
pub fn put_code_cell(
|
||||||
|
&self,
|
||||||
|
path: &str,
|
||||||
|
kind: Kind,
|
||||||
|
name: &str,
|
||||||
|
links: &[Czyx],
|
||||||
|
code: &[Op],
|
||||||
|
) -> Result<Czyx, crate::SysError> {
|
||||||
|
let coord =
|
||||||
|
self.with_mut(|store| crate::store_code_cell(store, path, kind, name, links, code))?;
|
||||||
|
if let Some(v) = self.get_raw(&coord) {
|
||||||
|
self.log_put(coord, v);
|
||||||
|
}
|
||||||
|
Ok(coord)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Force a durable checkpoint now (also flushes pending WAL).
|
||||||
|
pub fn checkpoint(&self) {
|
||||||
|
self.wal.flush_pending();
|
||||||
|
if self.db_path.as_os_str().is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
checkpoint_store(&self.inner, &self.db_path, &self.cp_seq_path, &self.wal);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Highest WAL sequence number known to be fsync'd to disk. Useful for
|
||||||
|
/// telemetry — "how much is actually durable right now".
|
||||||
|
pub fn wal_durable_seq(&self) -> u64 {
|
||||||
|
self.wal.committed_seq.load(Ordering::SeqCst)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Stop background threads and perform a final checkpoint. Safe to call
|
||||||
|
/// more than once.
|
||||||
|
pub fn shutdown(&self) {
|
||||||
|
self.stop.store(true, Ordering::SeqCst);
|
||||||
|
self.wal.set_stop();
|
||||||
|
self.checkpoint();
|
||||||
|
eprintln!(
|
||||||
|
"cube-store: shutdown; durable WAL seq up to {}",
|
||||||
|
self.wal_durable_seq()
|
||||||
|
);
|
||||||
|
if let Some(t) = self.flush_thread.lock().unwrap().take() {
|
||||||
|
let _ = t.join();
|
||||||
|
}
|
||||||
|
// Group thread is best-effort joined at Drop if still running.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Drop for ConcurrentStore {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
self.stop.store(true, Ordering::SeqCst);
|
||||||
|
self.wal.set_stop();
|
||||||
|
if let Some(t) = self.flush_thread.lock().unwrap().take() {
|
||||||
|
let _ = t.join();
|
||||||
|
}
|
||||||
|
if let Some(t) = self.wal.group_thread.lock().unwrap().take() {
|
||||||
|
let _ = t.join();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Write the full store to `db_path` (atomic rename) and rotate the WAL.
|
||||||
|
/// `cp_seq` recorded is the highest sequence assigned, because the snapshot
|
||||||
|
/// covers the entire in-RAM store (which always includes every put so far).
|
||||||
|
fn checkpoint_store(
|
||||||
|
inner: &Arc<Mutex<CubeStore<HashBackend>>>,
|
||||||
|
db_path: &Path,
|
||||||
|
cp_seq_path: &Path,
|
||||||
|
wal: &Arc<Wal>,
|
||||||
|
) {
|
||||||
|
let snap = inner.lock().unwrap().clone();
|
||||||
|
let json = persist::dump_store(&snap);
|
||||||
|
if let Some(parent) = db_path.parent() {
|
||||||
|
let _ = fs::create_dir_all(parent);
|
||||||
|
}
|
||||||
|
let tmp = db_path.with_extension("tmp");
|
||||||
|
if let Ok(mut f) = File::create(&tmp) {
|
||||||
|
if f.write_all(json.as_bytes()).is_ok() && f.flush().is_ok() && f.sync_all().is_ok() {
|
||||||
|
let _ = fs::rename(&tmp, db_path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Record the checkpoint sequence (max assigned), so replay on next start
|
||||||
|
// only recovers entries strictly newer than this snapshot.
|
||||||
|
let seq = wal.seq.load(Ordering::SeqCst);
|
||||||
|
if let Ok(mut f) = File::create(cp_seq_path) {
|
||||||
|
let _ = f.write_all(seq.to_string().as_bytes());
|
||||||
|
let _ = f.sync_all();
|
||||||
|
}
|
||||||
|
wal.rotate();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Append a recovery event to `path` (NDJSON, fsync'd). Called whenever we
|
||||||
|
/// had to fall back to the WAL backup at startup.
|
||||||
|
fn append_recovery_log(path: &Path, applied: u64) {
|
||||||
|
if let Some(p) = path.parent() {
|
||||||
|
let _ = fs::create_dir_all(p);
|
||||||
|
}
|
||||||
|
if let Ok(mut f) = OpenOptions::new().create(true).append(true).open(path) {
|
||||||
|
let ts = std::time::SystemTime::now()
|
||||||
|
.duration_since(std::time::UNIX_EPOCH)
|
||||||
|
.map(|d| d.as_secs())
|
||||||
|
.unwrap_or(0);
|
||||||
|
let line = format!(
|
||||||
|
"{{\"ts\":{ts},\"event\":\"wal_recovery\",\"applied\":{applied},\"note\":\"recovered {applied} transactions from the JSON WAL backup because they had not yet been checkpointed into the database\"}}\n"
|
||||||
|
);
|
||||||
|
let _ = f.write_all(line.as_bytes());
|
||||||
|
let _ = f.sync_all();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- WAL serialization (dependency-free) ----
|
||||||
|
|
||||||
|
fn encode_wal(e: &WalEntry) -> String {
|
||||||
|
match e.op {
|
||||||
|
WalOp::Put => format!(
|
||||||
|
"{{\"seq\":{},\"op\":\"put\",\"c\":{},\"z\":{},\"y\":{},\"x\":{},\"v\":\"{}\"}}",
|
||||||
|
e.seq,
|
||||||
|
e.coord.c,
|
||||||
|
e.coord.z,
|
||||||
|
e.coord.y,
|
||||||
|
e.coord.x,
|
||||||
|
to_hex(&e.value)
|
||||||
|
),
|
||||||
|
WalOp::Delete => format!(
|
||||||
|
"{{\"seq\":{},\"op\":\"del\",\"c\":{},\"z\":{},\"y\":{},\"x\":{}}}",
|
||||||
|
e.seq, e.coord.c, e.coord.z, e.coord.y, e.coord.x
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn decode_wal(line: &str) -> Option<WalEntry> {
|
||||||
|
let line = line.trim();
|
||||||
|
if line.is_empty() || !line.starts_with('{') {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let seq = field_u64(line, "seq").ok()?;
|
||||||
|
let op_s = field_str(line, "op");
|
||||||
|
let c = field_u8(line, "c").ok()?;
|
||||||
|
let z = field_u8(line, "z").ok()?;
|
||||||
|
let y = field_u8(line, "y").ok()?;
|
||||||
|
let x = field_u8(line, "x").ok()?;
|
||||||
|
let coord = Czyx::new(c, z, y, x);
|
||||||
|
match op_s {
|
||||||
|
"put" => {
|
||||||
|
let v = field_str(line, "v");
|
||||||
|
let value = from_hex(v).ok()?;
|
||||||
|
Some(WalEntry {
|
||||||
|
seq,
|
||||||
|
op: WalOp::Put,
|
||||||
|
coord,
|
||||||
|
value,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
"del" => Some(WalEntry {
|
||||||
|
seq,
|
||||||
|
op: WalOp::Delete,
|
||||||
|
coord,
|
||||||
|
value: Vec::new(),
|
||||||
|
}),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn field_u64(obj: &str, key: &str) -> Result<u64, String> {
|
||||||
|
let pat = format!("\"{key}\":");
|
||||||
|
let pos = obj.find(&pat).ok_or_else(|| format!("missing {key}"))?;
|
||||||
|
let after = &obj[pos + pat.len()..];
|
||||||
|
let end = after.find([',', '}', ' ']).unwrap_or(after.len());
|
||||||
|
after[..end]
|
||||||
|
.trim()
|
||||||
|
.parse::<u64>()
|
||||||
|
.map_err(|e| format!("bad {key}: {e}"))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn field_u8(obj: &str, key: &str) -> Result<u8, String> {
|
||||||
|
field_u64(obj, key).and_then(|v| u8::try_from(v).map_err(|_| format!("{key} out of u8 range")))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn field_str<'a>(obj: &'a str, key: &str) -> &'a str {
|
||||||
|
let pat = format!("\"{key}\":");
|
||||||
|
let pos = match obj.find(&pat) {
|
||||||
|
Some(p) => p,
|
||||||
|
None => return "",
|
||||||
|
};
|
||||||
|
let after = obj[pos + pat.len()..].trim_start();
|
||||||
|
if let Some(rest) = after.strip_prefix('"') {
|
||||||
|
if let Some(end) = rest.find('"') {
|
||||||
|
return &rest[..end];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
""
|
||||||
|
}
|
||||||
|
|
||||||
|
fn to_hex(bytes: &[u8]) -> String {
|
||||||
|
let mut s = String::with_capacity(bytes.len() * 2);
|
||||||
|
for b in bytes {
|
||||||
|
s.push_str(&format!("{b:02x}"));
|
||||||
|
}
|
||||||
|
s
|
||||||
|
}
|
||||||
|
|
||||||
|
fn from_hex(s: &str) -> Result<Vec<u8>, String> {
|
||||||
|
if !s.len().is_multiple_of(2) {
|
||||||
|
return Err("odd-length hex".to_string());
|
||||||
|
}
|
||||||
|
let bytes = s.as_bytes();
|
||||||
|
let mut out = Vec::with_capacity(s.len() / 2);
|
||||||
|
let mut i = 0;
|
||||||
|
while i < bytes.len() {
|
||||||
|
let hi = (bytes[i] as char)
|
||||||
|
.to_digit(16)
|
||||||
|
.ok_or_else(|| "bad hex digit".to_string())?;
|
||||||
|
let lo = (bytes[i + 1] as char)
|
||||||
|
.to_digit(16)
|
||||||
|
.ok_or_else(|| "bad hex digit".to_string())?;
|
||||||
|
out.push((hi * 16 + lo) as u8);
|
||||||
|
i += 2;
|
||||||
|
}
|
||||||
|
Ok(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use std::sync::atomic::AtomicUsize;
|
||||||
|
|
||||||
|
fn tmp(name: &str) -> String {
|
||||||
|
format!(
|
||||||
|
"{}/cubetest-{}-{}",
|
||||||
|
std::env::temp_dir().display(),
|
||||||
|
std::process::id(),
|
||||||
|
name
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn cleanup(db: &str) {
|
||||||
|
let _ = fs::remove_file(db);
|
||||||
|
let _ = fs::remove_file(format!("{db}.wal"));
|
||||||
|
let _ = fs::remove_file(format!("{db}.recovery.ndjson"));
|
||||||
|
let _ = fs::remove_file(format!("{db}.seq"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn memory_store_roundtrip() {
|
||||||
|
let s = ConcurrentStore::memory();
|
||||||
|
s.put_raw(Czyx::new(1, 2, 3, 4), vec![9]);
|
||||||
|
assert_eq!(s.get_raw(&Czyx::new(1, 2, 3, 4)), Some(vec![9]));
|
||||||
|
let sn = s.read_snapshot();
|
||||||
|
assert_eq!(sn.get_raw(&Czyx::new(1, 2, 3, 4)), Some(vec![9]));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn durable_checkpoint_and_replay() {
|
||||||
|
let db = tmp("db.json");
|
||||||
|
let wal = format!("{db}.wal");
|
||||||
|
let rec = format!("{db}.recovery.ndjson");
|
||||||
|
cleanup(&db);
|
||||||
|
|
||||||
|
{
|
||||||
|
let s = ConcurrentStore::open(
|
||||||
|
&db,
|
||||||
|
&wal,
|
||||||
|
&rec,
|
||||||
|
DurabilityConfig {
|
||||||
|
checkpoint_ms: 50,
|
||||||
|
wal_fsync_ms: 10,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
s.put_record(Czyx::new(1, 1, 1, 1), &CubeHeader::new(), b"1");
|
||||||
|
s.put_record(Czyx::new(2, 2, 2, 2), &CubeHeader::new(), b"2");
|
||||||
|
s.checkpoint();
|
||||||
|
}
|
||||||
|
{
|
||||||
|
let s = ConcurrentStore::open(&db, &wal, &rec, DurabilityConfig::default()).unwrap();
|
||||||
|
assert!(
|
||||||
|
s.keys().contains(&Czyx::new(1, 1, 1, 1)),
|
||||||
|
"coord 1 lost across checkpoint"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
s.keys().contains(&Czyx::new(2, 2, 2, 2)),
|
||||||
|
"coord 2 lost across checkpoint"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
s.get_record(&Czyx::new(1, 1, 1, 1)).unwrap().1,
|
||||||
|
b"1".to_vec()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
cleanup(&db);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn wal_recovery_after_crash() {
|
||||||
|
let db = tmp("db2.json");
|
||||||
|
let wal = format!("{db}.wal");
|
||||||
|
let rec = format!("{db}.recovery.ndjson");
|
||||||
|
cleanup(&db);
|
||||||
|
|
||||||
|
{
|
||||||
|
// Long checkpoint interval so the post-checkpoint write is NOT
|
||||||
|
// checkpointed before we "crash".
|
||||||
|
let s = ConcurrentStore::open(
|
||||||
|
&db,
|
||||||
|
&wal,
|
||||||
|
&rec,
|
||||||
|
DurabilityConfig {
|
||||||
|
checkpoint_ms: 10_000,
|
||||||
|
wal_fsync_ms: 10,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
s.put_record(Czyx::new(1, 1, 1, 1), &CubeHeader::new(), b"1");
|
||||||
|
s.put_record(Czyx::new(2, 2, 2, 2), &CubeHeader::new(), b"2");
|
||||||
|
s.checkpoint();
|
||||||
|
// Simulate a write that is fsync'd to the WAL but never
|
||||||
|
// checkpointed, then a crash (drop without another checkpoint).
|
||||||
|
s.put_record(Czyx::new(3, 3, 3, 3), &CubeHeader::new(), b"3");
|
||||||
|
s.wal.flush_pending();
|
||||||
|
}
|
||||||
|
// Reopen: 1,2 from the checkpoint; 3 recovered from the WAL.
|
||||||
|
{
|
||||||
|
let s = ConcurrentStore::open(&db, &wal, &rec, DurabilityConfig::default()).unwrap();
|
||||||
|
assert!(s.keys().contains(&Czyx::new(1, 1, 1, 1)), "coord 1 lost");
|
||||||
|
assert!(s.keys().contains(&Czyx::new(2, 2, 2, 2)), "coord 2 lost");
|
||||||
|
assert!(
|
||||||
|
s.keys().contains(&Czyx::new(3, 3, 3, 3)),
|
||||||
|
"coord 3 not recovered from WAL"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
let rec_text = fs::read_to_string(&rec).unwrap_or_default();
|
||||||
|
assert!(
|
||||||
|
rec_text.contains("wal_recovery"),
|
||||||
|
"expected a recovery event to be logged"
|
||||||
|
);
|
||||||
|
cleanup(&db);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn concurrent_writes() {
|
||||||
|
let s = Arc::new(ConcurrentStore::memory());
|
||||||
|
let counter = Arc::new(AtomicUsize::new(0));
|
||||||
|
let mut hs = Vec::new();
|
||||||
|
for t in 0..8u32 {
|
||||||
|
let s = s.clone();
|
||||||
|
let c = counter.clone();
|
||||||
|
hs.push(thread::spawn(move || {
|
||||||
|
for i in 0..500u32 {
|
||||||
|
let x = (t * 500 + i) as u8;
|
||||||
|
s.put_raw(Czyx::new(1, 0, 0, x), vec![x]);
|
||||||
|
c.fetch_add(1, Ordering::SeqCst);
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
for h in hs {
|
||||||
|
h.join().unwrap();
|
||||||
|
}
|
||||||
|
assert_eq!(counter.load(Ordering::SeqCst), 4000);
|
||||||
|
// All 4000 puts landed (last-writer-wins per coordinate); because the X
|
||||||
|
// axis is a u8, the 4000 distinct input x-values wrap to 256 unique
|
||||||
|
// coordinates, so the store holds 256 records — every put was observed
|
||||||
|
// by the mutex, none lost to a race.
|
||||||
|
assert_eq!(s.keys().len(), 256);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn query_doc_type_works() {
|
||||||
|
let s = ConcurrentStore::memory();
|
||||||
|
let mut h = CubeHeader::new();
|
||||||
|
h.doc_type = Some("fn".into());
|
||||||
|
h.refresh_flags();
|
||||||
|
s.put_record(Czyx::new(1, 1, 1, 1), &h, b"a");
|
||||||
|
s.put_record(Czyx::new(1, 1, 1, 2), &CubeHeader::new(), b"b");
|
||||||
|
let fns = s.query_doc_type("fn");
|
||||||
|
assert_eq!(fns, vec![Czyx::new(1, 1, 1, 1)]);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user