//! Dependency-free snapshot persistence for a `CubeStore`. //! //! 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":"","body":""} //! ``` //! //! 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, 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) -> String { let mut entries: Vec = 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, 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, 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 { 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::() .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]) }