Skip to main content

slate_kv/
db.rs

1use slate_kv_core::config::{SchedCfg, OP_DEL, OP_PUT};
2use slate_kv_core::epoch::{EngineState, MountError, SecurityMode};
3use slate_kv_core::gc::SegTable;
4use slate_kv_core::index::Index;
5use slate_kv_core::log::{HeadState, Log};
6use slate_kv_core::metrics::Metrics;
7use slate_kv_core::sched::Scheduler;
8use slate_kv_core::slate::Slate;
9use slate_kv_crypto::sealer::CryptoSealer;
10use std::fs::OpenOptions;
11use std::path::{Path, PathBuf};
12use std::sync::Mutex;
13
14use crate::file_counter::FileCounter;
15use crate::file_flash::FileFlash;
16
17#[derive(Debug)]
18pub enum DbError {
19    Core(slate_kv_core::error::Error),
20    Mount(slate_kv_core::epoch::MountError),
21    Io(std::io::Error),
22    Config(String),
23    InvalidArg(String),
24}
25
26impl std::fmt::Display for DbError {
27    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
28        match self {
29            // The core error types are `no_std` and carry no Display impl, so
30            // format them with Debug rather than duplicating their variants
31            // here — a mirrored list would drift the moment core gains one.
32            DbError::Core(e) => write!(f, "engine error: {e:?}"),
33            DbError::Mount(e) => write!(f, "mount failed: {e:?}"),
34            DbError::Io(e) => write!(f, "i/o error: {e}"),
35            DbError::Config(m) => write!(f, "configuration error: {m}"),
36            DbError::InvalidArg(m) => write!(f, "invalid argument: {m}"),
37        }
38    }
39}
40
41impl std::error::Error for DbError {
42    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
43        // Only `Io` wraps a type that itself implements `Error`; the core
44        // variants are `no_std` enums with no such impl to chain to.
45        match self {
46            DbError::Io(e) => Some(e),
47            _ => None,
48        }
49    }
50}
51
52impl From<slate_kv_core::error::Error> for DbError {
53    fn from(e: slate_kv_core::error::Error) -> Self {
54        DbError::Core(e)
55    }
56}
57impl From<slate_kv_core::epoch::MountError> for DbError {
58    fn from(e: slate_kv_core::epoch::MountError) -> Self {
59        DbError::Mount(e)
60    }
61}
62impl From<std::io::Error> for DbError {
63    fn from(e: std::io::Error) -> Self {
64        DbError::Io(e)
65    }
66}
67impl From<String> for DbError {
68    fn from(e: String) -> Self {
69        DbError::Config(e)
70    }
71}
72impl From<&str> for DbError {
73    fn from(e: &str) -> Self {
74        DbError::Config(e.to_string())
75    }
76}
77
78pub enum KeySource {
79    Bytes([u8; 32]),
80    File(PathBuf),
81    Env(&'static str),
82}
83
84#[derive(Clone, Copy, Debug, PartialEq, Eq)]
85pub enum Profile {
86    Esp32,
87    Pi,
88}
89
90/// Tuning knobs for [`Db::open`].
91///
92/// `Clone` so a caller can open the same configuration twice — reopening a
93/// database after a drop is the normal way to test durability, and without
94/// `Clone` every such call site has to rebuild the struct field by field.
95#[derive(Clone, Debug)]
96pub struct Options {
97    pub capacity: u32,
98    pub b_commit: u32,
99    pub auto_b: bool,
100    pub staleness_budget_ms: u32,
101    pub n_keys: usize,
102    pub profile: Profile,
103    pub durability: crate::file_flash::Durability,
104}
105
106impl Default for Options {
107    fn default() -> Self {
108        Self {
109            capacity: 4 * 1024 * 1024,
110            b_commit: 8,
111            auto_b: true,
112            staleness_budget_ms: 1000,
113            n_keys: 8192,
114            profile: Profile::Pi,
115            durability: crate::file_flash::Durability::Full,
116        }
117    }
118}
119
120pub struct ScrubReport {
121    pub errors_found: u32,
122    pub errors_fixed: u32,
123}
124
125/// What one [`Db::open`] actually cost.
126///
127/// Mount is claimed to be an O(1) freshness check plus O(Θ) tail replay, and
128/// wall-clock alone cannot distinguish those two terms from a full-volume scan
129/// that happens to be fast. These fields separate them: `replay_from` is where
130/// the checkpoint said the tail begins, `records_replayed` is how many committed
131/// records the tail scan actually AEAD-opened, and the flash counters are read at
132/// the HAL boundary, so they include the checkpoint read as well as the scan.
133#[derive(Debug, Default, Clone, Copy)]
134pub struct MountReport {
135    /// Whether a checkpoint was found (false = volume was formatted by this open).
136    pub had_checkpoint: bool,
137    /// First log byte the tail scan started from.
138    pub replay_from: u32,
139    /// Log head after replay.
140    pub head_pos: u32,
141    /// Bytes of log the tail scan walked.
142    pub scan_bytes: u32,
143    /// Committed records replayed into the index.
144    pub records_replayed: u64,
145    /// Serialized index bytes loaded from the checkpoint (0 if none).
146    pub ckpt_index_bytes: usize,
147    /// Live keys in the index once mount finished.
148    pub keys: usize,
149    /// Flash reads/programs/erases performed during this mount.
150    pub flash: crate::file_flash::FlashCounters,
151    /// Index slots the arena was sized to.
152    pub index_slots: usize,
153    /// Checkpoint slots that read back and verified (bounded by `CKPT_SLOTS`).
154    pub ckpt_slots_verified: u8,
155    /// Flash counters as of just after the checkpoint load, before tail replay.
156    ///
157    /// Subtracting this from [`Self::flash`] splits mount's read cost into its
158    /// O(1) checkpoint term and its O(tail) replay term, which is the whole point
159    /// of the claim being tested.
160    pub flash_after_ckpt: crate::file_flash::FlashCounters,
161    /// Full-key verification reads replay issued to resolve fingerprint
162    /// collisions while rebuilding the index.
163    ///
164    /// Replay must AEAD-open a candidate record to compare the exact key before
165    /// overwriting its slot, so this term grows with how many keys are already
166    /// live — not with the volume of the log. Reported separately so it is not
167    /// mistaken for either.
168    pub key_verify_calls: u64,
169}
170
171// Opaque stats struct for passing metrics
172#[derive(Default, Clone, Debug)]
173pub struct Stats {
174    pub commits: u64,
175    pub wakes: u64,
176    /// Record bytes the application asked to store (framing + key + value).
177    pub user_bytes: u64,
178    /// Record bytes rewritten by GC relocation.
179    pub gc_bytes: u64,
180    /// Parity pages programmed.
181    pub parity_bytes: u64,
182    /// Commit-marker pages programmed.
183    pub marker_bytes: u64,
184    /// Checkpoint pages programmed by epoch seals.
185    pub ckpt_bytes: u64,
186    pub erases: u64,
187    /// Segments in the table (`0` when unmanaged).
188    pub segments: u32,
189    /// Segments currently reclaimable-by-GC (`Sealed`).
190    pub segments_sealed: u32,
191    /// Segments currently free.
192    pub segments_free: u32,
193    /// Reclaim watermark: a sealed segment is eligible when its allocation
194    /// number is below this.
195    pub ckpt_seg_seq: u64,
196    /// Newest segment allocation number handed out.
197    pub cur_seg_seq: u64,
198    /// Records visited by compaction scans.
199    pub gc_scanned: u64,
200    /// Records compaction found live and relocated.
201    pub gc_relocated: u64,
202    /// Records compaction could not decrypt (data loss if nonzero).
203    pub gc_open_failed: u64,
204    /// Segments reclaimed.
205    pub gc_segments_freed: u64,
206    /// Hot log head offset.
207    pub hot_head: u32,
208    /// Cold log head offset.
209    pub cold_head: u32,
210    /// First offset past the last segment.
211    pub seg_end: u32,
212}
213
214impl Stats {
215    /// Total bytes programmed to flash across every bucket.
216    pub fn flash_bytes(&self) -> u64 {
217        self.user_bytes + self.gc_bytes + self.parity_bytes + self.marker_bytes + self.ckpt_bytes
218    }
219
220    /// Write amplification: flash bytes programmed per byte of user data.
221    ///
222    /// `None` when nothing has been written yet. A workload that has not been
223    /// measured and one with no overhead are different claims, so this must not
224    /// silently report 1.0 for the former.
225    pub fn write_amplification(&self) -> Option<f64> {
226        if self.user_bytes == 0 {
227            None
228        } else {
229            Some(self.flash_bytes() as f64 / self.user_bytes as f64)
230        }
231    }
232}
233
234// Box pointers to free on drop
235struct Buffers {
236    hot: *mut [u8],
237    cold: *mut [u8],
238    index: *mut [u32],
239    ckpt: *mut [u8],
240}
241
242// SAFETY: Buffers are heap allocated arrays, and pointers are exclusively owned by Db/OwnedEngine.
243unsafe impl Send for Buffers {}
244unsafe impl Sync for Buffers {}
245
246impl Drop for Buffers {
247    fn drop(&mut self) {
248        // SAFETY: Pointers were allocated via Box::into_raw in Db::open and never freed elsewhere.
249        unsafe {
250            let _ = Box::from_raw(self.hot);
251            let _ = Box::from_raw(self.cold);
252            let _ = Box::from_raw(self.index);
253            let _ = Box::from_raw(self.ckpt);
254        }
255    }
256}
257
258struct OwnedEngine {
259    slate: slate_kv_core::slate::Slate<
260        'static,
261        slate_kv_hal::BlockingFlash<FileFlash>,
262        slate_kv_hal::BlockingCounter<FileCounter>,
263        CryptoSealer,
264    >,
265    #[allow(dead_code)]
266    bufs: Buffers, // Dropped after slate because of declaration order
267}
268
269// SAFETY: OwnedEngine encapsulates exclusively owned data and is protected by Mutex in Db.
270unsafe impl Send for OwnedEngine {}
271
272pub struct Db {
273    inner: Mutex<OwnedEngine>,
274    mount: MountReport,
275}
276
277impl Drop for Db {
278    /// Best-effort flush of the pending batch on a clean close.
279    ///
280    /// Records sit in the in-memory batch until a commit marker is written, so
281    /// without this a `drop` after a successful `put` loses up to `b_max`
282    /// records — a surprising result for callers who never crashed. This is a
283    /// convenience, not a durability guarantee: the error is deliberately
284    /// swallowed because `drop` cannot report one, so any caller that must know
285    /// its data is durable has to call [`Db::commit`] and check the result.
286    fn drop(&mut self) {
287        if let Ok(mut inner) = self.inner.lock() {
288            let _ = inner.slate.commit();
289        }
290    }
291}
292
293impl Db {
294    #[allow(clippy::collapsible_if)]
295    pub fn open(path: &Path, key: KeySource, opts: Options) -> Result<Self, DbError> {
296        let root_key = match key {
297            KeySource::Bytes(k) => k,
298            KeySource::File(p) => {
299                let mut k = [0u8; 32];
300                let b = std::fs::read(&p)?;
301                if b.len() < 32 {
302                    return Err(DbError::Config("Key file too short".into()));
303                }
304                k.copy_from_slice(&b[0..32]);
305                k
306            }
307            KeySource::Env(var) => {
308                let mut k = [0u8; 32];
309                let val = std::env::var(var).map_err(|e| DbError::Config(e.to_string()))?;
310                let b = val.as_bytes();
311                if b.len() < 32 {
312                    return Err(DbError::Config("Env key too short".into()));
313                }
314                k.copy_from_slice(&b[0..32]);
315                k
316            }
317        };
318
319        use slate_kv_core::log::Sealer;
320        std::fs::create_dir_all(path)?;
321
322        let flash_path = path.join("data.bin");
323        let counter_path = path.join("counter.bin");
324
325        let flash_file = OpenOptions::new()
326            .read(true)
327            .write(true)
328            .create(true)
329            .truncate(false)
330            .open(flash_path)?;
331        let counter_file = OpenOptions::new()
332            .read(true)
333            .write(true)
334            .create(true)
335            .truncate(false)
336            .open(counter_path)?;
337
338        let mut flash = FileFlash::new(flash_file, opts.capacity, 256, 4096, opts.durability)
339            .map_err(|e| DbError::Config(e.to_string()))?;
340        let device_key = slate_kv_crypto::keys::DeviceKey(root_key);
341        let keyset = slate_kv_crypto::keys::KeySet::derive(&device_key, 1);
342        let k_ctr = keyset.k_ctr;
343
344        let mut counter = FileCounter::new(counter_file, k_ctr, u64::MAX).map_err(|e| match e {
345            // FormatError is only ever returned when a counter slot fails HMAC
346            // verification, i.e. it is a tamper signal, not a config problem —
347            // it must stay distinguishable from operational errors (RULES.md).
348            crate::file_counter::FileCounterError::FormatError => {
349                DbError::Mount(MountError::Tampered)
350            }
351            crate::file_counter::FileCounterError::Io(err) => DbError::Io(err),
352            crate::file_counter::FileCounterError::Exhausted => {
353                DbError::Config("monotonic counter budget exhausted".to_string())
354            }
355        })?;
356
357        let mut sealer = CryptoSealer::new(keyset);
358
359        // Allocate buffers
360        let hot_box = vec![0u8; 65536].into_boxed_slice();
361        let cold_box = vec![0u8; 65536].into_boxed_slice();
362        // Size the cuckoo table for the requested key count, then reject up
363        // front anything whose serialized form would not fit in a checkpoint
364        // slot. The whole index goes into one checkpoint, so exceeding
365        // MAX_CKPT_LEN does not degrade gracefully: every epoch seal would fail
366        // and the database would run without checkpoints entirely.
367        let n_buckets = (opts.n_keys.max(2048) as f64 / 0.95) as usize;
368        let index_slots_count = n_buckets.next_power_of_two() * slate_kv_core::config::BUCKET_SLOTS;
369        let max_slots = slate_kv_core::config::max_index_slots();
370        if index_slots_count > max_slots {
371            return Err(DbError::Config(format!(
372                "n_keys={} needs {} index slots but the checkpoint format allows at most {} \
373                 (MAX_CKPT_LEN={}); reduce n_keys to at most {}",
374                opts.n_keys,
375                index_slots_count,
376                max_slots,
377                slate_kv_core::config::MAX_CKPT_LEN,
378                max_slots / slate_kv_core::config::BUCKET_SLOTS * 95 / 100,
379            )));
380        }
381        let index_box = vec![0u32; index_slots_count].into_boxed_slice();
382        let index_len = index_box.len();
383
384        // The checkpoint buffer must hold header + serialized index + AEAD tag.
385        // Sizing it from the same helper the format uses keeps the two in step;
386        // the previous `index_len + 1024` under-counted because the index costs
387        // 4 bytes per slot, not 1.
388        let ckpt_box =
389            vec![0u8; slate_kv_core::config::ckpt_len_for_slots(index_len)].into_boxed_slice();
390        let ckpt_ptr = Box::into_raw(ckpt_box);
391        let ckpt_slice = unsafe { &mut *ckpt_ptr };
392
393        // Mount. `replay_from` is where tail replay begins: the log head as of
394        // the newest checkpoint, or the base of the log for a freshly formatted
395        // volume. Everything below it is already captured in the checkpointed
396        // index.
397        let data_base = slate_kv_core::config::data_base_offset(4096);
398        let mut had_checkpoint = true;
399        let mut ckpt_slots_verified = 0u8;
400        let (engine_state, plain_len, replay_from, ckpt_seg_seq) =
401            match slate_kv_core::epoch::mount(&mut flash, &mut counter, &mut sealer, ckpt_slice) {
402                Ok(mi) => {
403                    ckpt_slots_verified = mi.ckpt_slots_verified;
404                    (
405                        mi.state,
406                        mi.plain_len,
407                        mi.ckpt_write_offset.max(data_base),
408                        mi.ckpt_seg_seq,
409                    )
410                }
411                Err(MountError::FormatError) => {
412                    had_checkpoint = false;
413                    // Formatting new
414                    let mut st = EngineState {
415                        epoch: 1,
416                        next_seq: 1,
417                        acked_seq: 0,
418                        d_ckpt: [0u8; 32],
419                        chain: slate_kv_core::chain::Chain::anchor(1, &[0u8; 32]),
420                        records_in_epoch: 0,
421                        security_mode: SecurityMode::BestEffortRollback,
422                        active_ckpt_slot: 0,
423                    };
424                    // The genesis checkpoint records the head at `data_base`,
425                    // where the log actually begins. Recording 0 here would make
426                    // the next mount replay the reserved checkpoint region as if
427                    // it were log data.
428                    let mut page_buf = [0u8; 512];
429                    slate_kv_core::epoch::seal_epoch(
430                        &mut st,
431                        &mut flash,
432                        &mut counter,
433                        &mut sealer,
434                        1,
435                        data_base,
436                        0,
437                        ckpt_slice,
438                        0,
439                        &mut page_buf,
440                    )?;
441                    (st, 0, data_base, 1)
442                }
443                Err(e) => return Err(e.into()),
444            };
445
446        let flash_after_ckpt = flash.counters();
447        sealer.roll_epoch(engine_state.epoch);
448
449        let hot_ptr = Box::into_raw(hot_box);
450        let cold_ptr = Box::into_raw(cold_box);
451        let index_ptr = Box::into_raw(index_box);
452
453        let bufs = Buffers {
454            hot: hot_ptr,
455            cold: cold_ptr,
456            index: index_ptr,
457            ckpt: ckpt_ptr,
458        };
459
460        // SAFETY: Pointers are valid for the lifetime of Db, ensuring Slate<'static> constraint.
461        let hot_slice = unsafe { &mut *hot_ptr };
462        let cold_slice = unsafe { &mut *cold_ptr };
463        let index_slice = unsafe { &mut *index_ptr };
464
465        // Both logs start above the reserved checkpoint region. Starting at 0
466        // would put the first records on top of the live checkpoint slots: the
467        // hot head is repositioned by recovery below, but the cold head is not,
468        // so a cold write would program still-live checkpoint pages.
469        let data_base = slate_kv_core::config::data_base_offset(4096);
470        let log_hot = Log::new(
471            hot_slice,
472            HeadState {
473                seg_seq: 1,
474                write_offset: data_base,
475                block_idx: 0,
476            },
477        );
478        let log_cold = Log::new(
479            cold_slice,
480            HeadState {
481                seg_seq: 1,
482                write_offset: data_base,
483                block_idx: 0,
484            },
485        );
486
487        let sched_cfg = SchedCfg {
488            auto_b: opts.auto_b,
489            fixed_cost_uj: match opts.profile {
490                Profile::Esp32 => 400,
491                Profile::Pi => 150,
492            },
493            staleness_budget_ms: opts.staleness_budget_ms,
494            deadline_ms: match opts.profile {
495                Profile::Esp32 => 1000,
496                Profile::Pi => 500,
497            },
498            b_min: 1,
499            b_max: 128,
500            b_commit: opts.b_commit,
501        };
502
503        let rng_seed = engine_state.epoch.max(1) ^ 42;
504        let mut slate = Slate {
505            flash: slate_kv_hal::BlockingFlash(flash),
506            counter: slate_kv_hal::BlockingCounter(counter),
507            sealer,
508            engine: engine_state,
509            log_hot,
510            log_cold,
511            index: Index::new(index_slice, index_len / 4),
512            segs: SegTable::with_base(
513                data_base,
514                slate_kv_core::gc::segments_in(data_base, opts.capacity),
515            ),
516            // Seeded from the checkpoint so GC's reclaim watermark survives a
517            // remount; starting at 0 would block victim selection until the
518            // next epoch seal.
519            ckpt_seg_seq,
520            sched: Scheduler::new(sched_cfg),
521            metrics: Metrics::default(),
522            ckpt_buf: ckpt_slice,
523            rng: slate_kv_core::index::XorShift64::new(rng_seed),
524            scratch_buf: slate_kv_core::slate::ScratchWorkspace::new(),
525        };
526
527        if plain_len > 0 {
528            slate.index.deserialize(
529                &slate.ckpt_buf[slate_kv_core::checkpoint::CKPT_HDR_LEN
530                    ..slate_kv_core::checkpoint::CKPT_HDR_LEN + plain_len],
531            );
532        }
533
534        let mut index_upsert_error = false;
535        let mut key_verify_calls: u64 = 0;
536        let mut rng = slate_kv_core::index::XorShift64::new(42);
537        let mut workspace = Box::new(slate_kv_core::recover::RecoverWorkspace::new());
538        // Replay only the tail: records written after the newest checkpoint.
539        // The checkpointed index already accounts for everything below
540        // `replay_from`, so rescanning from the base of the log would both cost
541        // O(log length) and fail to validate — those older batches' commit
542        // markers belong to earlier epochs.
543        let rec_info = slate_kv_core::recover::recover(
544            &mut slate.flash,
545            &mut slate.sealer,
546            &mut slate.engine.chain,
547            slate.engine.epoch,
548            replay_from,
549            &mut workspace,
550            |flash, sealer, _seq, off, op, key| {
551                // Replay in seq order, deduplicating by the full key so repeated
552                // Puts/Deletes to the same key collapse to a single live slot
553                // rather than filling the table with superseded entries.
554                if op == slate_kv_core::config::OP_PUT {
555                    let res = slate.index.upsert(key, off, &mut rng, |cand_off| {
556                        key_verify_calls += 1;
557                        slate_kv_core::recover::record_key_eq(flash, sealer, cand_off, key)
558                    });
559                    if res.is_err() {
560                        index_upsert_error = true;
561                    }
562                } else if op == slate_kv_core::config::OP_DEL {
563                    slate.index.remove(key, |cand_off| {
564                        key_verify_calls += 1;
565                        slate_kv_core::recover::record_key_eq(flash, sealer, cand_off, key)
566                    });
567                }
568            },
569        )
570        .map_err(|e| DbError::Config(format!("{:?}", e)))?;
571
572        // Never let the head land below where the checkpoint said the log
573        // already reached: an empty tail returns `head_pos == replay_from`, and
574        // anything lower would overwrite durable records.
575        slate.log_hot.head.write_offset = rec_info.head_pos.max(replay_from);
576        slate.log_cold.head.write_offset = slate.log_hot.head.write_offset;
577        // `mount` seeded next_seq from the checkpoint (= ckpt.seq). Never let the
578        // tail replay regress the sequence counter below it — a crash immediately
579        // after a checkpoint leaves committed_upto == 0 with a non-trivial
580        // checkpoint seq, and reusing those seq numbers would break AEAD nonce
581        // uniqueness and the total order.
582        let mount_next = slate.engine.next_seq;
583        slate.engine.next_seq = (rec_info.committed_upto + 1).max(mount_next);
584        slate.engine.acked_seq = rec_info.committed_upto.max(mount_next.saturating_sub(1));
585
586        if index_upsert_error {
587            return Err("Index capacity exceeded during recovery".into());
588        }
589
590        let mount = MountReport {
591            had_checkpoint,
592            replay_from,
593            head_pos: rec_info.head_pos,
594            scan_bytes: rec_info.scan_bytes,
595            records_replayed: rec_info.records_applied,
596            ckpt_index_bytes: plain_len,
597            keys: slate.index.len(),
598            flash: slate.flash.0.counters(),
599            index_slots: index_len,
600            ckpt_slots_verified,
601            flash_after_ckpt,
602            key_verify_calls,
603        };
604
605        Ok(Db {
606            inner: Mutex::new(OwnedEngine { slate, bufs }),
607            mount,
608        })
609    }
610
611    /// What the [`Db::open`] that produced this handle cost.
612    ///
613    /// Captured at open time rather than recomputed: the counters it reports are
614    /// cumulative on the flash handle, so reading them later would fold in every
615    /// put and commit since.
616    pub fn mount_report(&self) -> MountReport {
617        self.mount
618    }
619
620    pub fn put(&self, key: &[u8], val: &[u8]) -> Result<(), DbError> {
621        if key.len() > slate_kv_core::config::MAX_KEY_LEN
622            || val.len() > slate_kv_core::config::MAX_VAL_LEN
623        {
624            return Err(DbError::InvalidArg("key or value too large".into()));
625        }
626        let mut inner = self.inner.lock().unwrap();
627        let OwnedEngine { slate, .. } = &mut *inner;
628        let now_ms = std::time::SystemTime::now()
629            .duration_since(std::time::UNIX_EPOCH)
630            .unwrap()
631            .as_millis() as u64;
632
633        let offset = slate.append_hot(OP_PUT, key, val)?;
634        slate.index_update_offset(key, offset)?;
635        // User bytes are counted inside `Slate::append_hot` so the no_std targets
636        // measure write amplification too. Counting them again here double-counted
637        // every record on this path, which understated WA as
638        // `WA_reported = (WA_true + 1) / 2`.
639        if slate.sched.on_append(now_ms) {
640            slate.commit()?;
641        }
642        Ok(())
643    }
644
645    pub fn put_durable(&self, key: &[u8], val: &[u8]) -> Result<(), DbError> {
646        if key.len() > slate_kv_core::config::MAX_KEY_LEN
647            || val.len() > slate_kv_core::config::MAX_VAL_LEN
648        {
649            return Err(DbError::InvalidArg("key or value too large".into()));
650        }
651        self.put(key, val)?;
652        self.commit()
653    }
654
655    #[allow(clippy::collapsible_if)]
656    pub fn get(&self, key: &[u8]) -> Result<Option<Vec<u8>>, DbError> {
657        if key.len() > slate_kv_core::config::MAX_KEY_LEN {
658            return Err(DbError::InvalidArg("key too large".into()));
659        }
660        let mut inner = self.inner.lock().unwrap();
661        let mut cbuf = slate_kv_core::index::CandidateBuf::new();
662        inner.slate.index.candidates(key, &mut cbuf);
663
664        let mut best_val: Option<Vec<u8>> = None;
665        let mut best_seq = 0;
666
667        for &off in cbuf.as_slice() {
668            let mut rec_bytes = None;
669
670            // Check hot log batch
671            let hot_write_off = inner.slate.log_hot.head.write_offset;
672            let hot_data = inner.slate.log_hot.batch.data();
673            if off >= hot_write_off && off < hot_write_off + hot_data.len() as u32 {
674                let rec_off = (off - hot_write_off) as usize;
675                if rec_off + slate_kv_core::config::REC_HDR_LEN <= hot_data.len() {
676                    let mut hdr_bytes = [0u8; slate_kv_core::config::REC_HDR_LEN];
677                    hdr_bytes.copy_from_slice(
678                        &hot_data[rec_off..rec_off + slate_kv_core::config::REC_HDR_LEN],
679                    );
680                    if let Ok(hdr) = slate_kv_core::record::RecordHeader::decode(&hdr_bytes) {
681                        let total_len = slate_kv_core::config::REC_OVERHEAD
682                            + hdr.klen as usize
683                            + hdr.vlen as usize;
684                        if rec_off + total_len <= hot_data.len() {
685                            rec_bytes =
686                                Some((hdr_bytes, hot_data[rec_off..rec_off + total_len].to_vec()));
687                        }
688                    }
689                }
690            }
691
692            // Check cold log batch
693            let cold_write_off = inner.slate.log_cold.head.write_offset;
694            let cold_data = inner.slate.log_cold.batch.data();
695            if rec_bytes.is_none()
696                && off >= cold_write_off
697                && off < cold_write_off + cold_data.len() as u32
698            {
699                let rec_off = (off - cold_write_off) as usize;
700                if rec_off + slate_kv_core::config::REC_HDR_LEN <= cold_data.len() {
701                    let mut hdr_bytes = [0u8; slate_kv_core::config::REC_HDR_LEN];
702                    hdr_bytes.copy_from_slice(
703                        &cold_data[rec_off..rec_off + slate_kv_core::config::REC_HDR_LEN],
704                    );
705                    if let Ok(hdr) = slate_kv_core::record::RecordHeader::decode(&hdr_bytes) {
706                        let total_len = slate_kv_core::config::REC_OVERHEAD
707                            + hdr.klen as usize
708                            + hdr.vlen as usize;
709                        if rec_off + total_len <= cold_data.len() {
710                            rec_bytes =
711                                Some((hdr_bytes, cold_data[rec_off..rec_off + total_len].to_vec()));
712                        }
713                    }
714                }
715            }
716
717            // Read from flash if not in batch
718            if rec_bytes.is_none() {
719                use slate_kv_hal::Flash;
720                let mut hdr_bytes = [0u8; slate_kv_core::config::REC_HDR_LEN];
721                if inner.slate.flash.read(off, &mut hdr_bytes).is_ok() {
722                    if let Ok(hdr) = slate_kv_core::record::RecordHeader::decode(&hdr_bytes) {
723                        let total_len = slate_kv_core::config::REC_OVERHEAD
724                            + hdr.klen as usize
725                            + hdr.vlen as usize;
726                        let mut rb = vec![0u8; total_len];
727                        if inner.slate.flash.read(off, &mut rb).is_ok() {
728                            rec_bytes = Some((hdr_bytes, rb));
729                        }
730                    }
731                }
732            }
733
734            if let Some((hdr_bytes, rb)) = rec_bytes {
735                if let Ok(hdr) = slate_kv_core::record::RecordHeader::decode(&hdr_bytes) {
736                    let mut plain_out = vec![0u8; rb.len() - 16];
737                    use slate_kv_core::log::Sealer;
738                    if inner
739                        .slate
740                        .sealer
741                        .open_record(
742                            &hdr_bytes,
743                            &rb[slate_kv_core::config::REC_HDR_LEN..],
744                            &mut plain_out,
745                        )
746                        .is_ok()
747                    {
748                        if plain_out[..hdr.klen as usize] == *key {
749                            if hdr.seq >= best_seq {
750                                best_seq = hdr.seq;
751                                if hdr.op == slate_kv_core::config::OP_PUT {
752                                    let val_start = hdr.klen as usize;
753                                    best_val = Some(
754                                        plain_out[val_start..val_start + hdr.vlen as usize]
755                                            .to_vec(),
756                                    );
757                                } else {
758                                    best_val = None;
759                                }
760                            }
761                        }
762                    }
763                }
764            }
765        }
766
767        Ok(best_val)
768    }
769
770    /// Append a tombstone and make it durable before returning.
771    ///
772    /// Mirrors [`Db::put_durable`]: the tombstone is acknowledged when its
773    /// commit marker is on flash, so a power failure after this call returns
774    /// cannot resurrect the key.
775    ///
776    /// This body was previously byte-for-byte identical to [`Db::delete`] —
777    /// it committed only when the scheduler happened to fire, so the
778    /// `_durable` suffix promised a guarantee the method did not provide and
779    /// a delete could be lost across a crash.
780    pub fn delete_durable(&self, key: &[u8]) -> Result<(), DbError> {
781        if key.len() > slate_kv_core::config::MAX_KEY_LEN {
782            return Err(DbError::InvalidArg("key too large".into()));
783        }
784        self.delete(key)?;
785        self.commit()
786    }
787
788    pub fn delete(&self, key: &[u8]) -> Result<(), DbError> {
789        if key.len() > slate_kv_core::config::MAX_KEY_LEN {
790            return Err(DbError::InvalidArg("key too large".into()));
791        }
792        let mut inner = self.inner.lock().unwrap();
793        let OwnedEngine { slate, .. } = &mut *inner;
794        let now_ms = std::time::SystemTime::now()
795            .duration_since(std::time::UNIX_EPOCH)
796            .unwrap()
797            .as_millis() as u64;
798
799        let _ = slate.append_hot(OP_DEL, key, &[])?;
800
801        slate.index_remove_key(key);
802        // Counted inside `Slate::append_hot` (see the note in `put`); counting it
803        // again here double-counted every record on this path.
804        if slate.sched.on_append(now_ms) {
805            slate.commit()?;
806        }
807        Ok(())
808    }
809
810    pub fn commit(&self) -> Result<(), DbError> {
811        let mut inner = self.inner.lock().unwrap();
812        inner.slate.commit()?;
813        Ok(())
814    }
815
816    pub fn compact(&self) -> Result<(), DbError> {
817        let mut inner = self.inner.lock().unwrap();
818        inner.slate.compact()?;
819        Ok(())
820    }
821
822    /// Seals the current epoch now, writing a checkpoint.
823    ///
824    /// Normally this happens on its own every `THETA` records. Exposing it lets a
825    /// caller place a checkpoint at a known point in the log, which is what makes
826    /// the length of the replay tail a controllable independent variable rather
827    /// than whatever Θ happened to leave behind.
828    pub fn seal_epoch(&self) -> Result<(), DbError> {
829        let mut inner = self.inner.lock().unwrap();
830        inner.slate.commit()?;
831        inner.slate.seal_epoch_now()?;
832        Ok(())
833    }
834
835    pub fn scrub(&self) -> Result<ScrubReport, DbError> {
836        Ok(ScrubReport {
837            errors_found: 0,
838            errors_fixed: 0,
839        })
840    }
841
842    pub fn security_mode(&self) -> SecurityMode {
843        let inner = self.inner.lock().unwrap();
844        inner.slate.engine.security_mode
845    }
846
847    pub fn acked_seq(&self) -> u64 {
848        self.inner.lock().unwrap().slate.engine.acked_seq
849    }
850
851    pub fn next_seq(&self) -> u64 {
852        self.inner.lock().unwrap().slate.engine.next_seq
853    }
854
855    pub fn epoch(&self) -> u64 {
856        self.inner.lock().unwrap().slate.engine.epoch
857    }
858
859    pub fn len(&self) -> usize {
860        self.inner.lock().unwrap().slate.index.len()
861    }
862
863    pub fn is_empty(&self) -> bool {
864        self.len() == 0
865    }
866
867    pub fn stats(&self) -> Stats {
868        let inner = self.inner.lock().unwrap();
869        let m = &inner.slate.metrics;
870        Stats {
871            commits: m.commits,
872            wakes: m.wakes,
873            user_bytes: m.user_bytes,
874            gc_bytes: m.gc_bytes,
875            parity_bytes: m.parity_bytes,
876            marker_bytes: m.marker_bytes,
877            ckpt_bytes: m.ckpt_bytes,
878            erases: m.erases,
879            segments: inner.slate.segs.num_segments,
880            segments_sealed: inner
881                .slate
882                .segs
883                .count_in_state(slate_kv_core::gc::SegState::Sealed),
884            segments_free: inner.slate.segs.free_count(),
885            ckpt_seg_seq: inner.slate.ckpt_seg_seq,
886            cur_seg_seq: inner.slate.segs.current_seg_seq(),
887            gc_scanned: m.gc_scanned,
888            gc_relocated: m.gc_relocated,
889            gc_open_failed: m.gc_open_failed,
890            gc_segments_freed: m.gc_segments_freed,
891            hot_head: inner.slate.log_hot.head.write_offset,
892            cold_head: inner.slate.log_cold.head.write_offset,
893            seg_end: inner.slate.segs.end_addr(),
894        }
895    }
896}