- 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.
189 lines
6.2 KiB
Rust
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])
|
|
}
|