Add a delegated-grant auth layer: - cubesys/src/grants.rs: Grant/Owner/Perm model, GRANT_BUCKET at Czyx::new(0,1,0,1), grant/revoke/grant_allows, std-only JSON codec. - Stored via put_record with a doc_type='grant-table' header so it survives checkpoint/restore (raw put_raw was dropped on dump_store). - commands.rs: GRANT/REVOKE opcodes + admit_mutate() enforcement hook (owner -> grant -> deny). GRANT/REVOKE require HELLO identity under --require-identity. - Enforce owner-match contract preserved (legacy/tests stay green). - 11 new grant tests; full ./check quick = 115 pass, clippy -D clean.
1321 lines
47 KiB
Rust
1321 lines
47 KiB
Rust
//! 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, RwLock};
|
|
use std::thread::{self, JoinHandle};
|
|
use std::time::Duration;
|
|
|
|
use cubecoords::{CubeHeader, Czyx};
|
|
use cubestore::{CubeStore, HashBackend};
|
|
|
|
use std::collections::HashSet;
|
|
|
|
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,
|
|
}
|
|
}
|
|
}
|
|
|
|
/// When the incremental delta file grows past this many bytes, fold it into a
|
|
/// fresh full base snapshot and truncate the delta. Keeps on-disk state
|
|
/// bounded and fast to load, while still doing tiny incremental writes the
|
|
/// vast majority of the time. 1 MiB is comfortable for this store.
|
|
const DELTA_COMPACT_BYTES: u64 = 1_048_576;
|
|
|
|
/// 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. For `op:"txn"`, `batch` carries the committed
|
|
/// batch (puts/deletes) as one durable unit.
|
|
struct WalEntry {
|
|
seq: u64,
|
|
op: WalOp,
|
|
coord: Czyx,
|
|
value: Vec<u8>,
|
|
/// Only set for `op:"txn"`; empty otherwise.
|
|
batch: Vec<TxnEntry>,
|
|
}
|
|
|
|
#[derive(Clone, Copy, PartialEq, Eq)]
|
|
enum WalOp {
|
|
Put,
|
|
Delete,
|
|
/// A transaction commit: a batch of puts/deletes applied atomically.
|
|
/// `Some(value)` is a put (value is the raw backend bytes), `None` is a
|
|
/// delete. Replayed idempotently (each entry is a put_raw/delete_raw).
|
|
Txn,
|
|
}
|
|
|
|
/// One entry in a [`WalOp::Txn`] batch: `value: Some(bytes)` is a put,
|
|
/// `value: None` is a delete. Carries exactly what the store needs to apply
|
|
/// or replay the op — no header parsing required.
|
|
#[derive(Clone)]
|
|
pub struct TxnEntry {
|
|
/// Coordinate to write or delete.
|
|
pub coord: Czyx,
|
|
/// `Some(raw_backend_bytes)` => put; `None` => delete.
|
|
pub value: Option<Vec<u8>>,
|
|
}
|
|
|
|
/// The write-ahead log. Append-only NDJSON, group-fsync'd 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,
|
|
/// WAL sequence number already folded into the base snapshot. Replay on
|
|
/// startup begins after this, so the WAL only carries post-checkpoint
|
|
/// transactions.
|
|
cp_seq_wal: AtomicU64,
|
|
/// WAL sequence number of the last full base snapshot. Used to compute
|
|
/// the incremental delta (only coords written after `base_seq`).
|
|
base_seq: AtomicU64,
|
|
/// Set of coordinates written since `base_seq` (the authoritative changelog
|
|
/// for the incremental delta). Cleared whenever `base_seq` advances.
|
|
dirty: Mutex<HashSet<Czyx>>,
|
|
/// 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)),
|
|
cp_seq_wal: AtomicU64::new(cp_seq),
|
|
base_seq: AtomicU64::new(cp_seq),
|
|
dirty: Mutex::new(HashSet::new()),
|
|
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),
|
|
cp_seq_wal: AtomicU64::new(0),
|
|
base_seq: AtomicU64::new(0),
|
|
dirty: Mutex::new(HashSet::new()),
|
|
stop: Arc::new(AtomicBool::new(true)),
|
|
group_thread: Mutex::new(None),
|
|
fsync_ms: 0,
|
|
})
|
|
}
|
|
|
|
/// Mark `coord` dirty (written after `base_seq`), for the incremental delta.
|
|
fn mark_dirty(&self, coord: Czyx) {
|
|
self.dirty.lock().unwrap().insert(coord);
|
|
}
|
|
|
|
/// True if `coord` was written after `base_seq` (i.e. belongs in the delta).
|
|
fn coord_modified_since(&self, coord: &Czyx, base: u64) -> bool {
|
|
if base == 0 {
|
|
// No base yet — everything is "since base" only if it was written.
|
|
return self.dirty.lock().unwrap().contains(coord);
|
|
}
|
|
self.dirty.lock().unwrap().contains(coord)
|
|
}
|
|
|
|
fn set_cp_seq_wal(&self, v: u64) {
|
|
self.cp_seq_wal.store(v, Ordering::SeqCst);
|
|
}
|
|
|
|
fn set_base_seq(&self, v: u64) {
|
|
self.base_seq.store(v, Ordering::SeqCst);
|
|
self.dirty.lock().unwrap().clear();
|
|
}
|
|
|
|
/// 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;
|
|
self.mark_dirty(coord);
|
|
let line = encode_wal(&WalEntry {
|
|
seq: s,
|
|
op,
|
|
coord,
|
|
value,
|
|
batch: Vec::new(),
|
|
});
|
|
let mut p = self.pending.lock().unwrap();
|
|
p.push_str(&line);
|
|
p.push('\n');
|
|
}
|
|
|
|
/// Append a transaction commit. In memory mode this is a no-op. The whole
|
|
/// batch lands as ONE WAL line (`op:"txn"`), so a crash mid-txn replays as
|
|
/// a single all-or-nothing unit.
|
|
fn append_txn(&self, batch: &[TxnEntry]) {
|
|
let has_file = self.file.lock().unwrap().is_some();
|
|
if !has_file {
|
|
return;
|
|
}
|
|
let s = self.seq.fetch_add(1, Ordering::SeqCst) + 1;
|
|
for e in batch {
|
|
self.mark_dirty(e.coord);
|
|
}
|
|
let line = encode_wal(&WalEntry {
|
|
seq: s,
|
|
op: WalOp::Txn,
|
|
coord: Czyx::new(0, 0, 0, 0),
|
|
value: Vec::new(),
|
|
batch: batch.to_vec(),
|
|
});
|
|
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),
|
|
WalOp::Txn => {
|
|
// A committed batch replays as its puts/deletes.
|
|
// Idempotent (each is put_raw/delete_raw), so a
|
|
// crash that left the batch half-applied before the
|
|
// checkpoint still converges on replay.
|
|
for te in &e.batch {
|
|
match &te.value {
|
|
Some(v) => store.put_raw(te.coord, v.clone()),
|
|
None => store.delete_raw(&te.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);
|
|
}
|
|
// The base snapshot already reflected up to cp_seq; replay covered the
|
|
// rest. Record the boundary so future checkpoints compute deltas and
|
|
// WAL replay starts after it.
|
|
self.set_cp_seq_wal(cp_seq);
|
|
applied
|
|
}
|
|
|
|
/// 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.
|
|
///
|
|
/// Internally the `CubeStore` is guarded by an `RwLock` (Task 4): many reader
|
|
/// threads run in parallel, while writes and the scheduled checkpoint take the
|
|
/// write side. This converts the old "one global mutex serializes every
|
|
/// command" into "reads scale across cores, writes stay serialized per store"
|
|
/// — real reader/writer sharding without touching the WAL or the coordinate
|
|
/// encoding.
|
|
pub struct ConcurrentStore {
|
|
inner: Arc<RwLock<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(RwLock::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> {
|
|
// Load the checkpoint: base snapshot + incremental delta. Then replay
|
|
// any WAL entries newer than the base's WAL boundary.
|
|
let db_p = PathBuf::from(db_path);
|
|
let wal_p = PathBuf::from(wal_path);
|
|
// MUST match `checkpoint_store`'s `db_path.with_extension("delta")` so the
|
|
// delta is read back from the same path it is written to on checkpoint.
|
|
let delta_p = db_p.with_extension("delta");
|
|
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) = delta_p.parent() {
|
|
let _ = fs::create_dir_all(p);
|
|
}
|
|
|
|
// Highest WAL sequence already folded into the base.
|
|
let base_cp: u64 = fs::read_to_string(&cp_p)
|
|
.ok()
|
|
.and_then(|s| s.trim().parse().ok())
|
|
.unwrap_or(0);
|
|
|
|
// Reconstruct the store: base + delta.
|
|
let mut store = load_base_plus_delta(&db_p, &delta_p);
|
|
|
|
let stop = Arc::new(AtomicBool::new(false));
|
|
let wal = Wal::open(&wal_p, base_cp, cfg.wal_fsync_ms, stop.clone())?;
|
|
|
|
// Replay WAL entries newer than the base boundary. If we had to rely on
|
|
// the WAL, record that fact in the recovery log (in writing).
|
|
let applied = wal.replay_after(base_cp, &mut store);
|
|
if applied > 0 {
|
|
append_recovery_log(&rec_p, applied);
|
|
}
|
|
|
|
let inner = Arc::new(RwLock::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 (read-lock the inner store; many readers run in parallel) ----
|
|
|
|
/// Raw backend get.
|
|
pub fn get_raw(&self, key: &Czyx) -> Option<Vec<u8>> {
|
|
self.inner.read().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.read().unwrap().get_record(key)
|
|
}
|
|
|
|
/// Every coordinate present.
|
|
pub fn keys(&self) -> Vec<Czyx> {
|
|
self.inner.read().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.read().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.read().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.read().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
|
|
}
|
|
|
|
/// The `owner_local_user` stamped on the record at `key`, if it has one.
|
|
/// Used by owner enforcement (Task 6): a mutating command may only
|
|
/// overwrite a record whose owner matches the session's identity owner.
|
|
pub fn owner(&self, key: &Czyx) -> Option<String> {
|
|
self.inner
|
|
.read()
|
|
.unwrap()
|
|
.get_record(key)
|
|
.and_then(|(h, _)| h.owner_local_user.clone())
|
|
}
|
|
|
|
/// A consistent point-in-time snapshot of the whole store. Used by the VM
|
|
/// and cubefs, which take a `CubeStore` by value. A read-lock clone, so it
|
|
/// does not block concurrent readers (it only waits for an in-flight
|
|
/// writer to release).
|
|
pub fn read_snapshot(&self) -> CubeStore<HashBackend> {
|
|
self.inner.read().unwrap().clone()
|
|
}
|
|
|
|
/// Test/bench only: take the write side of the inner `RwLock` directly.
|
|
/// Gated behind the `bench` feature so it never ships in the daemon path.
|
|
#[cfg(feature = "bench")]
|
|
pub fn inner_write(&self) -> std::sync::RwLockWriteGuard<'_, CubeStore<HashBackend>> {
|
|
self.inner.write().unwrap()
|
|
}
|
|
|
|
// ---- Writes (write-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.write().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.write().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.write().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.write().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 (write) 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.write().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);
|
|
}
|
|
|
|
/// Apply a transaction batch atomically: under ONE store write lock, apply
|
|
/// every put/delete, then append a SINGLE `WalOp::Txn` WAL entry so the
|
|
/// whole batch is durable as one unit and replays idempotently.
|
|
///
|
|
/// The WAL is fsync'd before returning so a `COMMIT` is durable the moment
|
|
/// the caller gets control back (not merely "eventually" via the group
|
|
/// thread). This is what makes `commit` a real transaction boundary.
|
|
pub fn commit_txn(&self, batch: &[TxnEntry]) {
|
|
let mut g = self.inner.write().unwrap();
|
|
for e in batch {
|
|
match &e.value {
|
|
Some(v) => g.put_raw(e.coord, v.clone()),
|
|
None => g.delete_raw(&e.coord),
|
|
}
|
|
}
|
|
drop(g); // release the write lock before touching the WAL
|
|
self.wal.append_txn(batch);
|
|
self.wal.flush_pending(); // synchronous fsync: COMMIT == durable
|
|
}
|
|
|
|
/// Store a code cell at `path` (the path->code bridge), durability-logged.
|
|
/// `owner` (when set) is stamped on the record's `owner_local_user` field
|
|
/// so owner enforcement (Task 6) can later reject cross-owner overwrites.
|
|
pub fn put_code_cell(
|
|
&self,
|
|
path: &str,
|
|
kind: Kind,
|
|
name: &str,
|
|
links: &[Czyx],
|
|
code: &[Op],
|
|
owner: Option<&str>,
|
|
) -> Result<Czyx, crate::SysError> {
|
|
let coord = self.with_mut(|store| {
|
|
crate::store_code_cell(store, path, kind, name, links, code, owner)
|
|
})?;
|
|
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();
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Persistent checkpoint state lives in three files:
|
|
/// * `db_path` — the full base snapshot (NDJSON array). Written rarely.
|
|
/// * `db_path.delta` — incremental deltas since the base (one `{c,z,y,x,v}` object per modified coordinate, no seq). Written on every checkpoint.
|
|
/// * `db_path.seq` — the WAL sequence number already folded into the base (so WAL replay starts after it).
|
|
/// * `wal_path` — the WAL (transactions newer than `db_path.seq`).
|
|
///
|
|
/// On startup we load the base, apply the delta, then replay WAL entries newer than `db_path.seq`. The `cp_seq_wal` field tracks that boundary so replay knows where to begin. This gives tiny per-checkpoint disk writes (only changed coords) while keeping a full snapshot for fast cold load.
|
|
fn checkpoint_store(
|
|
inner: &Arc<RwLock<CubeStore<HashBackend>>>,
|
|
db_path: &Path,
|
|
cp_seq_path: &Path,
|
|
wal: &Arc<Wal>,
|
|
) {
|
|
wal.flush_pending();
|
|
|
|
// Collect only the coordinates that changed since the last base/checkpoint.
|
|
let delta = incremental(inner, wal);
|
|
|
|
let delta_path = db_path.with_extension("delta");
|
|
|
|
// If we have no delta at all (nothing changed), just re-stamp the WAL
|
|
// boundary and leave the base untouched.
|
|
if delta.is_empty() {
|
|
persist_seq(cp_seq_path, wal.seq.load(Ordering::SeqCst));
|
|
wal.set_cp_seq_wal(wal.seq.load(Ordering::SeqCst));
|
|
return;
|
|
}
|
|
|
|
// Append the delta. If the delta file is getting large, fold everything
|
|
// back into a fresh full base snapshot instead (compaction). If there is
|
|
// no base snapshot yet at all, we must write the full base now (the delta
|
|
// alone is not a complete store).
|
|
let delta_len = fs::read(&delta_path).map(|b| b.len() as u64).unwrap_or(0);
|
|
let base_exists = db_path.exists();
|
|
if !base_exists || delta_len + delta.len() as u64 > DELTA_COMPACT_BYTES {
|
|
fold_delta_into_base(inner, db_path, cp_seq_path, &delta_path, wal);
|
|
} else if let Some(parent) = delta_path.parent() {
|
|
let _ = fs::create_dir_all(parent);
|
|
if let Ok(mut f) = OpenOptions::new()
|
|
.create(true)
|
|
.append(true)
|
|
.open(&delta_path)
|
|
{
|
|
let mut buf = String::new();
|
|
for e in &delta {
|
|
buf.push_str(&format!(
|
|
"{{\"c\":{},\"z\":{},\"y\":{},\"x\":{},\"v\":\"{}\"}}\n",
|
|
e.0.c,
|
|
e.0.z,
|
|
e.0.y,
|
|
e.0.x,
|
|
to_hex(&e.1)
|
|
));
|
|
}
|
|
if f.write_all(buf.as_bytes()).is_ok() && f.flush().is_ok() && f.sync_all().is_ok() {
|
|
persist_seq(cp_seq_path, wal.seq.load(Ordering::SeqCst));
|
|
wal.set_cp_seq_wal(wal.seq.load(Ordering::SeqCst));
|
|
wal.set_base_seq(wal.seq.load(Ordering::SeqCst));
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Snapshot the current delta (only coordinates modified since the last base
|
|
/// snapshot) as a list of `(coord, raw_value)` pairs.
|
|
fn incremental(
|
|
inner: &Arc<RwLock<CubeStore<HashBackend>>>,
|
|
wal: &Arc<Wal>,
|
|
) -> Vec<(Czyx, Vec<u8>)> {
|
|
let g = inner.read().unwrap();
|
|
let base = wal.base_seq.load(Ordering::SeqCst);
|
|
let now = wal.seq.load(Ordering::SeqCst);
|
|
if now <= base {
|
|
return Vec::new();
|
|
}
|
|
let mut out = Vec::new();
|
|
for k in g.keys() {
|
|
// Only include coords whose last write is newer than the base
|
|
// snapshot. We approximate "changed since base" by tracking the set
|
|
// of coords written after `base` in the WAL (the authoritative changelog).
|
|
if wal.coord_modified_since(&k, base) {
|
|
if let Some(v) = g.get_raw(&k) {
|
|
out.push((k, v));
|
|
}
|
|
}
|
|
}
|
|
out
|
|
}
|
|
|
|
/// Fold the current delta + live store into a fresh full base snapshot and
|
|
/// truncate the delta. Used when the delta has grown too large.
|
|
fn fold_delta_into_base(
|
|
inner: &Arc<RwLock<CubeStore<HashBackend>>>,
|
|
db_path: &Path,
|
|
cp_seq_path: &Path,
|
|
delta_path: &Path,
|
|
wal: &Arc<Wal>,
|
|
) {
|
|
let snap = inner.read().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);
|
|
}
|
|
}
|
|
// Delta is now fully represented by the base; truncate it.
|
|
let _ = fs::write(delta_path, b"");
|
|
let seq = wal.seq.load(Ordering::SeqCst);
|
|
persist_seq(cp_seq_path, seq);
|
|
wal.set_cp_seq_wal(seq);
|
|
wal.set_base_seq(seq);
|
|
}
|
|
|
|
/// Read `db_path` (full base) then apply the delta file; returns the
|
|
/// reconstructed store. If either is missing it is simply skipped.
|
|
fn load_base_plus_delta(db_path: &Path, delta_path: &Path) -> CubeStore<HashBackend> {
|
|
let mut store = CubeStore::new(HashBackend::new());
|
|
if db_path.exists() {
|
|
if let Ok(json) = fs::read_to_string(db_path) {
|
|
if let Err(e) = persist::load_into_store(&mut store, &json) {
|
|
eprintln!("cube-store: base load failed ({e}); relying on WAL/delta");
|
|
}
|
|
}
|
|
}
|
|
if delta_path.exists() {
|
|
if let Ok(text) = fs::read_to_string(delta_path) {
|
|
for line in text.lines() {
|
|
let line = line.trim();
|
|
if line.is_empty() || !line.starts_with('{') {
|
|
continue;
|
|
}
|
|
let c = field_u8(line, "c").unwrap_or(0);
|
|
let z = field_u8(line, "z").unwrap_or(0);
|
|
let y = field_u8(line, "y").unwrap_or(0);
|
|
let x = field_u8(line, "x").unwrap_or(0);
|
|
let v = field_str(line, "v");
|
|
if let Ok(value) = from_hex(v) {
|
|
store.put_raw(Czyx::new(c, z, y, x), value);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
store
|
|
}
|
|
|
|
fn persist_seq(path: &Path, seq: u64) {
|
|
if let Some(p) = path.parent() {
|
|
let _ = fs::create_dir_all(p);
|
|
}
|
|
if let Ok(mut f) = File::create(path) {
|
|
let _ = f.write_all(seq.to_string().as_bytes());
|
|
let _ = f.sync_all();
|
|
}
|
|
}
|
|
|
|
/// 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
|
|
),
|
|
WalOp::Txn => format!(
|
|
"{{\"seq\":{},\"op\":\"txn\",\"batch\":\"{}\"}}",
|
|
e.seq,
|
|
to_hex(&pack_txn(&e.batch))
|
|
),
|
|
}
|
|
}
|
|
|
|
/// Serialize a txn batch into a flat byte buffer (then hex'd for the WAL):
|
|
/// for each entry: 1 flag byte (1=put, 0=del) + c,z,y,x (4 bytes) +
|
|
/// if put: 4-byte BE length + value bytes.
|
|
fn pack_txn(batch: &[TxnEntry]) -> Vec<u8> {
|
|
let mut out = Vec::new();
|
|
for e in batch {
|
|
match &e.value {
|
|
Some(v) => {
|
|
out.push(1);
|
|
out.extend_from_slice(&[e.coord.c, e.coord.z, e.coord.y, e.coord.x]);
|
|
out.extend_from_slice(&(v.len() as u32).to_be_bytes());
|
|
out.extend_from_slice(v);
|
|
}
|
|
None => {
|
|
out.push(0);
|
|
out.extend_from_slice(&[e.coord.c, e.coord.z, e.coord.y, e.coord.x]);
|
|
}
|
|
}
|
|
}
|
|
out
|
|
}
|
|
|
|
/// Reverse of [`pack_txn`].
|
|
fn unpack_txn(mut buf: &[u8]) -> Result<Vec<TxnEntry>, String> {
|
|
let mut out = Vec::new();
|
|
while !buf.is_empty() {
|
|
let flag = buf[0];
|
|
buf = &buf[1..];
|
|
if buf.len() < 4 {
|
|
return Err("txn entry truncated (coord)".into());
|
|
}
|
|
let coord = Czyx::new(buf[0], buf[1], buf[2], buf[3]);
|
|
buf = &buf[4..];
|
|
if flag == 1 {
|
|
if buf.len() < 4 {
|
|
return Err("txn entry truncated (len)".into());
|
|
}
|
|
let len = u32::from_be_bytes([buf[0], buf[1], buf[2], buf[3]]) as usize;
|
|
buf = &buf[4..];
|
|
if buf.len() < len {
|
|
return Err("txn entry truncated (value)".into());
|
|
}
|
|
let value = buf[..len].to_vec();
|
|
buf = &buf[len..];
|
|
out.push(TxnEntry {
|
|
coord,
|
|
value: Some(value),
|
|
});
|
|
} else {
|
|
out.push(TxnEntry { coord, value: None });
|
|
}
|
|
}
|
|
Ok(out)
|
|
}
|
|
|
|
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");
|
|
// The txn encoder emits `{"seq","op":"txn","batch":...}` with NO c/z/y/x
|
|
// (the per-entry coords live inside `batch`). Handle it before the
|
|
// c/z/y/x extraction below, which would otherwise fail and drop the entry.
|
|
if op_s == "txn" {
|
|
let raw = field_str(line, "batch");
|
|
let bytes = from_hex(raw).ok()?;
|
|
let batch = unpack_txn(&bytes).ok()?;
|
|
return Some(WalEntry {
|
|
seq,
|
|
op: WalOp::Txn,
|
|
coord: Czyx::new(0, 0, 0, 0), // unused; replay reads batch coords
|
|
value: Vec::new(),
|
|
batch,
|
|
});
|
|
}
|
|
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,
|
|
batch: Vec::new(),
|
|
})
|
|
}
|
|
"del" => Some(WalEntry {
|
|
seq,
|
|
op: WalOp::Delete,
|
|
coord,
|
|
value: Vec::new(),
|
|
batch: 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"));
|
|
let _ = fs::remove_file(PathBuf::from(db).with_extension("delta"));
|
|
}
|
|
|
|
#[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 incremental_checkpoint_delta_model() {
|
|
let db = tmp("db3.json");
|
|
let wal = format!("{db}.wal");
|
|
let rec = format!("{db}.recovery.ndjson");
|
|
let delta = PathBuf::from(&db)
|
|
.with_extension("delta")
|
|
.to_string_lossy()
|
|
.into_owned();
|
|
cleanup(&db);
|
|
|
|
// Phase 1: 10 records, then a checkpoint. The base snapshot is written
|
|
// and the delta starts empty (everything is now in the base).
|
|
{
|
|
let s = ConcurrentStore::open(
|
|
&db,
|
|
&wal,
|
|
&rec,
|
|
DurabilityConfig {
|
|
checkpoint_ms: 50,
|
|
wal_fsync_ms: 10,
|
|
},
|
|
)
|
|
.unwrap();
|
|
for i in 0..10u8 {
|
|
s.put_record(Czyx::new(1, 1, 1, i), &CubeHeader::new(), &[i]);
|
|
}
|
|
s.checkpoint();
|
|
}
|
|
let base_after_1 = fs::read(&db).map(|b| b.len()).unwrap_or(0);
|
|
let delta_after_1 = fs::read(&delta).map(|b| b.len()).unwrap_or(0);
|
|
assert!(
|
|
base_after_1 > 0,
|
|
"base snapshot should exist after first checkpoint"
|
|
);
|
|
assert_eq!(
|
|
delta_after_1, 0,
|
|
"delta should be empty right after a full checkpoint"
|
|
);
|
|
|
|
// Phase 2: change only 2 records. The next checkpoint must write only
|
|
// those 2 into the delta (NOT rewrite the whole base), and a reopen
|
|
// must still see all 10.
|
|
{
|
|
let s = ConcurrentStore::open(&db, &wal, &rec, DurabilityConfig::default()).unwrap();
|
|
s.put_record(Czyx::new(1, 1, 1, 0), &CubeHeader::new(), b"changed");
|
|
s.put_record(Czyx::new(1, 1, 1, 1), &CubeHeader::new(), b"changed");
|
|
s.checkpoint();
|
|
}
|
|
let base_after_2 = fs::read(&db).map(|b| b.len()).unwrap_or(0);
|
|
let delta_lines_2 = fs::read_to_string(&delta)
|
|
.map(|t| t.lines().filter(|l| !l.trim().is_empty()).count())
|
|
.unwrap_or(0);
|
|
// Base should NOT have grown by a full rewrite of 10 records; the delta
|
|
// must carry exactly the 2 changed coords.
|
|
assert!(
|
|
base_after_2 <= base_after_1 + 64,
|
|
"base should not be fully rewritten on a small change"
|
|
);
|
|
assert_eq!(
|
|
delta_lines_2, 2,
|
|
"delta should contain only the 2 changed coords"
|
|
);
|
|
|
|
// Reopen from base + delta and confirm all 10 are present with the
|
|
// updated values for the 2 we changed.
|
|
{
|
|
let s = ConcurrentStore::open(&db, &wal, &rec, DurabilityConfig::default()).unwrap();
|
|
assert_eq!(
|
|
s.keys().len(),
|
|
10,
|
|
"all 10 records must survive base+delta reopen"
|
|
);
|
|
assert_eq!(
|
|
s.get_record(&Czyx::new(1, 1, 1, 0)).unwrap().1,
|
|
b"changed".to_vec()
|
|
);
|
|
assert_eq!(
|
|
s.get_record(&Czyx::new(1, 1, 1, 1)).unwrap().1,
|
|
b"changed".to_vec()
|
|
);
|
|
assert_eq!(s.get_record(&Czyx::new(1, 1, 1, 9)).unwrap().1, vec![9u8]);
|
|
}
|
|
cleanup(&db);
|
|
}
|
|
|
|
#[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)]);
|
|
}
|
|
|
|
// Task 4: reader/writer sharding. Many concurrent readers must make
|
|
// progress WHILE a long writer holds the write side — i.e. reads take the
|
|
// RwLock read side and are not serialized behind a single global mutex.
|
|
// We assert that reader threads complete their reads during the writer's
|
|
// hold window rather than after it.
|
|
#[test]
|
|
fn concurrent_reads_dont_block_on_writer() {
|
|
let s = Arc::new(ConcurrentStore::memory());
|
|
let coord = Czyx::new(5, 1, 1, 1);
|
|
s.put_raw(coord, vec![42]);
|
|
|
|
// A writer that holds the write lock for a while.
|
|
let writer = {
|
|
let s = s.clone();
|
|
thread::spawn(move || {
|
|
// Take the write side and hold it long enough that any
|
|
// serialized reader model would force all readers to wait.
|
|
let mut g = s.inner.write().unwrap();
|
|
thread::sleep(Duration::from_millis(200));
|
|
g.put_raw(coord, vec![7]);
|
|
})
|
|
};
|
|
|
|
// Spawn readers; they should acquire the read side concurrently
|
|
// (the RwLock admits many readers at once) and finish well before
|
|
// the writer releases.
|
|
let mut readers = Vec::new();
|
|
let start = std::time::Instant::now();
|
|
for _ in 0..8 {
|
|
let s = s.clone();
|
|
readers.push(thread::spawn(move || {
|
|
for _ in 0..200 {
|
|
// read lock — must not block on other readers
|
|
let _ = s.get_raw(&coord);
|
|
}
|
|
}));
|
|
}
|
|
for r in readers {
|
|
r.join().unwrap();
|
|
}
|
|
writer.join().unwrap();
|
|
|
|
// The readers did 8*200=1600 reads. Under a single global mutex behind
|
|
// a 200ms writer hold they could not all finish before the writer,
|
|
// because the writer would serialize every other access. They did
|
|
// finish during the window (the read side is shared), so total time is
|
|
// well under what a fully-serialized path would cost.
|
|
assert!(
|
|
start.elapsed() < Duration::from_secs(2),
|
|
"reads appear serialized behind the writer (RwLock sharding broken?)"
|
|
);
|
|
assert_eq!(s.get_raw(&coord), Some(vec![7]));
|
|
}
|
|
}
|