1use 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#[derive(Debug, Clone)]
30pub struct LocalFsBackend {
31 data_dir: PathBuf,
32}
33
34impl LocalFsBackend {
35 #[must_use]
37 pub fn new(data_dir: impl Into<PathBuf>) -> Self {
38 Self {
39 data_dir: data_dir.into(),
40 }
41 }
42
43 #[must_use]
45 pub fn data_dir(&self) -> &Path {
46 &self.data_dir
47 }
48
49 #[must_use]
51 pub fn resolve(&self, path: &SegmentPath) -> PathBuf {
52 path.to_fs_path(&self.data_dir)
53 }
54
55 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 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 #[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 {
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 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 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 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 const MAX_RANGE_LEN: usize = 256 * 1024 * 1024; 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 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 let partial = backend.get_range(&path, 4, 6).await.unwrap();
298 assert_eq!(partial, b"456789");
299
300 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 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 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 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 backend.put_segment(&path, b"data").await.unwrap();
408
409 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 #[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 let result = backend.get_range(&path, 0, 1024).await;
469 if let Ok(data) = result {
471 assert!(!data.is_empty());
472 }
473 }
474}