Skip to main content

objstore_r/
local.rs

1// Author: support@lituus.io
2// Copyright: lituus-io, all rights reserved
3//
4//! A directory as an object store.
5//!
6//! Each object is a file at `root/<key>` holding exactly the object's bytes,
7//! so a store can be copied to or from a bucket byte-for-byte.
8//!
9//! - **Atomic writes.** A body is written to a uniquely named temporary file in
10//!   the target directory, fsynced, then renamed over the key (and the
11//!   directory fsynced on unix). Readers see the old or the new body, never a
12//!   mix.
13//! - **Generations.** An object's generation is its modification time in
14//!   nanoseconds, *set explicitly* by the writer. Writes allocate generations
15//!   that strictly increase per key, in steps of the filesystem's timestamp
16//!   precision (probed at [`LocalDir::open`]), so coarse-timestamp filesystems
17//!   still distinguish every write.
18//! - **Conditions.** Writes and deletes hold an exclusive `File::lock` on
19//!   `root/.objstore.lock` while they check and apply, so conditional writes are
20//!   atomic across threads and processes on the same host. Reads take no lock.
21//! - **Nesting.** A filesystem cannot hold an object `k` and objects under
22//!   `k/` at once; such a write fails with [`Error::InvalidKey`]. Layouts that
23//!   keep objects at the leaves (the usual case) are unaffected.
24//! - **Blocking I/O.** Operations complete synchronously inside the returned
25//!   futures (bodies here are snapshots and log segments, and local disk is
26//!   fast); keep very large stores off latency-critical executor threads.
27
28use crate::key::check_prefix;
29use crate::{clip, Bytes, Cond, Error, Key, Meta, Object, ObjectStore, Result};
30use graph_r::fsio;
31use std::fs::{self, File};
32use std::future::{ready, Ready};
33use std::io::{Read, Seek, SeekFrom};
34use std::ops::Range;
35use std::path::{Path, PathBuf};
36
37use std::sync::Mutex;
38use std::time::{Duration, SystemTime, UNIX_EPOCH};
39
40const LOCK_FILE: &str = ".objstore.lock";
41const PROBE_FILE: &str = ".objstore.probe";
42
43/// A directory-backed [`ObjectStore`].
44#[derive(Debug)]
45pub struct LocalDir {
46    root: PathBuf,
47    /// `root`, canonicalized: every object path must resolve inside it.
48    canon: PathBuf,
49    /// Timestamp precision of the filesystem, in nanoseconds.
50    precision: u64,
51    /// Highest generation this handle has issued (keeps delete-then-recreate
52    /// from reusing a generation within the process).
53    last: Mutex<u64>,
54}
55
56impl LocalDir {
57    /// Open (creating if needed) a store rooted at `root`.
58    pub fn open(root: impl AsRef<Path>) -> Result<Self> {
59        let root = root.as_ref().to_path_buf();
60        fs::create_dir_all(&root)?;
61        let canon = root.canonicalize()?;
62        let precision = probe_precision(&root)?;
63        Ok(Self { root, canon, precision, last: Mutex::new(0) })
64    }
65
66    /// The root directory.
67    #[must_use]
68    pub fn root(&self) -> &Path {
69        &self.root
70    }
71
72    /// The probed timestamp precision in nanoseconds (1 on most modern filesystems).
73    #[must_use]
74    pub fn precision_ns(&self) -> u64 {
75        self.precision
76    }
77
78    fn path(&self, key: Key<'_>) -> PathBuf {
79        let mut p = self.root.clone();
80        p.extend(key.as_str().split('/'));
81        p
82    }
83
84    /// Refuse paths whose existing directories resolve outside the root (a
85    /// symlink planted inside the store must not redirect reads or writes).
86    fn contained(&self, dir: &Path) -> Result<()> {
87        let mut probe = dir;
88        // The nearest existing ancestor decides (missing parts are created
89        // below it, so they cannot escape).
90        loop {
91            match probe.canonicalize() {
92                Ok(c) if c.starts_with(&self.canon) => return Ok(()),
93                Ok(_) => {
94                    return Err(Error::InvalidKey { reason: "resolves outside the store root" })
95                }
96                Err(e) if absent(&e) => match probe.parent() {
97                    Some(p) => probe = p,
98                    None => {
99                        return Err(Error::InvalidKey { reason: "resolves outside the store root" })
100                    }
101                },
102                Err(e) => return Err(e.into()),
103            }
104        }
105    }
106
107    fn lock(&self) -> Result<File> {
108        Ok(fsio::lock(&self.root.join(LOCK_FILE))?)
109    }
110
111    fn next_generation(&self, current: Option<u64>) -> u64 {
112        let p = self.precision;
113        let now = SystemTime::now()
114            .duration_since(UNIX_EPOCH)
115            .map_or(0, |d| u64::try_from(d.as_nanos()).unwrap_or(u64::MAX));
116        let mut last = self.last.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
117        let floor = current.unwrap_or(0).max(*last).saturating_add(p);
118        let g = round_up(now.max(floor), p).max(p);
119        *last = g;
120        g
121    }
122
123    fn put_sync(&self, key: &str, body: &Bytes, cond: Cond) -> Result<u64> {
124        let key = Key::parse(key)?;
125        let path = self.path(key);
126        self.contained(path.parent().unwrap_or(&self.root))?;
127        let _lock = self.lock()?;
128        let current = generation_of(&path)?;
129        match (cond, current) {
130            (Cond::CreateOnly, Some(_)) => return Err(Error::Conflict),
131            (Cond::IfGeneration(want), cur) if cur != Some(want) => {
132                return Err(Error::PreconditionFailed)
133            }
134            _ => {}
135        }
136        let dir = path.parent().unwrap_or(&self.root);
137        self.contained(dir)?;
138        fs::create_dir_all(dir).map_err(|e| match e.kind() {
139            std::io::ErrorKind::NotADirectory | std::io::ErrorKind::AlreadyExists => COLLISION,
140            _ => e.into(),
141        })?;
142        self.contained(dir)?;
143        let generation = self.next_generation(current);
144        // The generation is the file's modification time, stamped before the
145        // rename makes the object visible.
146        let stamp = UNIX_EPOCH + Duration::from_nanos(generation);
147        fsio::write_atomic_with(&path, &[body], |f| f.set_modified(stamp))?;
148        // Read back what the filesystem recorded: the generation callers will see.
149        Ok(generation_of(&path)?.unwrap_or(generation))
150    }
151
152    fn list_sync(&self, prefix: &str) -> Result<Vec<Meta>> {
153        check_prefix(prefix)?;
154        // Walk only the deepest directory the prefix pins down.
155        let (dir_part, _) = prefix.rsplit_once('/').unwrap_or(("", prefix));
156        let mut start = self.root.clone();
157        if !dir_part.is_empty() {
158            start.extend(dir_part.split('/'));
159        }
160        let mut out = Vec::new();
161        let mut stack = vec![(start, dir_part.to_owned())];
162        while let Some((dir, rel)) = stack.pop() {
163            let entries = match fs::read_dir(&dir) {
164                Ok(e) => e,
165                Err(e) if absent(&e) => continue,
166                Err(e) => return Err(e.into()),
167            };
168            for entry in entries {
169                let entry = entry?;
170                let Some(name) = entry.file_name().to_str().map(str::to_owned) else { continue };
171                let key = if rel.is_empty() { name.clone() } else { format!("{rel}/{name}") };
172                if Key::parse(&key).is_err() {
173                    continue; // temporaries, lock/probe files, foreign names
174                }
175                let ft = entry.file_type()?; // symlinks are neither dirs nor files here: skipped
176                if ft.is_dir() {
177                    if prefix.starts_with(&format!("{key}/"))
178                        || key.starts_with(prefix)
179                        || prefix.is_empty()
180                    {
181                        stack.push((entry.path(), key));
182                    }
183                } else if ft.is_file() && key.starts_with(prefix) {
184                    let md = entry.metadata()?;
185                    out.push(Meta { key, size: md.len(), generation: mtime_generation(&md) });
186                }
187            }
188        }
189        out.sort();
190        Ok(out)
191    }
192}
193
194impl ObjectStore for LocalDir {
195    type Get<'a> = Ready<Result<Option<Object>>>;
196    type GetRange<'a> = Ready<Result<Bytes>>;
197    type Head<'a> = Ready<Result<Option<Meta>>>;
198    type Put<'a> = Ready<Result<u64>>;
199    type List<'a> = Ready<Result<Vec<Meta>>>;
200    type Delete<'a> = Ready<Result<()>>;
201
202    fn get<'a>(&'a self, key: &'a str) -> Self::Get<'a> {
203        ready((|| {
204            let path = self.path(Key::parse(key)?);
205            self.contained(path.parent().unwrap_or(&self.root))?;
206            let Some(mut f) = open_existing(&path)? else { return Ok(None) };
207            // Metadata and body from one handle: consistent even if a writer
208            // renames a new version over the path meanwhile.
209            let md = f.metadata()?;
210            let mut buf = Vec::with_capacity(usize::try_from(md.len()).unwrap_or(0));
211            f.read_to_end(&mut buf)?;
212            Ok(Some(Object { bytes: Bytes::from(buf), generation: mtime_generation(&md) }))
213        })())
214    }
215
216    fn get_range<'a>(&'a self, key: &'a str, range: Range<u64>) -> Self::GetRange<'a> {
217        ready((|| {
218            let path = self.path(Key::parse(key)?);
219            self.contained(path.parent().unwrap_or(&self.root))?;
220            let Some(mut f) = open_existing(&path)? else { return Err(Error::NotFound) };
221            let r = clip(&range, f.metadata()?.len());
222            let mut buf = vec![0; r.len()];
223            f.seek(SeekFrom::Start(r.start as u64))?;
224            f.read_exact(&mut buf)?;
225            Ok(Bytes::from(buf))
226        })())
227    }
228
229    fn head<'a>(&'a self, key: &'a str) -> Self::Head<'a> {
230        ready((|| {
231            let k = Key::parse(key)?;
232            let path = self.path(k);
233            self.contained(path.parent().unwrap_or(&self.root))?;
234            match fs::symlink_metadata(&path) {
235                Ok(md) if md.is_file() => Ok(Some(Meta {
236                    key: k.as_str().to_owned(),
237                    size: md.len(),
238                    generation: mtime_generation(&md),
239                })),
240                Ok(_) => Ok(None),
241                Err(e) if absent(&e) => Ok(None),
242                Err(e) => Err(e.into()),
243            }
244        })())
245    }
246
247    fn put<'a>(&'a self, key: &'a str, body: Bytes, cond: Cond) -> Self::Put<'a> {
248        ready(self.put_sync(key, &body, cond))
249    }
250
251    fn list<'a>(&'a self, prefix: &'a str) -> Self::List<'a> {
252        ready(self.list_sync(prefix))
253    }
254
255    fn delete<'a>(&'a self, key: &'a str) -> Self::Delete<'a> {
256        ready((|| {
257            let path = self.path(Key::parse(key)?);
258            self.contained(path.parent().unwrap_or(&self.root))?;
259            let _lock = self.lock()?;
260            // Only a regular file is an object: directories and symlinks stay.
261            match fs::symlink_metadata(&path) {
262                Ok(md) if md.is_file() => {}
263                Ok(_) => return Ok(()),
264                Err(e) if absent(&e) => return Ok(()),
265                Err(e) => return Err(e.into()),
266            }
267            match fs::remove_file(&path) {
268                Ok(()) => {}
269                Err(e) if absent(&e) => return Ok(()),
270                Err(e) => return Err(e.into()),
271            }
272            // Prune directories the delete emptied, so the key space frees up
273            // (`k` is writable again once nothing is left under `k/`).
274            let mut dir = path.parent();
275            while let Some(d) = dir.filter(|d| *d != self.root) {
276                if fs::remove_dir(d).is_err() {
277                    break; // not empty (or raced): stop
278                }
279                dir = d.parent();
280            }
281            fsio::sync_dir(dir.unwrap_or(&self.root));
282            Ok(())
283        })())
284    }
285}
286
287/// A filesystem cannot hold both `a` and `a/b`; object stores can.
288const COLLISION: Error = Error::InvalidKey {
289    reason: "an object and a prefix collide (LocalDir cannot hold both `k` and `k/...`)",
290};
291
292/// Missing, or a path through an object as if it were a directory.
293fn absent(e: &std::io::Error) -> bool {
294    matches!(e.kind(), std::io::ErrorKind::NotFound | std::io::ErrorKind::NotADirectory)
295}
296
297fn open_existing(path: &Path) -> Result<Option<File>> {
298    // An object is a regular file; a symlink at an object path is never followed.
299    match fs::symlink_metadata(path) {
300        Ok(md) if !md.file_type().is_file() => return Ok(None),
301        Ok(_) => {}
302        Err(e) if absent(&e) => return Ok(None),
303        Err(e) => return Err(e.into()),
304    }
305    match File::open(path) {
306        Ok(f) if f.metadata()?.is_file() => Ok(Some(f)),
307        Ok(_) => Ok(None),
308        Err(e) if absent(&e) => Ok(None),
309        Err(e) => Err(e.into()),
310    }
311}
312
313fn generation_of(path: &Path) -> Result<Option<u64>> {
314    match fs::symlink_metadata(path) {
315        Ok(md) if md.is_file() => Ok(Some(mtime_generation(&md))),
316        Ok(md) if md.is_dir() => Err(COLLISION),
317        Ok(_) => Err(Error::Protocol {
318            message: "a non-file entry exists where an object key is".into(),
319        }),
320        Err(e) if absent(&e) => Ok(None),
321        Err(e) => Err(e.into()),
322    }
323}
324
325fn mtime_generation(md: &fs::Metadata) -> u64 {
326    md.modified()
327        .ok()
328        .and_then(|t| t.duration_since(UNIX_EPOCH).ok())
329        .map_or(1, |d| u64::try_from(d.as_nanos()).unwrap_or(u64::MAX).max(1))
330}
331
332fn round_up(v: u64, step: u64) -> u64 {
333    v.div_ceil(step).saturating_mul(step)
334}
335
336/// The filesystem's timestamp precision: set a known nanosecond mtime and see
337/// what survives.
338fn probe_precision(root: &Path) -> Result<u64> {
339    const PROBE_NS: u64 = 1_700_000_000_123_456_789;
340    let path = root.join(PROBE_FILE);
341    let f = File::create(&path)?;
342    f.set_modified(UNIX_EPOCH + Duration::from_nanos(PROBE_NS))?;
343    drop(f);
344    let got = mtime_generation(&fs::metadata(&path)?);
345    let _ = fs::remove_file(&path);
346    for step in [
347        1,
348        10,
349        100,
350        1_000,
351        10_000,
352        100_000,
353        1_000_000,
354        10_000_000,
355        100_000_000,
356        1_000_000_000,
357        2_000_000_000,
358    ] {
359        if got == PROBE_NS / step * step {
360            return Ok(step);
361        }
362    }
363    Ok(2_000_000_000)
364}
365
366#[cfg(test)]
367mod tests {
368    use super::*;
369    use futures::executor::block_on;
370
371    #[test]
372    fn generations_increase_per_key_even_when_written_fast() {
373        let dir = tempfile::tempdir().unwrap();
374        let s = LocalDir::open(dir.path()).unwrap();
375        let mut last = 0;
376        for i in 0..50u8 {
377            let g = block_on(s.put("k", Bytes::from(vec![i]), Cond::None)).unwrap();
378            assert!(g > last, "generation {g} after {last}");
379            last = g;
380        }
381        assert_eq!(block_on(s.head("k")).unwrap().unwrap().generation, last);
382    }
383
384    #[test]
385    fn no_temporaries_left_and_bytes_are_exact() {
386        let dir = tempfile::tempdir().unwrap();
387        let s = LocalDir::open(dir.path()).unwrap();
388        block_on(s.put("a/b/c.bin", Bytes::from_static(b"\x00\x01payload"), Cond::CreateOnly))
389            .unwrap();
390        assert_eq!(fs::read(dir.path().join("a/b/c.bin")).unwrap(), b"\x00\x01payload");
391        let names: Vec<_> =
392            fs::read_dir(dir.path().join("a/b")).unwrap().map(|e| e.unwrap().file_name()).collect();
393        assert_eq!(names, ["c.bin"]);
394    }
395}