Skip to main content

velesdb_core/storage/
mmap.rs

1//! Memory-mapped file storage for vectors.
2//!
3//! Uses a combination of an index file (ID -> offset) and a data file (raw vectors).
4//! Also implements a simple WAL for durability.
5//!
6//! # Safety Guarantees (EPIC-032/US-001)
7//!
8//! All vector data is stored with f32 alignment (4 bytes):
9//! - Initial offset starts at 0 (aligned)
10//! - Each vector occupies `dimension * 4` bytes (always a multiple of 4)
11//! - Offsets are verified at runtime before pointer casting
12//!
13//! # P2 Optimization: Aggressive Pre-allocation
14//!
15//! To minimize blocking during `ensure_capacity` (which requires a write lock),
16//! we use aggressive pre-allocation:
17//! - Initial size: 16MB (vs 64KB before) - handles most small-medium datasets
18//! - Growth factor: 2x minimum with 64MB floor - fewer resize operations
19//! - Explicit `reserve_capacity()` for bulk imports
20
21mod vector_io;
22mod wal_replay;
23
24use super::compaction;
25use super::guard::VectorSliceGuard;
26use super::log_payload::DurabilityMode;
27use super::metrics::StorageMetrics;
28use super::sharded_index::ShardedIndex;
29use super::traits::VectorStorage;
30use crate::metrics::global_guardrails_metrics;
31
32use memmap2::MmapMut;
33use parking_lot::RwLock;
34use rustc_hash::FxHashMap;
35use std::fs::{File, OpenOptions};
36use std::io::{self, Write};
37use std::path::{Path, PathBuf};
38use std::sync::atomic::{AtomicU64, AtomicUsize, Ordering};
39use std::sync::Arc;
40use tracing::error;
41
42/// Memory-mapped file storage for vectors.
43///
44/// Uses a combination of an index file (ID -> offset) and a data file (raw vectors).
45/// Also implements a simple WAL for durability.
46#[allow(clippy::module_name_repetitions)]
47pub struct MmapStorage {
48    /// Directory path for storage files
49    pub(super) path: PathBuf,
50    /// Vector dimension
51    pub(super) dimension: usize,
52    /// In-memory index of ID -> file offset
53    /// EPIC-033/US-004: Sharded for reduced lock contention on read-heavy workloads
54    pub(super) index: ShardedIndex,
55    /// Write-Ahead Log writer
56    pub(super) wal: RwLock<io::BufWriter<File>>,
57    /// File handle for the data file (kept open for resizing)
58    pub(super) data_file: File,
59    /// Memory mapped data file
60    pub(super) mmap: RwLock<MmapMut>,
61    /// Next available offset in the data file
62    pub(super) next_offset: AtomicUsize,
63    /// P0 Audit: Metrics for monitoring `ensure_capacity` latency
64    pub(super) metrics: Arc<StorageMetrics>,
65    /// Epoch counter incremented every time the mmap is remapped.
66    ///
67    /// # Overflow Safety
68    ///
69    /// Uses wrapping arithmetic (guaranteed by `fetch_add`). Even at 1 billion
70    /// remaps/second, overflow would take ~584 years. The worst-case scenario
71    /// on wrap is a false-positive panic in `VectorSliceGuard::as_slice()`,
72    /// which is acceptable given the astronomical time required.
73    pub(super) remap_epoch: AtomicU64,
74    /// Controls WAL write and sync behavior for vector storage.
75    ///
76    /// Issue #423 Component 4: `DurabilityMode::None` skips WAL writes
77    /// entirely for bulk import scenarios where data can be re-derived.
78    /// Default is `Fsync` (unchanged from pre-#423 behavior).
79    pub(super) durability: DurabilityMode,
80    /// Ids touched by the WAL replay performed in [`MmapStorage::new`]
81    /// (store + delete entries), deduplicated. The replay truncates the WAL,
82    /// so these ids are the only remaining witness of writes the persisted
83    /// HNSW index may not reflect; `Collection::open` drains them via
84    /// [`MmapStorage::take_wal_replayed_ids`] for stale-entry reconciliation.
85    pub(super) wal_replayed_ids: Vec<u64>,
86}
87
88impl MmapStorage {
89    /// P2: Increased from 64KB to 16MB for better initial capacity.
90    pub(super) const INITIAL_SIZE: u64 = 16 * 1024 * 1024;
91
92    /// P2: Increased from 1MB to 64MB minimum growth.
93    pub(super) const MIN_GROWTH: u64 = 64 * 1024 * 1024;
94
95    /// P2: Growth factor for exponential pre-allocation.
96    pub(super) const GROWTH_FACTOR: u64 = 2;
97
98    /// Creates a new `MmapStorage` or opens an existing one.
99    ///
100    /// Uses the default durability mode (`Fsync`).
101    ///
102    /// # Arguments
103    ///
104    /// * `path` - Directory to store data
105    /// * `dimension` - Vector dimension
106    ///
107    /// # Errors
108    ///
109    /// Returns an error if file operations fail.
110    pub fn new<P: AsRef<Path>>(path: P, dimension: usize) -> io::Result<Self> {
111        Self::new_with_durability(path, dimension, DurabilityMode::default())
112    }
113
114    /// Creates a new `MmapStorage` with the specified durability mode.
115    ///
116    /// See [`DurabilityMode`] for available modes and their trade-offs.
117    ///
118    /// Issue #423 Component 4: `DurabilityMode::None` skips WAL writes
119    /// entirely for bulk import scenarios. Data is written directly to
120    /// the mmap file and is readable immediately, but not recoverable
121    /// from WAL after a crash.
122    ///
123    /// # Arguments
124    ///
125    /// * `path` - Directory to store data
126    /// * `dimension` - Vector dimension
127    /// * `durability` - WAL write/sync behavior
128    ///
129    /// # Errors
130    ///
131    /// Returns an error if file operations fail.
132    pub fn new_with_durability<P: AsRef<Path>>(
133        path: P,
134        dimension: usize,
135        durability: DurabilityMode,
136    ) -> io::Result<Self> {
137        let path = path.as_ref().to_path_buf();
138        std::fs::create_dir_all(&path)?;
139
140        let data_path = path.join("vectors.dat");
141        compaction::recover_compaction_artifacts(&data_path)?;
142
143        let data_file = Self::open_data_file(&data_path)?;
144        let mmap = Self::create_initial_mmap(&data_file)?;
145
146        let wal_path = path.join("vectors.wal");
147        let wal = Self::open_wal(&wal_path)?;
148
149        let index_path = path.join("vectors.idx");
150        let data_len = data_file.metadata()?.len();
151        let (index, next_offset) = Self::load_index(&index_path, dimension, data_len)?;
152
153        let (mmap, next_offset, wal_replayed_ids) = Self::replay_wal(
154            mmap,
155            next_offset,
156            &wal_path,
157            &index_path,
158            &index,
159            dimension,
160            &data_file,
161        )?;
162
163        Ok(Self {
164            path,
165            dimension,
166            index,
167            wal: RwLock::new(wal),
168            data_file,
169            mmap: RwLock::new(mmap),
170            next_offset: AtomicUsize::new(next_offset),
171            metrics: Arc::new(StorageMetrics::new()),
172            remap_epoch: AtomicU64::new(0),
173            durability,
174            wal_replayed_ids,
175        })
176    }
177
178    /// Drains the ids touched by the open-time WAL replay (see the
179    /// `wal_replayed_ids` field). Subsequent calls return an empty vec.
180    pub(crate) fn take_wal_replayed_ids(&mut self) -> Vec<u64> {
181        std::mem::take(&mut self.wal_replayed_ids)
182    }
183
184    /// Returns the current durability mode.
185    #[must_use]
186    pub fn durability(&self) -> DurabilityMode {
187        self.durability
188    }
189
190    /// Sets the durability mode at runtime.
191    pub fn set_durability_mode(&mut self, mode: DurabilityMode) {
192        self.durability = mode;
193    }
194
195    /// Returns a reference to the storage metrics.
196    #[must_use]
197    pub fn metrics(&self) -> &StorageMetrics {
198        &self.metrics
199    }
200
201    /// Opens or creates the data file, ensuring it has at least `INITIAL_SIZE` bytes.
202    fn open_data_file(data_path: &Path) -> io::Result<File> {
203        let data_file = OpenOptions::new()
204            .read(true)
205            .write(true)
206            .create(true)
207            .truncate(false)
208            .open(data_path)?;
209
210        let file_len = data_file.metadata()?.len();
211        if file_len == 0 {
212            data_file.set_len(Self::INITIAL_SIZE)?;
213        }
214        Ok(data_file)
215    }
216
217    /// Creates the initial memory map for the data file.
218    fn create_initial_mmap(data_file: &File) -> io::Result<MmapMut> {
219        // SAFETY: data_file is a valid, open file with set_len() called to ensure
220        // the mapping range is fully allocated.
221        // - Condition 1: File was opened with read+write permissions.
222        // - Condition 2: set_len() was called to ensure the file has INITIAL_SIZE bytes.
223        // - Condition 3: MmapMut requires readable and writable file, guaranteed by OpenOptions.
224        // SAFETY: Memory mapping requires unsafe due to potential for undefined behavior if file is truncated externally.
225        unsafe { MmapMut::map_mut(data_file) }
226    }
227
228    /// Opens or creates the WAL file wrapped in a buffered writer.
229    fn open_wal(wal_path: &Path) -> io::Result<io::BufWriter<File>> {
230        let wal_file = OpenOptions::new()
231            .append(true)
232            .create(true)
233            .open(wal_path)?;
234        Ok(io::BufWriter::new(wal_file))
235    }
236
237    /// Loads the sharded index from disk, returning the index and the next write offset.
238    ///
239    /// Validates every persisted offset against the backing file size (#898):
240    /// a corrupt index entry whose `offset + vector_size` overflows or exceeds
241    /// `data_len` would otherwise yield out-of-bounds reads or an inflated
242    /// `next_offset`. Such an index is rejected as corrupt. A 0-byte file is
243    /// the one exception: it is the footprint of a torn legacy in-place
244    /// rewrite, carries no information, and is treated as absent.
245    fn load_index(
246        index_path: &Path,
247        dimension: usize,
248        data_len: u64,
249    ) -> io::Result<(ShardedIndex, usize)> {
250        if !index_path.exists() {
251            return Ok((ShardedIndex::new(), 0));
252        }
253
254        let bytes = std::fs::read(index_path)?;
255        if bytes.is_empty() {
256            // A valid postcard-encoded index is never 0 bytes (even an empty
257            // map serializes to one length byte). A 0-byte vectors.idx is the
258            // footprint of a torn in-place rewrite by pre-atomic-persist
259            // versions and carries no information: treat it as absent so WAL
260            // replay can rebuild, instead of failing open() forever. A
261            // non-empty corrupt file still fails loudly below — it may
262            // witness real corruption that must not be silently discarded.
263            tracing::warn!("vectors.idx is 0 bytes (torn legacy rewrite); rebuilding from WAL");
264            return Ok((ShardedIndex::new(), 0));
265        }
266        let flat_index: FxHashMap<u64, usize> = postcard::from_bytes(&bytes)
267            .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
268
269        let vector_size = dimension * std::mem::size_of::<f32>();
270        let mut max_end = 0usize;
271        for &offset in flat_index.values() {
272            let end = offset.checked_add(vector_size).ok_or_else(|| {
273                io::Error::new(
274                    io::ErrorKind::InvalidData,
275                    "index offset arithmetic overflow",
276                )
277            })?;
278            let end_u64 = u64::try_from(end).map_err(|_| {
279                io::Error::new(
280                    io::ErrorKind::InvalidData,
281                    "index offset exceeds addressable range",
282                )
283            })?;
284            if end_u64 > data_len {
285                return Err(io::Error::new(
286                    io::ErrorKind::InvalidData,
287                    "index offset exceeds data file size",
288                ));
289            }
290            max_end = max_end.max(end);
291        }
292
293        Ok((ShardedIndex::from_hashmap(flat_index), max_end))
294    }
295
296    /// Replays the WAL to recover writes since the last flush.
297    ///
298    /// Crash-safe ordering (#898): apply WAL entries → flush the recovered mmap
299    /// → persist `vectors.idx` → only then truncate the WAL. Truncating before
300    /// the mmap and index are durable would lose the replayed writes on a crash
301    /// in that window.
302    ///
303    /// Returns the deduplicated ids touched by the replay so the caller can
304    /// reconcile the persisted HNSW index against them after the WAL is gone.
305    fn replay_wal(
306        mut mmap: MmapMut,
307        mut next_offset: usize,
308        wal_path: &Path,
309        index_path: &Path,
310        index: &ShardedIndex,
311        dimension: usize,
312        data_file: &File,
313    ) -> io::Result<(MmapMut, usize, Vec<u64>)> {
314        let mut touched_ids = Vec::new();
315        let replayed = wal_replay::replay_wal_to_index(
316            wal_path,
317            index,
318            dimension,
319            &mut mmap,
320            data_file,
321            &mut next_offset,
322            &mut touched_ids,
323        )?;
324        if replayed > 0 {
325            // 1. Make the recovered vector bytes durable.
326            mmap.flush()?;
327            // 2. Persist the rebuilt index so the recovered state survives even
328            //    after the WAL is cleared.
329            Self::persist_index_file(index_path, index)?;
330            // 3. Safe to clear the WAL now that mmap + index are durable.
331            wal_replay::truncate_wal(wal_path)?;
332        }
333        touched_ids.sort_unstable();
334        touched_ids.dedup();
335        Ok((mmap, next_offset, touched_ids))
336    }
337
338    /// Serializes the sharded index to `index_path`, atomically.
339    ///
340    /// Shared by [`Self::flush_index`] and WAL replay recovery. Goes through
341    /// a staged `vectors.idx.new` + rename so an interrupted persist can
342    /// never leave a torn `vectors.idx` behind (audit 2026-06, finding 3).
343    fn persist_index_file(index_path: &Path, index: &ShardedIndex) -> io::Result<()> {
344        compaction::persist_flat_index_atomic(index_path, &index.to_hashmap())
345    }
346
347    // ensure_capacity, reserve_capacity, compact, fragmentation_ratio are in mmap_capacity.rs
348
349    /// Retrieves a vector by ID without copying (zero-copy).
350    ///
351    /// Returns a guard providing direct mmap access. Faster than `retrieve()`
352    /// as it eliminates heap allocation and memcpy. Guard must be dropped to release lock.
353    ///
354    /// # Errors
355    ///
356    /// Returns an error if the stored offset is out of bounds.
357    ///
358    /// # Panics
359    ///
360    /// Panics if the stored offset is not f32-aligned (must be multiple of 4).
361    /// This should never happen with properly stored data.
362    pub fn retrieve_ref(&self, id: u64) -> io::Result<Option<VectorSliceGuard<'_>>> {
363        // EPIC-033/US-004: Use sharded index for reduced contention
364        let Some(offset) = self.index.get(id) else {
365            return Ok(None);
366        };
367
368        // Now acquire mmap read lock and validate bounds
369        let mmap = self.mmap.read();
370        let vector_size = self.dimension * std::mem::size_of::<f32>();
371
372        Self::validate_offset(offset, vector_size, mmap.len())?;
373
374        #[allow(clippy::cast_ptr_alignment)]
375        // SAFETY: We validated bounds/alignment above and keep the mmap read lock
376        // in `VectorSliceGuard`, so `ptr` stays valid for the guard lifetime.
377        // - Condition 1: `end <= mmap.len()` guarantees the addressed range exists.
378        // - Condition 2: `offset` is aligned to `align_of::<f32>()`.
379        // - Condition 3: `mmap` read lock pins the mapping while guard is alive.
380        // SAFETY: Zero-copy read path needs raw pointer conversion to `[f32]`.
381        let ptr = unsafe { mmap.as_ptr().add(offset).cast::<f32>() };
382
383        let epoch_at_creation = self.remap_epoch.load(Ordering::Acquire);
384        Ok(Some(VectorSliceGuard {
385            _guard: mmap,
386            ptr,
387            len: self.dimension,
388            epoch_ptr: &self.remap_epoch,
389            epoch_at_creation,
390        }))
391    }
392
393    /// Validates that `offset` is within bounds and f32-aligned.
394    ///
395    /// Returns an error if the offset overflows, is out of bounds, or
396    /// is not aligned to `align_of::<f32>()`.
397    fn validate_offset(offset: usize, vector_size: usize, mmap_len: usize) -> io::Result<()> {
398        let end = offset.checked_add(vector_size).ok_or_else(|| {
399            global_guardrails_metrics().record_invalid_offset_read_error();
400            io::Error::new(
401                io::ErrorKind::InvalidData,
402                "Offset arithmetic overflow while reading vector",
403            )
404        })?;
405
406        if end > mmap_len {
407            global_guardrails_metrics().record_invalid_offset_read_error();
408            return Err(io::Error::new(
409                io::ErrorKind::InvalidData,
410                "Offset out of bounds",
411            ));
412        }
413
414        // EPIC-032/US-001: Verify alignment before pointer cast
415        if !offset.is_multiple_of(std::mem::align_of::<f32>()) {
416            global_guardrails_metrics().record_invalid_offset_read_error();
417            return Err(io::Error::new(
418                io::ErrorKind::InvalidData,
419                format!(
420                    "EPIC-032/US-001: offset {offset} is not f32-aligned (must be multiple of {})",
421                    std::mem::align_of::<f32>()
422                ),
423            ));
424        }
425
426        Ok(())
427    }
428
429    /// Persists the `vectors.idx` index file to disk with fsync.
430    ///
431    /// Issue #423: Extracted from the former `flush()` to allow callers to
432    /// control when the (expensive) index serialization happens. The WAL
433    /// provides crash recovery even if this file is stale, so it can be
434    /// deferred to compaction or explicit shutdown.
435    ///
436    /// # Errors
437    ///
438    /// Returns an error if serialization or I/O fails.
439    pub fn flush_index(&self) -> io::Result<()> {
440        // EPIC-033/US-004: Convert ShardedIndex to flat HashMap for serialization
441        // EPIC-069/US-001: fsync index file for crash recovery on Windows
442        let index_path = self.path.join("vectors.idx");
443        Self::persist_index_file(&index_path, &self.index)
444    }
445
446    /// Full durability flush: WAL + mmap + `vectors.idx`.
447    ///
448    /// Equivalent to the pre-#423 `flush()` behavior. Use this on shutdown
449    /// or before compaction to ensure the index file is up-to-date, avoiding
450    /// a full WAL replay on the next startup.
451    ///
452    /// # Errors
453    ///
454    /// Returns an error if any I/O operation fails.
455    pub fn flush_full(&mut self) -> io::Result<()> {
456        self.flush()?;
457        self.flush_index()
458    }
459
460    /// Attempts a best-effort durability sync during shutdown.
461    ///
462    /// This method never returns an error and never blocks on lock contention:
463    /// if the WAL/mmap lock cannot be acquired immediately, the flush step is
464    /// skipped and shutdown continues.
465    ///
466    /// Use explicit [`VectorStorage::flush`](crate::storage::traits::VectorStorage::flush)
467    /// to obtain a deterministic durability barrier.
468    pub(crate) fn flush_on_shutdown_best_effort(&self) {
469        // 1. Flush WAL first (operation log)
470        self.try_flush_wal();
471
472        // 2. Flush mmap to persist vector bytes
473        self.try_flush_mmap();
474    }
475
476    /// Best-effort WAL flush: skips if lock is contended.
477    fn try_flush_wal(&self) {
478        if let Some(mut wal) = self.wal.try_write() {
479            if let Err(e) = wal.flush() {
480                error!(?e, "Failed to flush WAL in MmapStorage shutdown path");
481            }
482            if let Err(e) = wal.get_ref().sync_all() {
483                error!(?e, "Failed to fsync WAL in MmapStorage shutdown path");
484            }
485        }
486    }
487
488    /// Best-effort mmap flush: skips if lock is contended.
489    fn try_flush_mmap(&self) {
490        if let Some(mmap) = self.mmap.try_write() {
491            if let Err(e) = mmap.flush() {
492                error!(?e, "Failed to flush mmap in MmapStorage shutdown path");
493            }
494        }
495    }
496}
497
498// -----------------------------------------------------------------------------
499// Drop implementation – best-effort sync on graceful shutdown.
500//
501// Important: `drop` is not a transactional durability boundary.
502// Call `flush()` explicitly when the caller requires deterministic durability.
503// -----------------------------------------------------------------------------
504impl Drop for MmapStorage {
505    fn drop(&mut self) {
506        self.flush_on_shutdown_best_effort();
507    }
508}