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}