Skip to main content

chronix_engine/storage/
local.rs

1//! Local filesystem storage backend.
2//!
3//! Implements [`StorageBackend`] for the local filesystem with:
4//! - Atomic writes via temporary file + rename
5//! - Positioned reads via `pread` (no seek/read race)
6//! - File permissions `0o600` (owner read/write only)
7//! - Namespace-scoped directory layout: `{data_dir}/ns_{namespace}/shard_{id}/`
8
9use std::path::{Path, PathBuf};
10use std::sync::atomic::{AtomicU64, Ordering};
11
12use tokio::fs;
13use tracing::debug;
14
15use chronix_core::{NamespaceId, ShardId};
16
17use crate::storage::backend::{SegmentPath, StorageBackend};
18use crate::storage::error::{Result, StorageError};
19
20/// Local filesystem storage backend.
21///
22/// Stores segment files in a namespace-scoped, shard-based directory hierarchy:
23/// ```text
24/// {data_dir}/ns_{namespace}/shard_{id}/segment_name.csx
25/// ```
26///
27/// All write operations are atomic (write to `.tmp` then rename) to prevent
28/// partial segment files from being visible to readers.
29#[derive(Debug, Clone)]
30pub struct LocalFsBackend {
31    data_dir: PathBuf,
32}
33
34impl LocalFsBackend {
35    /// Create a new local filesystem backend rooted at `data_dir`.
36    #[must_use]
37    pub fn new(data_dir: impl Into<PathBuf>) -> Self {
38        Self {
39            data_dir: data_dir.into(),
40        }
41    }
42
43    /// Returns the data directory path.
44    #[must_use]
45    pub fn data_dir(&self) -> &Path {
46        &self.data_dir
47    }
48
49    /// Resolve a [`SegmentPath`] to an absolute filesystem path.
50    #[must_use]
51    pub fn resolve(&self, path: &SegmentPath) -> PathBuf {
52        path.to_fs_path(&self.data_dir)
53    }
54
55    /// Ensure the namespace/shard directory exists.
56    async fn ensure_shard_dir(&self, path: &SegmentPath) -> Result<()> {
57        let dir = self
58            .data_dir
59            .join(format!("ns_{}", path.namespace().as_str()))
60            .join(format!("shard_{}", path.shard_id().0));
61        fs::create_dir_all(&dir).await?;
62        Ok(())
63    }
64
65    /// Inner helper for [`put_segment`] — performs write-fsync-rename.
66    /// Separated so the caller can clean up the temp file on error.
67    async fn put_segment_inner(
68        &self,
69        temp_path: &Path,
70        target: &Path,
71        data: &[u8],
72        path: &SegmentPath,
73    ) -> Result<()> {
74        fs::write(temp_path, data).await?;
75
76        // Set file permissions (owner read/write only)
77        #[cfg(unix)]
78        {
79            use std::os::unix::fs::PermissionsExt;
80            let perms = std::fs::Permissions::from_mode(0o600);
81            fs::set_permissions(temp_path, perms).await?;
82        }
83
84        // Fsync the temp file to ensure data is durable before rename.
85        // Open with write access so fsync is POSIX-compliant even on
86        // kernels that ignore sync on read-only fds.
87        {
88            let file = fs::OpenOptions::new().write(true).open(temp_path).await?;
89            file.sync_all().await?;
90        }
91
92        fs::rename(temp_path, target).await?;
93
94        // Fsync the parent directory to ensure the rename is durable.
95        // Use spawn_blocking to avoid stalling the tokio worker on sync I/O.
96        if let Some(parent) = target.parent() {
97            let parent = parent.to_path_buf();
98            tokio::task::spawn_blocking(move || -> std::result::Result<(), std::io::Error> {
99                let dir = std::fs::File::open(&parent)?;
100                dir.sync_all()?;
101                Ok(())
102            })
103            .await
104            .map_err(|e| StorageError::Io(std::io::Error::other(e)))?
105            .map_err(StorageError::Io)?;
106        }
107
108        debug!(path = %path, bytes = data.len(), "segment written");
109        Ok(())
110    }
111}
112
113impl StorageBackend for LocalFsBackend {
114    async fn put_segment(&self, path: &SegmentPath, data: &[u8]) -> Result<()> {
115        self.ensure_shard_dir(path).await?;
116        let target = self.resolve(path);
117
118        // Atomic write: write to temp file, fsync, then rename.
119        // Use a process-wide atomic counter to guarantee uniqueness even
120        // under NTP clock adjustments or same-nanosecond concurrent calls.
121        //
122        // Create temp file in the same directory as the target
123        // so that `rename()` is always an atomic same-filesystem operation
124        // (avoids EXDEV errors across mount points). The temp filename uses
125        // only the file stem, not the full path, to stay within PATH_MAX.
126        static TEMP_COUNTER: AtomicU64 = AtomicU64::new(0);
127        let seq = TEMP_COUNTER.fetch_add(1, Ordering::Relaxed);
128        let pid = std::process::id();
129        let file_name = target.file_name().unwrap_or_default().to_string_lossy();
130        let temp_name = format!(".{file_name}.{seq}.{pid}.tmp");
131        let temp_path = target
132            .parent()
133            .unwrap_or_else(|| std::path::Path::new("."))
134            .join(temp_name);
135
136        match self
137            .put_segment_inner(&temp_path, &target, data, path)
138            .await
139        {
140            Ok(()) => Ok(()),
141            Err(e) => {
142                // Clean up the temp file on any error to avoid leaking it.
143                let _ = fs::remove_file(&temp_path).await;
144                Err(e)
145            }
146        }
147    }
148
149    async fn get_segment(&self, path: &SegmentPath) -> Result<Vec<u8>> {
150        let fs_path = self.resolve(path);
151        match fs::read(&fs_path).await {
152            Ok(data) => Ok(data),
153            Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
154                Err(StorageError::NotFound { path: fs_path })
155            }
156            Err(e) => Err(e.into()),
157        }
158    }
159
160    async fn get_range(&self, path: &SegmentPath, offset: u64, length: usize) -> Result<Vec<u8>> {
161        // Cap read length to prevent OOM from corrupted headers.
162        const MAX_RANGE_LEN: usize = 256 * 1024 * 1024; // 256 MiB
163        if length > MAX_RANGE_LEN {
164            return Err(StorageError::Io(std::io::Error::new(
165                std::io::ErrorKind::InvalidInput,
166                format!("get_range length {length} exceeds maximum {MAX_RANGE_LEN}"),
167            )));
168        }
169
170        let fs_path = self.resolve(path);
171
172        // Use positioned read (pread) to avoid seek/read race conditions.
173        // tokio::task::spawn_blocking is used because pread is a sync syscall.
174        let result = tokio::task::spawn_blocking(move || -> Result<Vec<u8>> {
175            let file = std::fs::File::open(&fs_path).map_err(|e| {
176                if e.kind() == std::io::ErrorKind::NotFound {
177                    StorageError::NotFound {
178                        path: fs_path.clone(),
179                    }
180                } else {
181                    StorageError::Io(e)
182                }
183            })?;
184
185            #[cfg(unix)]
186            {
187                use std::os::unix::fs::FileExt;
188                let mut buf = vec![0u8; length];
189                file.read_exact_at(&mut buf, offset)?;
190                Ok(buf)
191            }
192
193            #[cfg(not(unix))]
194            {
195                use std::io::{Read, Seek, SeekFrom};
196                let mut file = file;
197                file.seek(SeekFrom::Start(offset))?;
198                let mut buf = vec![0u8; length];
199                file.read_exact(&mut buf)?;
200                Ok(buf)
201            }
202        })
203        .await
204        .map_err(|e| StorageError::Io(std::io::Error::other(e)))?;
205
206        result
207    }
208
209    async fn delete_segment(&self, path: &SegmentPath) -> Result<()> {
210        let fs_path = self.resolve(path);
211        match fs::remove_file(&fs_path).await {
212            Ok(()) => {
213                debug!(path = %path, "segment deleted");
214                Ok(())
215            }
216            Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
217                debug!(path = %path, "segment already absent, treating as success");
218                Ok(())
219            }
220            Err(e) => Err(e.into()),
221        }
222    }
223
224    async fn list_segments(
225        &self,
226        namespace: &NamespaceId,
227        shard_id: &ShardId,
228    ) -> Result<Vec<SegmentPath>> {
229        let shard_dir = self
230            .data_dir
231            .join(format!("ns_{}", namespace.as_str()))
232            .join(format!("shard_{}", shard_id.0));
233
234        match fs::read_dir(&shard_dir).await {
235            Ok(mut entries) => {
236                let mut segments = Vec::new();
237                while let Some(entry) = entries.next_entry().await? {
238                    let file_name = entry.file_name();
239                    let name = file_name.to_string_lossy();
240                    if name.ends_with(".csx") {
241                        segments.push(SegmentPath::new(
242                            namespace.clone(),
243                            *shard_id,
244                            name.into_owned(),
245                        )?);
246                    }
247                }
248                segments.sort_by(|a, b| a.segment_name().cmp(b.segment_name()));
249                Ok(segments)
250            }
251            Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(Vec::new()),
252            Err(e) => Err(e.into()),
253        }
254    }
255
256    async fn exists(&self, path: &SegmentPath) -> Result<bool> {
257        let fs_path = self.resolve(path);
258        match fs::metadata(&fs_path).await {
259            Ok(_) => Ok(true),
260            Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(false),
261            Err(e) => Err(e.into()),
262        }
263    }
264}
265
266#[cfg(test)]
267mod tests {
268    use super::*;
269
270    fn default_ns() -> NamespaceId {
271        NamespaceId::default_namespace()
272    }
273
274    #[tokio::test]
275    async fn put_get_roundtrip() {
276        let dir = tempfile::tempdir().unwrap();
277        let backend = LocalFsBackend::new(dir.path());
278        let path = SegmentPath::new(default_ns(), ShardId(1), "test_segment.csx").unwrap();
279
280        let data = b"hello segment data";
281        backend.put_segment(&path, data).await.unwrap();
282
283        let read_back = backend.get_segment(&path).await.unwrap();
284        assert_eq!(read_back, data);
285    }
286
287    #[tokio::test]
288    async fn put_get_range_partial_read() {
289        let dir = tempfile::tempdir().unwrap();
290        let backend = LocalFsBackend::new(dir.path());
291        let path = SegmentPath::new(default_ns(), ShardId(2), "range_test.csx").unwrap();
292
293        let data = b"0123456789abcdef";
294        backend.put_segment(&path, data).await.unwrap();
295
296        // Read bytes 4..10
297        let partial = backend.get_range(&path, 4, 6).await.unwrap();
298        assert_eq!(partial, b"456789");
299
300        // Read from start
301        let start = backend.get_range(&path, 0, 4).await.unwrap();
302        assert_eq!(start, b"0123");
303    }
304
305    #[tokio::test]
306    async fn delete_then_exists_returns_false() {
307        let dir = tempfile::tempdir().unwrap();
308        let backend = LocalFsBackend::new(dir.path());
309        let path = SegmentPath::new(default_ns(), ShardId(1), "delete_me.csx").unwrap();
310
311        backend.put_segment(&path, b"data").await.unwrap();
312        assert!(backend.exists(&path).await.unwrap());
313
314        backend.delete_segment(&path).await.unwrap();
315        assert!(!backend.exists(&path).await.unwrap());
316    }
317
318    #[tokio::test]
319    async fn list_segments_scans_directory() {
320        let dir = tempfile::tempdir().unwrap();
321        let backend = LocalFsBackend::new(dir.path());
322        let ns = default_ns();
323        let shard = ShardId(5);
324
325        // Write 3 segments
326        for i in 0..3 {
327            let path = SegmentPath::new(ns.clone(), shard, format!("seg_{i:03}.csx")).unwrap();
328            backend.put_segment(&path, b"data").await.unwrap();
329        }
330
331        // Also write a non-.csx file (should be ignored)
332        let shard_dir = dir.path().join("ns_default").join("shard_5");
333        tokio::fs::write(shard_dir.join("not_a_segment.txt"), b"ignore")
334            .await
335            .unwrap();
336
337        let segments = backend.list_segments(&ns, &shard).await.unwrap();
338        assert_eq!(segments.len(), 3);
339        assert_eq!(segments[0].segment_name(), "seg_000.csx");
340        assert_eq!(segments[1].segment_name(), "seg_001.csx");
341        assert_eq!(segments[2].segment_name(), "seg_002.csx");
342    }
343
344    #[tokio::test]
345    async fn list_segments_empty_shard() {
346        let dir = tempfile::tempdir().unwrap();
347        let backend = LocalFsBackend::new(dir.path());
348        let segments = backend
349            .list_segments(&default_ns(), &ShardId(999))
350            .await
351            .unwrap();
352        assert!(segments.is_empty());
353    }
354
355    #[tokio::test]
356    async fn get_nonexistent_returns_not_found() {
357        let dir = tempfile::tempdir().unwrap();
358        let backend = LocalFsBackend::new(dir.path());
359        let path = SegmentPath::new(default_ns(), ShardId(1), "missing.csx").unwrap();
360
361        let err = backend.get_segment(&path).await.unwrap_err();
362        assert!(matches!(err, StorageError::NotFound { .. }));
363    }
364
365    #[tokio::test]
366    async fn delete_nonexistent_is_idempotent() {
367        let dir = tempfile::tempdir().unwrap();
368        let backend = LocalFsBackend::new(dir.path());
369        let path = SegmentPath::new(default_ns(), ShardId(1), "missing.csx").unwrap();
370
371        // Deleting a non-existent segment should succeed (idempotent).
372        backend.delete_segment(&path).await.unwrap();
373    }
374
375    #[tokio::test]
376    async fn exists_nonexistent_returns_false() {
377        let dir = tempfile::tempdir().unwrap();
378        let backend = LocalFsBackend::new(dir.path());
379        let path = SegmentPath::new(default_ns(), ShardId(1), "missing.csx").unwrap();
380        assert!(!backend.exists(&path).await.unwrap());
381    }
382
383    #[cfg(unix)]
384    #[tokio::test]
385    async fn put_segment_sets_permissions() {
386        use std::os::unix::fs::PermissionsExt;
387
388        let dir = tempfile::tempdir().unwrap();
389        let backend = LocalFsBackend::new(dir.path());
390        let path = SegmentPath::new(default_ns(), ShardId(1), "perms.csx").unwrap();
391
392        backend.put_segment(&path, b"data").await.unwrap();
393
394        let fs_path = backend.resolve(&path);
395        let metadata = std::fs::metadata(&fs_path).unwrap();
396        let mode = metadata.permissions().mode() & 0o777;
397        assert_eq!(mode, 0o600);
398    }
399
400    #[tokio::test]
401    async fn put_segment_atomic() {
402        let dir = tempfile::tempdir().unwrap();
403        let backend = LocalFsBackend::new(dir.path());
404        let path = SegmentPath::new(default_ns(), ShardId(1), "atomic.csx").unwrap();
405
406        // Write segment
407        backend.put_segment(&path, b"data").await.unwrap();
408
409        // Verify no .tmp file remains
410        let shard_dir = dir.path().join("ns_default").join("shard_1");
411        let mut entries = tokio::fs::read_dir(&shard_dir).await.unwrap();
412        while let Some(entry) = entries.next_entry().await.unwrap() {
413            let name = entry.file_name().to_string_lossy().to_string();
414            assert!(
415                !std::path::Path::new(&name)
416                    .extension()
417                    .is_some_and(|ext| ext.eq_ignore_ascii_case("tmp")),
418                "temp file should not remain: {name}"
419            );
420        }
421    }
422
423    // ── Additional edge-case tests ────────────────────────────────
424
425    #[tokio::test]
426    async fn put_segment_overwrite() {
427        let dir = tempfile::tempdir().unwrap();
428        let backend = LocalFsBackend::new(dir.path());
429        let path = SegmentPath::new(default_ns(), ShardId(1), "ow.csx").unwrap();
430
431        backend.put_segment(&path, b"v1").await.unwrap();
432        backend.put_segment(&path, b"v2").await.unwrap();
433        let data = backend.get_segment(&path).await.unwrap();
434        assert_eq!(data, b"v2");
435    }
436
437    #[tokio::test]
438    async fn list_segments_sorted() {
439        let dir = tempfile::tempdir().unwrap();
440        let backend = LocalFsBackend::new(dir.path());
441        let ns = default_ns();
442
443        for name in &["c.csx", "a.csx", "b.csx"] {
444            let p = SegmentPath::new(ns.clone(), ShardId(1), *name).unwrap();
445            backend.put_segment(&p, b"x").await.unwrap();
446        }
447
448        let segs = backend.list_segments(&ns, &ShardId(1)).await.unwrap();
449        let names: Vec<_> = segs.iter().map(|s| s.segment_name().to_string()).collect();
450        assert_eq!(names, vec!["a.csx", "b.csx", "c.csx"]);
451    }
452
453    #[tokio::test]
454    async fn data_dir_accessor() {
455        let dir = tempfile::tempdir().unwrap();
456        let backend = LocalFsBackend::new(dir.path());
457        assert_eq!(backend.data_dir(), dir.path());
458    }
459
460    #[tokio::test]
461    async fn get_range_beyond_eof() {
462        let dir = tempfile::tempdir().unwrap();
463        let backend = LocalFsBackend::new(dir.path());
464        let path = SegmentPath::new(default_ns(), ShardId(1), "short.csx").unwrap();
465        backend.put_segment(&path, b"tiny").await.unwrap();
466
467        // Reading beyond EOF should return what's available or error
468        let result = backend.get_range(&path, 0, 1024).await;
469        // Either succeeds with partial data or errors — implementation defined
470        if let Ok(data) = result {
471            assert!(!data.is_empty());
472        }
473    }
474}