Skip to main content

velesdb_core/storage/
log_payload.rs

1//! Log-structured payload storage with snapshot support.
2//!
3//! Stores payloads in an append-only log file with an in-memory index.
4//! Supports periodic snapshots for fast cold-start recovery.
5//!
6//! ## WAL Entry Formats
7//!
8//! **CRC32-protected (current, markers 0xC3/0xC4):**
9//! ```text
10//! Store:  [0xC3: 1B] [id: 8B LE] [len: 4B LE] [payload: len B] [crc32: 4B LE]
11//! Delete: [0xC4: 1B] [id: 8B LE] [crc32: 4B LE]
12//! ```
13//!
14//! **Legacy (markers 1/2, read-only for backward compatibility):**
15//! ```text
16//! Store:  [1: 1B] [id: 8B LE] [len: 4B LE] [payload: len B]
17//! Delete: [2: 1B] [id: 8B LE]
18//! ```
19//!
20//! CRC32 covers all bytes preceding the CRC field. On CRC mismatch during
21//! replay, the corrupted entry is skipped and a warning is logged.
22//!
23//! Snapshot format and I/O are handled by the [`super::snapshot`] module.
24
25use super::log_payload_io::{compute_delete_crc, write_store_record, CRC_DELETE_MARKER};
26use super::snapshot;
27use super::traits::PayloadStorage;
28
29// Re-export snapshot items for backward compatibility with existing imports
30#[allow(unused_imports)] // SNAPSHOT_MAGIC/VERSION used only in test modules
31pub(crate) use snapshot::{crc32_hash, SNAPSHOT_MAGIC, SNAPSHOT_VERSION};
32
33use parking_lot::RwLock;
34use rustc_hash::FxHashMap;
35use std::fs::{File, OpenOptions};
36use std::io::{self, BufReader, Seek, SeekFrom, Write};
37use std::path::{Path, PathBuf};
38
39/// Controls how payload WAL writes are synced to disk.
40///
41/// - `Fsync` (default): `flush()` + `sync_all()` — full durability, safe against power loss.
42/// - `FlushOnly`: `flush()` only — data reaches OS kernel but may be lost on power failure.
43/// - `None`: No sync — maximum throughput for bulk imports where data can be re-derived.
44#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
45#[non_exhaustive]
46pub enum DurabilityMode {
47    /// Full durability: flush buffer + fsync to disk.
48    #[default]
49    Fsync,
50    /// Flush buffer to OS only (no fsync). Faster but not power-loss safe.
51    FlushOnly,
52    /// No sync at all. Maximum throughput for bulk imports.
53    None,
54}
55
56/// Log-structured payload storage with snapshot support.
57///
58/// Stores payloads in an append-only log file with an in-memory index.
59/// Supports periodic snapshots for O(1) cold-start recovery instead of O(N) WAL replay.
60#[allow(clippy::module_name_repetitions)]
61pub struct LogPayloadStorage {
62    /// Directory path for storage files
63    path: PathBuf,
64    /// In-memory index: ID -> Offset of length field in WAL
65    index: RwLock<FxHashMap<u64, u64>>,
66    /// Write-Ahead Log writer (append-only)
67    wal: RwLock<io::BufWriter<File>>,
68    /// Independent file handle for reading, protected for seeking
69    reader: RwLock<File>,
70    /// WAL position at last snapshot (0 = no snapshot)
71    last_snapshot_wal_pos: RwLock<u64>,
72    /// Durability mode for WAL writes
73    durability: DurabilityMode,
74    /// Tracked WAL write position (avoids flush+metadata syscall for `DurabilityMode::None`)
75    write_offset: RwLock<u64>,
76}
77
78use super::wal_entry::WalEntry;
79
80impl LogPayloadStorage {
81    /// Creates a new `LogPayloadStorage` with the default durability mode (`Fsync`).
82    ///
83    /// If a snapshot file exists and is valid, loads from snapshot and replays
84    /// only the WAL delta for fast startup. Otherwise, falls back to full WAL replay.
85    ///
86    /// # Errors
87    ///
88    /// Returns an error if file operations fail.
89    pub fn new<P: AsRef<Path>>(path: P) -> io::Result<Self> {
90        Self::new_with_durability(path, DurabilityMode::default())
91    }
92
93    /// Creates a new `LogPayloadStorage` with the specified durability mode.
94    ///
95    /// See [`DurabilityMode`] for available modes and their trade-offs.
96    ///
97    /// # Errors
98    ///
99    /// Returns an error if file operations fail.
100    pub fn new_with_durability<P: AsRef<Path>>(
101        path: P,
102        durability: DurabilityMode,
103    ) -> io::Result<Self> {
104        let path = path.as_ref().to_path_buf();
105        std::fs::create_dir_all(&path)?;
106        let log_path = path.join("payloads.log");
107
108        let wal = Self::open_wal_writer(&log_path)?;
109        let (reader, wal_len) = Self::open_wal_reader(&log_path)?;
110        let (index, last_snapshot_wal_pos) = Self::load_or_replay_index(&path, &log_path, wal_len)?;
111
112        Ok(Self {
113            path,
114            index: RwLock::new(index),
115            wal: RwLock::new(wal),
116            reader: RwLock::new(reader),
117            last_snapshot_wal_pos: RwLock::new(last_snapshot_wal_pos),
118            durability,
119            write_offset: RwLock::new(wal_len),
120        })
121    }
122
123    /// Opens the WAL file for append-mode writing.
124    fn open_wal_writer(log_path: &Path) -> io::Result<io::BufWriter<File>> {
125        let writer_file = OpenOptions::new()
126            .create(true)
127            .append(true)
128            .open(log_path)?;
129        Ok(io::BufWriter::new(writer_file))
130    }
131
132    /// Opens the WAL file for random-access reading, creating it if absent.
133    ///
134    /// Returns the reader handle and the current WAL length in bytes.
135    fn open_wal_reader(log_path: &Path) -> io::Result<(File, u64)> {
136        if !log_path.exists() {
137            File::create(log_path)?;
138        }
139        let reader = File::open(log_path)?;
140        let wal_len = reader.metadata()?.len();
141        Ok((reader, wal_len))
142    }
143
144    /// Loads the payload index, trying a snapshot first, falling back to full WAL replay.
145    ///
146    /// Returns `(index, last_snapshot_wal_position)`.
147    fn load_or_replay_index(
148        dir: &Path,
149        log_path: &Path,
150        wal_len: u64,
151    ) -> io::Result<(FxHashMap<u64, u64>, u64)> {
152        let snapshot_path = dir.join("payloads.snapshot");
153        match snapshot::load_snapshot(&snapshot_path) {
154            Ok((snapshot_index, snapshot_wal_pos)) => {
155                let index =
156                    Self::replay_wal_from(log_path, snapshot_index, snapshot_wal_pos, wal_len)?;
157                Ok((index, snapshot_wal_pos))
158            }
159            Err(e) => {
160                // `NotFound` is the expected cold-start case (no snapshot yet).
161                // Any other error kind means a snapshot existed but failed to
162                // load (corrupt header, truncated file, bad CRC) — surface it
163                // so operators can see recovery fell back to a full WAL
164                // replay instead of the fast path, even though the fallback
165                // itself is correct.
166                if e.kind() != io::ErrorKind::NotFound {
167                    tracing::warn!(
168                        error = %e,
169                        path = %snapshot_path.display(),
170                        "payload snapshot failed to load, falling back to full WAL replay"
171                    );
172                }
173                let index = Self::replay_wal_from(log_path, FxHashMap::default(), 0, wal_len)?;
174                Ok((index, 0))
175            }
176        }
177    }
178
179    /// Applies the configured durability mode to a WAL writer.
180    fn sync_wal(wal: &mut io::BufWriter<File>, mode: DurabilityMode) -> io::Result<()> {
181        match mode {
182            DurabilityMode::Fsync => {
183                wal.flush()?;
184                wal.get_ref().sync_all()?;
185            }
186            DurabilityMode::FlushOnly => {
187                wal.flush()?;
188            }
189            DurabilityMode::None => {}
190        }
191        Ok(())
192    }
193
194    /// Syncs the WAL according to durability mode, resyncing `write_offset`
195    /// with the actual file length on failure to prevent desync on subsequent
196    /// writes.
197    ///
198    /// RF-2: Shared by `store` and `delete` to eliminate duplicated
199    /// sync-and-resync-offset error handling.
200    fn sync_wal_or_resync(
201        wal: &mut io::BufWriter<File>,
202        mode: DurabilityMode,
203        offset: &mut u64,
204    ) -> io::Result<()> {
205        if let Err(e) = Self::sync_wal(wal, mode) {
206            if let Ok(meta) = wal.get_ref().metadata() {
207                *offset = meta.len();
208            }
209            return Err(e);
210        }
211        Ok(())
212    }
213
214    /// Replays WAL entries from `start_pos` to `end_pos`, updating the index.
215    fn replay_wal_from(
216        log_path: &Path,
217        mut index: FxHashMap<u64, u64>,
218        start_pos: u64,
219        end_pos: u64,
220    ) -> io::Result<FxHashMap<u64, u64>> {
221        if start_pos >= end_pos {
222            return Ok(index);
223        }
224
225        let file = File::open(log_path)?;
226        let mut reader_buf = BufReader::new(file);
227        reader_buf.seek(SeekFrom::Start(start_pos))?;
228
229        let mut pos = start_pos;
230        while pos < end_pos {
231            // `read` returns `None` to stop cleanly on a torn tail (crash
232            // mid-append) or on mid-stream corruption (unknown marker), keeping
233            // every entry replayed so far; see `wal_entry`'s policy.
234            let Some(entry) = WalEntry::read(&mut reader_buf, pos) else {
235                break;
236            };
237            // `apply` returns `Ok(None)` for a torn tail in the payload region
238            // (short/oversized final record): stop cleanly, keeping prior entries.
239            let Some(next_pos) = entry.apply(&mut index, &mut reader_buf, end_pos)? else {
240                break;
241            };
242            pos = next_pos;
243        }
244
245        Ok(index)
246    }
247
248    /// Creates a snapshot of the current index state.
249    ///
250    /// The snapshot captures:
251    /// - Current WAL position
252    /// - All index entries (ID -> offset mappings)
253    /// - CRC32 checksum for integrity
254    ///
255    /// # Errors
256    ///
257    /// Returns an error if file operations fail.
258    pub fn create_snapshot(&mut self) -> io::Result<()> {
259        // Flush WAL before snapshotting to ensure data is on disk for the reader
260        {
261            let mut wal = self.wal.write();
262            wal.flush()?;
263            wal.get_ref().sync_all()?;
264        }
265
266        let index = self.index.read();
267        let wal_pos = *self.write_offset.read();
268
269        snapshot::create_snapshot_file(&self.path, &index, wal_pos)?;
270
271        *self.last_snapshot_wal_pos.write() = wal_pos;
272
273        Ok(())
274    }
275
276    /// Returns whether a new snapshot should be created.
277    ///
278    /// Heuristic: Returns true if WAL has grown by more than the default threshold
279    /// bytes since the last snapshot.
280    #[must_use]
281    pub fn should_create_snapshot(&self) -> bool {
282        snapshot::should_create_snapshot(
283            *self.last_snapshot_wal_pos.read(),
284            *self.write_offset.read(),
285        )
286    }
287
288    /// Attempts to create a snapshot if the WAL has grown past the threshold.
289    ///
290    /// Best-effort: on failure the error is logged but not propagated,
291    /// because the WAL write that triggered the check already succeeded.
292    fn maybe_auto_snapshot(&mut self) {
293        if self.should_create_snapshot() {
294            if let Err(e) = self.create_snapshot() {
295                tracing::warn!(
296                    error = %e,
297                    "Auto-snapshot after WAL growth failed; will retry on next write"
298                );
299            }
300        }
301    }
302
303    /// Stores multiple payloads in a single batch operation.
304    ///
305    /// Optimized for bulk imports: acquires WAL + index + offset locks once,
306    /// writes all records sequentially, and performs a **single** durability
307    /// sync at the end instead of per-point fsync.
308    ///
309    /// # Errors
310    ///
311    /// Returns an error if serialization or WAL write fails. On partial failure,
312    /// entries written before the error are durable (WAL is append-only).
313    pub fn store_batch(&mut self, entries: &[(u64, &serde_json::Value)]) -> io::Result<()> {
314        self.store_batch_inner(entries, true)
315    }
316
317    /// Stores multiple payloads without forcing an fsync at the end.
318    ///
319    /// Identical to [`store_batch`](Self::store_batch) except the final
320    /// `sync_all()` is replaced by a buffer-only `flush()`. WAL entries are
321    /// written and the `BufWriter` is flushed to the OS kernel, but not
322    /// fsynced to disk.
323    ///
324    /// Use this for intermediate batches in a streaming bulk import, where
325    /// only the final batch needs full durability. Call
326    /// [`PayloadStorage::flush()`] after the last batch to force fsync.
327    ///
328    /// # Errors
329    ///
330    /// Returns an error if serialization or WAL write fails.
331    pub fn store_batch_deferred(
332        &mut self,
333        entries: &[(u64, &serde_json::Value)],
334    ) -> io::Result<()> {
335        self.store_batch_inner(entries, false)
336    }
337
338    /// Shared implementation for [`store_batch`] and [`store_batch_deferred`].
339    ///
340    /// When `fsync` is `true`, the configured durability mode is applied.
341    /// When `false`, only a buffer flush is performed (no `sync_all`).
342    fn store_batch_inner(
343        &mut self,
344        entries: &[(u64, &serde_json::Value)],
345        fsync: bool,
346    ) -> io::Result<()> {
347        if entries.is_empty() {
348            return Ok(());
349        }
350
351        {
352            let mut wal = self.wal.write();
353            let mut index = self.index.write();
354            let mut offset = self.write_offset.write();
355            let mut record_buf = Vec::with_capacity(256);
356
357            for &(id, payload) in entries {
358                write_store_record(
359                    &mut wal,
360                    id,
361                    payload,
362                    &mut offset,
363                    &mut index,
364                    &mut record_buf,
365                )?;
366            }
367
368            if fsync {
369                Self::sync_wal_or_resync(&mut wal, self.durability, &mut offset)?;
370            } else {
371                // Buffer-only flush: data reaches OS kernel but is not fsynced.
372                // Safe for intermediate batches — caller must fsync after the
373                // final batch.
374                wal.flush()?;
375            }
376        }
377
378        self.maybe_auto_snapshot();
379        Ok(())
380    }
381
382    /// Deletes multiple payloads under ONE durability barrier.
383    ///
384    /// Batched counterpart of [`PayloadStorage::delete`], which syncs the WAL
385    /// per id: calling it in a loop cost one fsync PER POINT on this store
386    /// (finding C3). Here every CRC-protected tombstone (`Marker(0xC4) |
387    /// ID(8) | CRC32(4)`) is built into one buffer, written with a single
388    /// `write_all`, and synced once via [`Self::sync_wal_or_resync`] — the
389    /// bytes are identical to N sequential `delete` calls, so WAL replay is
390    /// unchanged. Ids absent from the index are skipped (no tombstone),
391    /// mirroring the single-delete guard.
392    ///
393    /// # Crash contract
394    ///
395    /// The batch's tombstones become durable together: a crash before the
396    /// sync loses ALL of them (the payloads simply remain live on replay —
397    /// nothing is half-applied), a crash after it persists all. A torn tail
398    /// inside the record group is skipped by CRC framing on replay. Index
399    /// entries are removed only after the sync succeeded, so acknowledged
400    /// in-memory state never runs ahead of the WAL.
401    ///
402    /// # Errors
403    ///
404    /// Returns an error if the WAL write or sync fails; the index is left
405    /// untouched in that case.
406    pub fn delete_batch(&mut self, ids: &[u64]) -> io::Result<()> {
407        /// Tombstone record size: Marker(1) + ID(8) + CRC32(4).
408        const TOMBSTONE_BYTES: u64 = 1 + 8 + 4;
409
410        // SAFETY: `&mut self` serializes all writers, so the read-then-write
411        // gap below cannot race a concurrent `store(id)` (same argument as
412        // the single-`delete` guard).
413        let live: Vec<u64> = {
414            let index = self.index.read();
415            ids.iter()
416                .copied()
417                .filter(|id| index.contains_key(id))
418                .collect()
419        };
420        if live.is_empty() {
421            return Ok(());
422        }
423
424        // Scoped block: all lock guards are released before the auto-snapshot
425        // check, which itself acquires locks (see `create_snapshot`).
426        {
427            let mut wal = self.wal.write();
428            let mut index = self.index.write();
429            let mut offset = self.write_offset.write();
430
431            let mut records = Vec::with_capacity(live.len() * 13);
432            for &id in &live {
433                records.push(CRC_DELETE_MARKER);
434                records.extend_from_slice(&id.to_le_bytes());
435                records.extend_from_slice(&compute_delete_crc(id).to_le_bytes());
436            }
437            wal.write_all(&records)?;
438
439            // ONE sync for the whole batch (resync offset on failure).
440            Self::sync_wal_or_resync(&mut wal, self.durability, &mut offset)?;
441
442            for &id in &live {
443                *offset += TOMBSTONE_BYTES;
444                index.remove(&id);
445            }
446        }
447
448        self.maybe_auto_snapshot();
449        Ok(())
450    }
451}
452
453impl PayloadStorage for LogPayloadStorage {
454    fn store(&mut self, id: u64, payload: &serde_json::Value) -> io::Result<()> {
455        // Scoped block: lock guards released before auto-snapshot (which acquires locks).
456        {
457            let mut wal = self.wal.write();
458            let mut index = self.index.write();
459            let mut offset = self.write_offset.write();
460            let mut record_buf = Vec::new();
461
462            write_store_record(
463                &mut wal,
464                id,
465                payload,
466                &mut offset,
467                &mut index,
468                &mut record_buf,
469            )?;
470
471            Self::sync_wal_or_resync(&mut wal, self.durability, &mut offset)?;
472        }
473
474        self.maybe_auto_snapshot();
475        Ok(())
476    }
477
478    fn retrieve(&self, id: u64) -> io::Result<Option<serde_json::Value>> {
479        let index = self.index.read();
480        let Some(&offset) = index.get(&id) else {
481            return Ok(None);
482        };
483        drop(index);
484
485        // H-2: Only flush when DurabilityMode::None is configured, because sync_wal()
486        // already flushes the BufWriter after every write in Fsync and FlushOnly modes.
487        // Skipping this avoids acquiring the WAL write lock on every read, which would
488        // serialize all readers behind writers.
489        if self.durability == DurabilityMode::None {
490            self.wal.write().flush()?;
491        }
492
493        // Positional reads (`read_at`/`seek_read`) take `&File` and never touch
494        // a shared file cursor, so a *shared* read lock is enough: concurrent
495        // hydrations no longer serialize behind an exclusive write lock. The
496        // guard is also released before `serde_json::from_slice` so deserialize
497        // runs fully outside the lock.
498        let payload_bytes = {
499            let reader = self.reader.read();
500            let file_len = reader.metadata()?.len();
501            read_length_prefixed_payload(&reader, offset, file_len)?
502        };
503
504        let payload = serde_json::from_slice(&payload_bytes)
505            .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
506
507        Ok(Some(payload))
508    }
509
510    fn delete(&mut self, id: u64) -> io::Result<()> {
511        // If the id is not in the index there is nothing for a tombstone to
512        // shadow on WAL replay. Without this guard, callers that issue
513        // per-entry deletes (e.g. `write_deduped_payloads` with all-None
514        // payloads) pay one fsync per never-stored id.
515        //
516        // SAFETY: `&mut self` serializes all writers, so the read-then-write
517        // gap below cannot race a concurrent `store(id)`. If this signature
518        // is ever relaxed to `&self`, replace this with a single write-lock
519        // acquisition or fold the check inside the existing scoped block.
520        if !self.index.read().contains_key(&id) {
521            return Ok(());
522        }
523
524        let crc = compute_delete_crc(id);
525
526        // Scoped block: all lock guards are released before the auto-snapshot
527        // check, which itself acquires locks (see `create_snapshot`).
528        {
529            let mut wal = self.wal.write();
530            let mut index = self.index.write();
531            let mut offset = self.write_offset.write();
532
533            // H-3: Build complete delete record in one buffer to minimize partial-write window.
534            // CRC-protected format: Marker(0xC4) | ID(8) | CRC32(4)
535            let mut record = [0u8; 1 + 8 + 4];
536            record[0] = CRC_DELETE_MARKER;
537            record[1..9].copy_from_slice(&id.to_le_bytes());
538            record[9..13].copy_from_slice(&crc.to_le_bytes());
539            wal.write_all(&record)?;
540
541            // Sync WAL according to durability mode (resync offset on failure).
542            Self::sync_wal_or_resync(&mut wal, self.durability, &mut offset)?;
543
544            *offset += 1 + 8 + 4; // Marker(1) + ID(8) + CRC32(4)
545            index.remove(&id);
546        }
547
548        self.maybe_auto_snapshot();
549        Ok(())
550    }
551
552    fn flush(&mut self) -> io::Result<()> {
553        let mut wal = self.wal.write();
554        Self::sync_wal(&mut wal, self.durability)
555    }
556
557    fn ids(&self) -> Vec<u64> {
558        self.index.read().keys().copied().collect()
559    }
560}
561
562/// Reads `buf.len()` bytes from `file` starting at absolute `offset` without
563/// disturbing any shared file cursor.
564///
565/// Uses positional I/O (`pread` on Unix via [`std::os::unix::fs::FileExt`],
566/// overlapped `ReadFile` on Windows via [`std::os::windows::fs::FileExt`]), so
567/// the same `&File` can be read concurrently from many threads under a shared
568/// lock — the offset is passed to the syscall rather than seeked on the handle.
569#[cfg(unix)]
570fn read_exact_at(file: &File, buf: &mut [u8], offset: u64) -> io::Result<()> {
571    use std::os::unix::fs::FileExt;
572    file.read_exact_at(buf, offset)
573}
574
575/// Windows counterpart to [`read_exact_at`]. `seek_read` carries the offset in
576/// an `OVERLAPPED` structure (it does not rely on the shared cursor), so it is
577/// safe under concurrent shared-locked reads; it may return short, so loop
578/// until `buf` is filled.
579#[cfg(windows)]
580fn read_exact_at(file: &File, buf: &mut [u8], offset: u64) -> io::Result<()> {
581    use std::os::windows::fs::FileExt;
582    let mut filled = 0usize;
583    while filled < buf.len() {
584        let read = file.seek_read(&mut buf[filled..], offset + filled as u64)?;
585        if read == 0 {
586            return Err(io::Error::new(
587                io::ErrorKind::UnexpectedEof,
588                "failed to fill whole buffer",
589            ));
590        }
591        filled += read;
592    }
593    Ok(())
594}
595
596/// Reads a length-prefixed payload at `offset`: a 4-byte LE length followed by
597/// that many payload bytes. The declared length is bounded by the bytes
598/// remaining after the prefix (OOM guard #897/#898) before allocating.
599///
600/// Uses positional reads on a borrowed `&File`, so callers only need a shared
601/// read lock — the file cursor is never mutated.
602fn read_length_prefixed_payload(file: &File, offset: u64, file_len: u64) -> io::Result<Vec<u8>> {
603    let mut len_bytes = [0u8; 4];
604    read_exact_at(file, &mut len_bytes, offset)?;
605    let declared = u64::from(u32::from_le_bytes(len_bytes));
606
607    // OOM guard (#897/#898): a corrupt length field must not drive an unbounded
608    // allocation. The payload cannot exceed the bytes remaining after the prefix.
609    let pos_after_len = offset.saturating_add(4);
610    let remaining = file_len.saturating_sub(pos_after_len);
611    if declared > remaining {
612        return Err(io::Error::new(
613            io::ErrorKind::InvalidData,
614            "payload length exceeds file size",
615        ));
616    }
617    let len = usize::try_from(declared)
618        .map_err(|_| io::Error::new(io::ErrorKind::InvalidData, "payload length overflow"))?;
619
620    let mut payload_bytes = vec![0u8; len];
621    read_exact_at(file, &mut payload_bytes, pos_after_len)?;
622    Ok(payload_bytes)
623}