Files
cubelinux-2/cubesys/src/persist.rs
T
CUBELinux-2 7ccb29aa6a 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.
2026-08-11 03:18:59 -04:00

189 lines
6.2 KiB
Rust

//! Dependency-free snapshot persistence for a `CubeStore<HashBackend>`.
//!
//! The store backend is the in-memory [`HashBackend`]. To make a long-lived
//! daemon actually "hold the store" across restarts, we snapshot records to a
//! single file on disk. The on-wire form is intentionally simple and std-only
//! (no serde): each record becomes
//!
//! ```text
//! {"c":u8,"z":u8,"y":u8,"x":u8,"hdr":"<hex>","body":"<hex>"}
//! ```
//!
//! 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
//! 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 cubecoords::Czyx;
use cubestore::{CubeStore, HashBackend};
/// Serialize a raw backend value (`header_len(4) || hdr || body`) as hex.
pub fn raw_to_hex(raw: &[u8]) -> String {
let mut s = String::with_capacity(raw.len() * 2);
for b in raw {
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();
for coord in store.keys() {
if let Some(raw) = store.get_raw(&coord) {
if raw.len() < 4 {
continue;
}
let mut len = [0u8; 4];
len.copy_from_slice(&raw[..4]);
let hlen = u32::from_le_bytes(len) as usize;
if raw.len() < 4 + hlen {
continue;
}
let hdr = &raw[4..4 + hlen];
let body = &raw[4 + hlen..];
entries.push(format!(
"{{\"c\":{},\"z\":{},\"y\":{},\"x\":{},\"hdr\":\"{}\",\"body\":\"{}\"}}",
coord.c,
coord.z,
coord.y,
coord.x,
raw_to_hex(hdr),
raw_to_hex(body)
));
}
}
let mut out = String::from("[\n");
for (i, e) in entries.iter().enumerate() {
out.push_str(" ");
out.push_str(e);
if i + 1 < entries.len() {
out.push(',');
}
out.push('\n');
}
out.push_str("]\n");
out
}
/// 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
/// callers that want a clean reload should start from an empty store.
pub fn load_into_store(store: &mut CubeStore<HashBackend>, json: &str) -> Result<(), String> {
let trimmed = json.trim();
if !trimmed.starts_with('[') {
return Err("snapshot is not a JSON array".to_string());
}
let inner = &trimmed[1..];
let mut i = 0;
while let Some(start) = inner[i..].find("{\"") {
let obj_start = i + start;
let obj_end = match inner[obj_start..].find('}') {
Some(o) => obj_start + o,
None => return Err("unterminated snapshot object".to_string()),
};
let obj = &inner[obj_start..=obj_end];
replay_object(store, obj)?;
i = obj_end + 1;
}
Ok(())
}
/// Parse one `{...}` object and write it back through the backend.
fn replay_object(store: &mut CubeStore<HashBackend>, obj: &str) -> Result<(), String> {
let c = field_u8(obj, "c")?;
let z = field_u8(obj, "z")?;
let y = field_u8(obj, "y")?;
let x = field_u8(obj, "x")?;
let hdr_hex = field_str(obj, "hdr")?;
let body_hex = field_str(obj, "body")?;
let coord = Czyx::new(c, z, y, x);
// The value stored by the backend is exactly header_len(4) || hdr || body
// (see CubeStore::put_record). Reconstruct that so the load path is
// identical to a normal write path.
let hdr = from_hex(hdr_hex)?;
let body = from_hex(body_hex)?;
let mut value = Vec::with_capacity(4 + hdr.len() + body.len());
value.extend_from_slice(&(hdr.len() as u32).to_le_bytes());
value.extend_from_slice(&hdr);
value.extend_from_slice(&body);
store.put_raw(coord, value);
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> {
let pat = pat_colon(key);
let pos = obj
.find(&pat)
.ok_or_else(|| format!("snapshot object missing {key}"))?;
let after = &obj[pos + pat.len()..];
let end = after.find([',', '}', ' ']).unwrap_or(after.len());
after[..end]
.trim()
.parse::<u8>()
.map_err(|e| format!("bad {key}: {e}"))
}
fn field_str<'a>(obj: &'a str, key: &str) -> Result<&'a str, String> {
let pat = pat_colon_quote(key);
let pos = obj
.find(&pat)
.ok_or_else(|| format!("snapshot object missing {key}"))?;
let after = &obj[pos + pat.len()..];
let end = after
.find('"')
.ok_or_else(|| format!("unterminated {key}"))?;
Ok(&after[..end])
}