Skip to main content

hermes_core/directories/
mmap.rs

1//! Memory-mapped directory for efficient access to large indices
2//!
3//! This module is only compiled with the "native" feature.
4
5use std::io;
6use std::ops::Range;
7use std::path::{Path, PathBuf};
8use std::sync::Arc;
9
10use async_trait::async_trait;
11use memmap2::Mmap;
12
13use super::{
14    Directory, DirectoryWriter, FileHandle, FileStreamingWriter, OwnedBytes, StreamingWriter,
15};
16
17/// Memory-mapped directory for efficient access to large index files
18///
19/// Uses memory-mapped files to avoid loading entire files into memory.
20/// The OS manages paging, making this ideal for indices larger than RAM.
21///
22/// Benefits:
23/// - Files are not fully loaded into memory
24/// - OS handles caching and paging automatically
25/// - Multiple processes can share the same mapped pages
26/// - Efficient random access patterns
27///
28/// Note: Write operations still use regular file I/O.
29/// No application-level cache - the OS page cache handles this efficiently.
30pub struct MmapDirectory {
31    root: PathBuf,
32    label: super::IndexLabel,
33}
34
35impl MmapDirectory {
36    /// Create a new MmapDirectory rooted at the given path
37    pub fn new(root: impl AsRef<Path>) -> Self {
38        Self {
39            root: root.as_ref().to_path_buf(),
40            label: super::IndexLabel::default(),
41        }
42    }
43
44    /// Get the root directory path
45    pub fn root(&self) -> &Path {
46        &self.root
47    }
48
49    fn resolve(&self, path: &Path) -> PathBuf {
50        self.root.join(path)
51    }
52
53    /// Memory-map a file (no application cache - OS page cache handles this)
54    fn mmap_file(&self, path: &Path) -> io::Result<Arc<Mmap>> {
55        let full_path = self.resolve(path);
56        let file = std::fs::File::open(&full_path)?;
57        let mmap = unsafe { Mmap::map(&file)? };
58        Ok(Arc::new(mmap))
59    }
60}
61
62impl Clone for MmapDirectory {
63    fn clone(&self) -> Self {
64        Self {
65            root: self.root.clone(),
66            // Shared, so a label set on any clone is visible on all
67            label: self.label.clone(),
68        }
69    }
70}
71
72#[async_trait]
73impl Directory for MmapDirectory {
74    async fn exists(&self, path: &Path) -> io::Result<bool> {
75        let full_path = self.resolve(path);
76        // `try_exists` maps NotFound to Ok(false); any other stat failure
77        // (EACCES, EIO, ...) must propagate so callers can distinguish a
78        // genuinely missing file from a transient IO error — swallowing it
79        // as `false` quarantines a healthy segment as "missing mandatory
80        // files" instead of retrying.
81        tokio::fs::try_exists(&full_path).await
82    }
83
84    async fn file_size(&self, path: &Path) -> io::Result<u64> {
85        let full_path = self.resolve(path);
86        let metadata = tokio::fs::metadata(&full_path).await?;
87        Ok(metadata.len())
88    }
89
90    async fn open_read(&self, path: &Path) -> io::Result<FileHandle> {
91        let mmap = self.mmap_file(path)?;
92        // Zero-copy: OwnedBytes references the mmap directly
93        Ok(FileHandle::from_bytes(OwnedBytes::from_mmap(mmap)))
94    }
95
96    async fn read_range(&self, path: &Path, range: Range<u64>) -> io::Result<OwnedBytes> {
97        let mmap = self.mmap_file(path)?;
98        let start = range.start as usize;
99        let end = range.end as usize;
100
101        if end > mmap.len() {
102            return Err(io::Error::new(
103                io::ErrorKind::InvalidInput,
104                format!("Range {}..{} exceeds file size {}", start, end, mmap.len()),
105            ));
106        }
107
108        // Zero-copy: slice references the mmap directly
109        Ok(OwnedBytes::from_mmap_range(mmap, start..end))
110    }
111
112    async fn list_files(&self, prefix: &Path) -> io::Result<Vec<PathBuf>> {
113        let full_path = self.resolve(prefix);
114        let mut entries = tokio::fs::read_dir(&full_path).await?;
115        let mut files = Vec::new();
116
117        while let Some(entry) = entries.next_entry().await? {
118            if entry.file_type().await?.is_file() {
119                files.push(entry.path().strip_prefix(&self.root).unwrap().to_path_buf());
120            }
121        }
122
123        Ok(files)
124    }
125
126    async fn open_lazy(&self, path: &Path) -> io::Result<FileHandle> {
127        // Mmap data is always available synchronously — return Inline handle
128        // This eliminates the async callback overhead entirely for mmap paths
129        self.open_read(path).await
130    }
131
132    fn set_index_label(&self, label: &str) {
133        self.label.set(label);
134    }
135
136    fn local_path(&self, path: &Path) -> Option<PathBuf> {
137        Some(self.resolve(path))
138    }
139}
140
141#[async_trait]
142impl DirectoryWriter for MmapDirectory {
143    async fn write(&self, path: &Path, data: &[u8]) -> io::Result<()> {
144        let full_path = self.resolve(path);
145
146        // Ensure parent directory exists
147        if let Some(parent) = full_path.parent() {
148            tokio::fs::create_dir_all(parent).await?;
149        }
150
151        tokio::fs::write(&full_path, data).await
152    }
153
154    async fn delete(&self, path: &Path) -> io::Result<()> {
155        let full_path = self.resolve(path);
156        tokio::fs::remove_file(&full_path).await
157    }
158
159    async fn rename(&self, from: &Path, to: &Path) -> io::Result<()> {
160        let from_path = self.resolve(from);
161        let to_path = self.resolve(to);
162        // This is the metadata commit point. A synchronous rename completes
163        // in one future poll, preventing cancellation from detaching the
164        // filesystem rename from the following in-memory publication.
165        std::fs::rename(&from_path, &to_path)
166    }
167
168    async fn link(&self, from: &Path, to: &Path) -> io::Result<()> {
169        std::fs::hard_link(self.resolve(from), self.resolve(to))
170    }
171
172    async fn sync(&self) -> io::Result<()> {
173        // fsync the directory
174        let dir = std::fs::File::open(&self.root)?;
175        dir.sync_all()?;
176        Ok(())
177    }
178
179    async fn streaming_writer(&self, path: &Path) -> io::Result<Box<dyn StreamingWriter>> {
180        let full_path = self.resolve(path);
181        if let Some(parent) = full_path.parent() {
182            tokio::fs::create_dir_all(parent).await?;
183        }
184        let file = std::fs::File::create(&full_path)?;
185        Ok(Box::new(FileStreamingWriter::new(file)))
186    }
187
188    async fn streaming_writer_cold(&self, path: &Path) -> io::Result<Box<dyn StreamingWriter>> {
189        let full_path = self.resolve(path);
190        if let Some(parent) = full_path.parent() {
191            tokio::fs::create_dir_all(parent).await?;
192        }
193        let file = std::fs::File::create(&full_path)?;
194        Ok(Box::new(super::ColdStreamingWriter::new(
195            file,
196            self.label.get(),
197        )))
198    }
199}
200
201#[cfg(test)]
202mod tests {
203    use super::*;
204    use tempfile::TempDir;
205
206    #[tokio::test]
207    async fn test_mmap_directory_basic() {
208        let temp_dir = TempDir::new().unwrap();
209        let dir = MmapDirectory::new(temp_dir.path());
210
211        // Write a file
212        let test_data = b"Hello, mmap world!";
213        dir.write(Path::new("test.txt"), test_data).await.unwrap();
214
215        // Check exists
216        assert!(dir.exists(Path::new("test.txt")).await.unwrap());
217        assert!(!dir.exists(Path::new("nonexistent.txt")).await.unwrap());
218
219        // Check file size
220        assert_eq!(
221            dir.file_size(Path::new("test.txt")).await.unwrap(),
222            test_data.len() as u64
223        );
224
225        // Read full file
226        let slice = dir.open_read(Path::new("test.txt")).await.unwrap();
227        let bytes = slice.read_bytes().await.unwrap();
228        assert_eq!(bytes.as_slice(), test_data);
229
230        // Read range
231        let range_bytes = dir.read_range(Path::new("test.txt"), 7..12).await.unwrap();
232        assert_eq!(range_bytes.as_slice(), b"mmap ");
233    }
234
235    /// A transient stat failure (EACCES here, EIO on flaky storage) must
236    /// surface as `Err`, not `Ok(false)`: callers classify a missing
237    /// mandatory segment file as deterministic corruption and quarantine
238    /// the segment until restart.
239    #[cfg(unix)]
240    #[tokio::test]
241    async fn test_mmap_exists_propagates_stat_errors_instead_of_reporting_missing() {
242        use std::os::unix::fs::PermissionsExt;
243
244        let temp_dir = TempDir::new().unwrap();
245        let dir = MmapDirectory::new(temp_dir.path());
246        dir.write(Path::new("locked/seg.meta"), b"data")
247            .await
248            .unwrap();
249
250        // Removing search permission from the parent makes stat on the child
251        // fail with EACCES while the file itself still exists.
252        let locked = temp_dir.path().join("locked");
253        let original = std::fs::metadata(&locked).unwrap().permissions();
254        std::fs::set_permissions(&locked, std::fs::Permissions::from_mode(0o000)).unwrap();
255        if std::fs::metadata(locked.join("seg.meta")).is_ok() {
256            // Running as root: directory permissions are not enforced, so the
257            // stat failure cannot be provoked.
258            std::fs::set_permissions(&locked, original).unwrap();
259            return;
260        }
261        let result = dir.exists(Path::new("locked/seg.meta")).await;
262        std::fs::set_permissions(&locked, original).unwrap();
263
264        let error =
265            result.expect_err("stat failure must propagate as Err, not be misreported as missing");
266        assert_ne!(error.kind(), io::ErrorKind::NotFound);
267        // Once stat succeeds again the file is reported present.
268        assert!(dir.exists(Path::new("locked/seg.meta")).await.unwrap());
269    }
270
271    #[tokio::test]
272    async fn test_mmap_directory_lazy_handle() {
273        let temp_dir = TempDir::new().unwrap();
274        let dir = MmapDirectory::new(temp_dir.path());
275
276        // Write a larger file
277        let data: Vec<u8> = (0..1000).map(|i| (i % 256) as u8).collect();
278        dir.write(Path::new("large.bin"), &data).await.unwrap();
279
280        // Open lazy handle — should be Inline (sync-capable) for mmap
281        let handle = dir.open_lazy(Path::new("large.bin")).await.unwrap();
282        assert_eq!(handle.len(), 1000);
283        assert!(handle.is_sync());
284
285        // Async reads
286        let range1 = handle.read_bytes_range(0..100).await.unwrap();
287        assert_eq!(range1.len(), 100);
288        assert_eq!(range1.as_slice(), &data[0..100]);
289
290        // Sync reads
291        let range2 = handle.read_bytes_range_sync(500..600).unwrap();
292        assert_eq!(range2.as_slice(), &data[500..600]);
293    }
294}