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 if let Ok((snapshot_index, snapshot_wal_pos)) = snapshot::load_snapshot(&snapshot_path) {
154 let index = Self::replay_wal_from(log_path, snapshot_index, snapshot_wal_pos, wal_len)?;
155 Ok((index, snapshot_wal_pos))
156 } else {
157 let index = Self::replay_wal_from(log_path, FxHashMap::default(), 0, wal_len)?;
158 Ok((index, 0))
159 }
160 }
161
162 /// Applies the configured durability mode to a WAL writer.
163 fn sync_wal(wal: &mut io::BufWriter<File>, mode: DurabilityMode) -> io::Result<()> {
164 match mode {
165 DurabilityMode::Fsync => {
166 wal.flush()?;
167 wal.get_ref().sync_all()?;
168 }
169 DurabilityMode::FlushOnly => {
170 wal.flush()?;
171 }
172 DurabilityMode::None => {}
173 }
174 Ok(())
175 }
176
177 /// Syncs the WAL according to durability mode, resyncing `write_offset`
178 /// with the actual file length on failure to prevent desync on subsequent
179 /// writes.
180 ///
181 /// RF-2: Shared by `store` and `delete` to eliminate duplicated
182 /// sync-and-resync-offset error handling.
183 fn sync_wal_or_resync(
184 wal: &mut io::BufWriter<File>,
185 mode: DurabilityMode,
186 offset: &mut u64,
187 ) -> io::Result<()> {
188 if let Err(e) = Self::sync_wal(wal, mode) {
189 if let Ok(meta) = wal.get_ref().metadata() {
190 *offset = meta.len();
191 }
192 return Err(e);
193 }
194 Ok(())
195 }
196
197 /// Replays WAL entries from `start_pos` to `end_pos`, updating the index.
198 fn replay_wal_from(
199 log_path: &Path,
200 mut index: FxHashMap<u64, u64>,
201 start_pos: u64,
202 end_pos: u64,
203 ) -> io::Result<FxHashMap<u64, u64>> {
204 if start_pos >= end_pos {
205 return Ok(index);
206 }
207
208 let file = File::open(log_path)?;
209 let mut reader_buf = BufReader::new(file);
210 reader_buf.seek(SeekFrom::Start(start_pos))?;
211
212 let mut pos = start_pos;
213 while pos < end_pos {
214 // `read` returns `None` to stop cleanly on a torn tail (crash
215 // mid-append) or on mid-stream corruption (unknown marker), keeping
216 // every entry replayed so far; see `wal_entry`'s policy.
217 let Some(entry) = WalEntry::read(&mut reader_buf, pos) else {
218 break;
219 };
220 // `apply` returns `Ok(None)` for a torn tail in the payload region
221 // (short/oversized final record): stop cleanly, keeping prior entries.
222 let Some(next_pos) = entry.apply(&mut index, &mut reader_buf, end_pos)? else {
223 break;
224 };
225 pos = next_pos;
226 }
227
228 Ok(index)
229 }
230
231 /// Creates a snapshot of the current index state.
232 ///
233 /// The snapshot captures:
234 /// - Current WAL position
235 /// - All index entries (ID -> offset mappings)
236 /// - CRC32 checksum for integrity
237 ///
238 /// # Errors
239 ///
240 /// Returns an error if file operations fail.
241 pub fn create_snapshot(&mut self) -> io::Result<()> {
242 // Flush WAL before snapshotting to ensure data is on disk for the reader
243 {
244 let mut wal = self.wal.write();
245 wal.flush()?;
246 wal.get_ref().sync_all()?;
247 }
248
249 let index = self.index.read();
250 let wal_pos = *self.write_offset.read();
251
252 snapshot::create_snapshot_file(&self.path, &index, wal_pos)?;
253
254 *self.last_snapshot_wal_pos.write() = wal_pos;
255
256 Ok(())
257 }
258
259 /// Returns whether a new snapshot should be created.
260 ///
261 /// Heuristic: Returns true if WAL has grown by more than the default threshold
262 /// bytes since the last snapshot.
263 #[must_use]
264 pub fn should_create_snapshot(&self) -> bool {
265 snapshot::should_create_snapshot(
266 *self.last_snapshot_wal_pos.read(),
267 *self.write_offset.read(),
268 )
269 }
270
271 /// Attempts to create a snapshot if the WAL has grown past the threshold.
272 ///
273 /// Best-effort: on failure the error is logged but not propagated,
274 /// because the WAL write that triggered the check already succeeded.
275 fn maybe_auto_snapshot(&mut self) {
276 if self.should_create_snapshot() {
277 if let Err(e) = self.create_snapshot() {
278 tracing::warn!(
279 error = %e,
280 "Auto-snapshot after WAL growth failed; will retry on next write"
281 );
282 }
283 }
284 }
285
286 /// Stores multiple payloads in a single batch operation.
287 ///
288 /// Optimized for bulk imports: acquires WAL + index + offset locks once,
289 /// writes all records sequentially, and performs a **single** durability
290 /// sync at the end instead of per-point fsync.
291 ///
292 /// # Errors
293 ///
294 /// Returns an error if serialization or WAL write fails. On partial failure,
295 /// entries written before the error are durable (WAL is append-only).
296 pub fn store_batch(&mut self, entries: &[(u64, &serde_json::Value)]) -> io::Result<()> {
297 self.store_batch_inner(entries, true)
298 }
299
300 /// Stores multiple payloads without forcing an fsync at the end.
301 ///
302 /// Identical to [`store_batch`](Self::store_batch) except the final
303 /// `sync_all()` is replaced by a buffer-only `flush()`. WAL entries are
304 /// written and the `BufWriter` is flushed to the OS kernel, but not
305 /// fsynced to disk.
306 ///
307 /// Use this for intermediate batches in a streaming bulk import, where
308 /// only the final batch needs full durability. Call
309 /// [`PayloadStorage::flush()`] after the last batch to force fsync.
310 ///
311 /// # Errors
312 ///
313 /// Returns an error if serialization or WAL write fails.
314 pub fn store_batch_deferred(
315 &mut self,
316 entries: &[(u64, &serde_json::Value)],
317 ) -> io::Result<()> {
318 self.store_batch_inner(entries, false)
319 }
320
321 /// Shared implementation for [`store_batch`] and [`store_batch_deferred`].
322 ///
323 /// When `fsync` is `true`, the configured durability mode is applied.
324 /// When `false`, only a buffer flush is performed (no `sync_all`).
325 fn store_batch_inner(
326 &mut self,
327 entries: &[(u64, &serde_json::Value)],
328 fsync: bool,
329 ) -> io::Result<()> {
330 if entries.is_empty() {
331 return Ok(());
332 }
333
334 {
335 let mut wal = self.wal.write();
336 let mut index = self.index.write();
337 let mut offset = self.write_offset.write();
338 let mut record_buf = Vec::with_capacity(256);
339
340 for &(id, payload) in entries {
341 write_store_record(
342 &mut wal,
343 id,
344 payload,
345 &mut offset,
346 &mut index,
347 &mut record_buf,
348 )?;
349 }
350
351 if fsync {
352 Self::sync_wal_or_resync(&mut wal, self.durability, &mut offset)?;
353 } else {
354 // Buffer-only flush: data reaches OS kernel but is not fsynced.
355 // Safe for intermediate batches — caller must fsync after the
356 // final batch.
357 wal.flush()?;
358 }
359 }
360
361 self.maybe_auto_snapshot();
362 Ok(())
363 }
364}
365
366impl PayloadStorage for LogPayloadStorage {
367 fn store(&mut self, id: u64, payload: &serde_json::Value) -> io::Result<()> {
368 // Scoped block: lock guards released before auto-snapshot (which acquires locks).
369 {
370 let mut wal = self.wal.write();
371 let mut index = self.index.write();
372 let mut offset = self.write_offset.write();
373 let mut record_buf = Vec::new();
374
375 write_store_record(
376 &mut wal,
377 id,
378 payload,
379 &mut offset,
380 &mut index,
381 &mut record_buf,
382 )?;
383
384 Self::sync_wal_or_resync(&mut wal, self.durability, &mut offset)?;
385 }
386
387 self.maybe_auto_snapshot();
388 Ok(())
389 }
390
391 fn retrieve(&self, id: u64) -> io::Result<Option<serde_json::Value>> {
392 let index = self.index.read();
393 let Some(&offset) = index.get(&id) else {
394 return Ok(None);
395 };
396 drop(index);
397
398 // H-2: Only flush when DurabilityMode::None is configured, because sync_wal()
399 // already flushes the BufWriter after every write in Fsync and FlushOnly modes.
400 // Skipping this avoids acquiring the WAL write lock on every read, which would
401 // serialize all readers behind writers.
402 if self.durability == DurabilityMode::None {
403 self.wal.write().flush()?;
404 }
405
406 // Positional reads (`read_at`/`seek_read`) take `&File` and never touch
407 // a shared file cursor, so a *shared* read lock is enough: concurrent
408 // hydrations no longer serialize behind an exclusive write lock. The
409 // guard is also released before `serde_json::from_slice` so deserialize
410 // runs fully outside the lock.
411 let payload_bytes = {
412 let reader = self.reader.read();
413 let file_len = reader.metadata()?.len();
414 read_length_prefixed_payload(&reader, offset, file_len)?
415 };
416
417 let payload = serde_json::from_slice(&payload_bytes)
418 .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
419
420 Ok(Some(payload))
421 }
422
423 fn delete(&mut self, id: u64) -> io::Result<()> {
424 // If the id is not in the index there is nothing for a tombstone to
425 // shadow on WAL replay. Without this guard, callers that issue
426 // per-entry deletes (e.g. `write_deduped_payloads` with all-None
427 // payloads) pay one fsync per never-stored id.
428 //
429 // SAFETY: `&mut self` serializes all writers, so the read-then-write
430 // gap below cannot race a concurrent `store(id)`. If this signature
431 // is ever relaxed to `&self`, replace this with a single write-lock
432 // acquisition or fold the check inside the existing scoped block.
433 if !self.index.read().contains_key(&id) {
434 return Ok(());
435 }
436
437 let crc = compute_delete_crc(id);
438
439 // Scoped block: all lock guards are released before the auto-snapshot
440 // check, which itself acquires locks (see `create_snapshot`).
441 {
442 let mut wal = self.wal.write();
443 let mut index = self.index.write();
444 let mut offset = self.write_offset.write();
445
446 // H-3: Build complete delete record in one buffer to minimize partial-write window.
447 // CRC-protected format: Marker(0xC4) | ID(8) | CRC32(4)
448 let mut record = [0u8; 1 + 8 + 4];
449 record[0] = CRC_DELETE_MARKER;
450 record[1..9].copy_from_slice(&id.to_le_bytes());
451 record[9..13].copy_from_slice(&crc.to_le_bytes());
452 wal.write_all(&record)?;
453
454 // Sync WAL according to durability mode (resync offset on failure).
455 Self::sync_wal_or_resync(&mut wal, self.durability, &mut offset)?;
456
457 *offset += 1 + 8 + 4; // Marker(1) + ID(8) + CRC32(4)
458 index.remove(&id);
459 }
460
461 self.maybe_auto_snapshot();
462 Ok(())
463 }
464
465 fn flush(&mut self) -> io::Result<()> {
466 let mut wal = self.wal.write();
467 Self::sync_wal(&mut wal, self.durability)
468 }
469
470 fn ids(&self) -> Vec<u64> {
471 self.index.read().keys().copied().collect()
472 }
473}
474
475/// Reads `buf.len()` bytes from `file` starting at absolute `offset` without
476/// disturbing any shared file cursor.
477///
478/// Uses positional I/O (`pread` on Unix via [`std::os::unix::fs::FileExt`],
479/// overlapped `ReadFile` on Windows via [`std::os::windows::fs::FileExt`]), so
480/// the same `&File` can be read concurrently from many threads under a shared
481/// lock — the offset is passed to the syscall rather than seeked on the handle.
482#[cfg(unix)]
483fn read_exact_at(file: &File, buf: &mut [u8], offset: u64) -> io::Result<()> {
484 use std::os::unix::fs::FileExt;
485 file.read_exact_at(buf, offset)
486}
487
488/// Windows counterpart to [`read_exact_at`]. `seek_read` carries the offset in
489/// an `OVERLAPPED` structure (it does not rely on the shared cursor), so it is
490/// safe under concurrent shared-locked reads; it may return short, so loop
491/// until `buf` is filled.
492#[cfg(windows)]
493fn read_exact_at(file: &File, buf: &mut [u8], offset: u64) -> io::Result<()> {
494 use std::os::windows::fs::FileExt;
495 let mut filled = 0usize;
496 while filled < buf.len() {
497 let read = file.seek_read(&mut buf[filled..], offset + filled as u64)?;
498 if read == 0 {
499 return Err(io::Error::new(
500 io::ErrorKind::UnexpectedEof,
501 "failed to fill whole buffer",
502 ));
503 }
504 filled += read;
505 }
506 Ok(())
507}
508
509/// Reads a length-prefixed payload at `offset`: a 4-byte LE length followed by
510/// that many payload bytes. The declared length is bounded by the bytes
511/// remaining after the prefix (OOM guard #897/#898) before allocating.
512///
513/// Uses positional reads on a borrowed `&File`, so callers only need a shared
514/// read lock — the file cursor is never mutated.
515fn read_length_prefixed_payload(file: &File, offset: u64, file_len: u64) -> io::Result<Vec<u8>> {
516 let mut len_bytes = [0u8; 4];
517 read_exact_at(file, &mut len_bytes, offset)?;
518 let declared = u64::from(u32::from_le_bytes(len_bytes));
519
520 // OOM guard (#897/#898): a corrupt length field must not drive an unbounded
521 // allocation. The payload cannot exceed the bytes remaining after the prefix.
522 let pos_after_len = offset.saturating_add(4);
523 let remaining = file_len.saturating_sub(pos_after_len);
524 if declared > remaining {
525 return Err(io::Error::new(
526 io::ErrorKind::InvalidData,
527 "payload length exceeds file size",
528 ));
529 }
530 let len = usize::try_from(declared)
531 .map_err(|_| io::Error::new(io::ErrorKind::InvalidData, "payload length overflow"))?;
532
533 let mut payload_bytes = vec![0u8; len];
534 read_exact_at(file, &mut payload_bytes, pos_after_len)?;
535 Ok(payload_bytes)
536}