Skip to main content

objstore_r/
local.rs

1// Author: spicyzhug@gmail.com
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//! - **Content encoding.** An encoded object ([`ObjectStore::put_encoded`])
25//!   is still one file holding exactly its stored (compressed) bytes; the
26//!   encoding lives in a sidecar under `root/.objstore-encoding/<key>`, a
27//!   tree no key can name. Each sidecar line binds an encoding to one
28//!   *generation*, and is written before the body it describes is renamed
29//!   into place, so a lock-free reader always knows how to read the body it
30//!   holds: a line for its generation means encoded; no line, with the body
31//!   still at that generation, means plain; otherwise the body moved on and
32//!   the read is retried (the last attempt under the store lock). A store
33//!   that never held an encoded object has no sidecar tree, and its reads
34//!   pay one `stat` for that.
35//! - **Blocking I/O.** Operations complete synchronously inside the returned
36//!   futures (bodies here are snapshots and log segments, and local disk is
37//!   fast); keep very large stores off latency-critical executor threads.
38
39use crate::encoding::{self, ContentEncoding, MAX_INFLATED_BYTES};
40use crate::key::check_prefix;
41use crate::{clip, Bytes, Cond, Error, Key, Meta, Object, ObjectStore, Result, Stat};
42use graph_r::fsio;
43use std::fs::{self, File};
44use std::future::{ready, Ready};
45use std::io::{Read, Seek, SeekFrom};
46use std::ops::Range;
47use std::path::{Path, PathBuf};
48
49use std::sync::Mutex;
50use std::time::{Duration, SystemTime, UNIX_EPOCH};
51
52const LOCK_FILE: &str = ".objstore.lock";
53const PROBE_FILE: &str = ".objstore.probe";
54/// Root of the encoding sidecars (a leading `.`: never a key segment).
55const ENC_DIR: &str = ".objstore-encoding";
56/// A sidecar holds at most two short lines; anything longer is not ours.
57const MAX_SIDECAR: u64 = 256;
58/// Lock-free attempts at a read whose body moves under it; the last one
59/// holds the store lock.
60const READ_ATTEMPTS: u32 = 3;
61
62/// How the body at one generation is encoded, as the sidecar says.
63enum Seen {
64    Plain,
65    Encoded(ContentEncoding),
66    /// The body is no longer at the generation read: read again.
67    Moved,
68}
69
70/// A directory-backed [`ObjectStore`].
71#[derive(Debug)]
72pub struct LocalDir {
73    root: PathBuf,
74    /// `root`, canonicalized: every object path must resolve inside it.
75    canon: PathBuf,
76    /// Timestamp precision of the filesystem, in nanoseconds.
77    precision: u64,
78    /// Highest generation this handle has issued (keeps delete-then-recreate
79    /// from reusing a generation within the process).
80    last: Mutex<u64>,
81    /// Bound on a decoded body.
82    max_inflated: u64,
83}
84
85impl LocalDir {
86    /// Open (creating if needed) a store rooted at `root`.
87    pub fn open(root: impl AsRef<Path>) -> Result<Self> {
88        let root = root.as_ref().to_path_buf();
89        fs::create_dir_all(&root)?;
90        let canon = root.canonicalize()?;
91        let precision = probe_precision(&root)?;
92        Ok(Self { root, canon, precision, last: Mutex::new(0), max_inflated: MAX_INFLATED_BYTES })
93    }
94
95    /// Refuse to decode an encoded object past `limit` bytes
96    /// ([`Error::TooLarge`]); the default is [`MAX_INFLATED_BYTES`].
97    #[must_use]
98    pub fn with_max_inflated(mut self, limit: u64) -> Self {
99        self.max_inflated = limit;
100        self
101    }
102
103    /// The root directory.
104    #[must_use]
105    pub fn root(&self) -> &Path {
106        &self.root
107    }
108
109    /// The probed timestamp precision in nanoseconds (1 on most modern filesystems).
110    #[must_use]
111    pub fn precision_ns(&self) -> u64 {
112        self.precision
113    }
114
115    fn path(&self, key: Key<'_>) -> PathBuf {
116        let mut p = self.root.clone();
117        p.extend(key.as_str().split('/'));
118        p
119    }
120
121    /// Refuse paths whose existing directories resolve outside the root (a
122    /// symlink planted inside the store must not redirect reads or writes).
123    fn contained(&self, dir: &Path) -> Result<()> {
124        let mut probe = dir;
125        // The nearest existing ancestor decides (missing parts are created
126        // below it, so they cannot escape).
127        loop {
128            match probe.canonicalize() {
129                Ok(c) if c.starts_with(&self.canon) => return Ok(()),
130                Ok(_) => {
131                    return Err(Error::InvalidKey { reason: "resolves outside the store root" })
132                }
133                Err(e) if absent(&e) => match probe.parent() {
134                    Some(p) => probe = p,
135                    None => {
136                        return Err(Error::InvalidKey { reason: "resolves outside the store root" })
137                    }
138                },
139                Err(e) => return Err(e.into()),
140            }
141        }
142    }
143
144    fn sidecar(&self, key: Key<'_>) -> PathBuf {
145        let mut p = self.root.join(ENC_DIR);
146        p.extend(key.as_str().split('/'));
147        p
148    }
149
150    /// How the body read at `generation` is encoded (see the module docs).
151    fn seen(&self, key: Key<'_>, path: &Path, generation: u64) -> Result<Seen> {
152        // The sidecar tree is created before the first encoded body and never
153        // removed: without it, nothing here was ever encoded.
154        match fs::symlink_metadata(self.root.join(ENC_DIR)) {
155            Err(e) if absent(&e) => return Ok(Seen::Plain),
156            Err(e) => return Err(e.into()),
157            Ok(_) => {}
158        }
159        let side = self.sidecar(key);
160        self.contained(side.parent().unwrap_or(&self.root))?;
161        if let Some(token) = sidecar_entry(&side, generation)? {
162            return match ContentEncoding::parse(&token)? {
163                Some(e) => Ok(Seen::Encoded(e)),
164                None => Err(Error::corrupt("encoding sidecar names no encoding")),
165            };
166        }
167        // No line for it: a line is only ever dropped after its body was
168        // replaced, so a body still at `generation` was written plain.
169        Ok(match fs::symlink_metadata(path) {
170            Ok(md) if md.is_file() && mtime_generation(&md) == generation => Seen::Plain,
171            Ok(_) => Seen::Moved,
172            Err(e) if absent(&e) => Seen::Moved,
173            Err(e) => return Err(e.into()),
174        })
175    }
176
177    /// Read `key` whole with its generation and encoding, consistently
178    /// against concurrent writers.
179    fn read_whole(&self, key: Key<'_>) -> Result<Option<(Bytes, u64, Option<ContentEncoding>)>> {
180        let path = self.path(key);
181        self.contained(path.parent().unwrap_or(&self.root))?;
182        for attempt in 1..=READ_ATTEMPTS {
183            let _quiesce = if attempt == READ_ATTEMPTS { Some(self.lock()?) } else { None };
184            let Some(mut f) = open_existing(&path)? else { return Ok(None) };
185            // Metadata and body from one handle: consistent even if a writer
186            // renames a new version over the path meanwhile.
187            let md = f.metadata()?;
188            let generation = mtime_generation(&md);
189            let mut buf = Vec::with_capacity(usize::try_from(md.len()).unwrap_or(0));
190            f.read_to_end(&mut buf)?;
191            match self.seen(key, &path, generation)? {
192                Seen::Plain => return Ok(Some((Bytes::from(buf), generation, None))),
193                Seen::Encoded(e) => return Ok(Some((Bytes::from(buf), generation, Some(e)))),
194                Seen::Moved => {}
195            }
196        }
197        Err(Error::protocol("an object changed while the store lock was held"))
198    }
199
200    fn stat_sync(&self, key: Key<'_>) -> Result<Option<Stat>> {
201        let path = self.path(key);
202        self.contained(path.parent().unwrap_or(&self.root))?;
203        for attempt in 1..=READ_ATTEMPTS {
204            let _quiesce = if attempt == READ_ATTEMPTS { Some(self.lock()?) } else { None };
205            let md = match fs::symlink_metadata(&path) {
206                Ok(md) if md.is_file() => md,
207                Ok(_) => return Ok(None),
208                Err(e) if absent(&e) => return Ok(None),
209                Err(e) => return Err(e.into()),
210            };
211            let meta = Meta {
212                key: key.as_str().to_owned(),
213                size: md.len(),
214                generation: mtime_generation(&md),
215            };
216            let content_encoding = match self.seen(key, &path, meta.generation)? {
217                Seen::Plain => None,
218                Seen::Encoded(e) => Some(e),
219                Seen::Moved => continue,
220            };
221            return Ok(Some(Stat { meta, content_encoding }));
222        }
223        Err(Error::protocol("an object changed while the store lock was held"))
224    }
225
226    fn list_stat_sync(&self, prefix: &str) -> Result<Vec<Stat>> {
227        let metas = self.list_sync(prefix)?;
228        let mut out = Vec::with_capacity(metas.len());
229        for meta in metas {
230            let key = Key::parse(&meta.key)?;
231            let content_encoding = match self.seen(key, &self.path(key), meta.generation)? {
232                Seen::Plain => None,
233                Seen::Encoded(e) => Some(e),
234                // Rewritten since it was listed: report it as it is now.
235                Seen::Moved => {
236                    out.extend(self.stat_sync(key)?);
237                    continue;
238                }
239            };
240            out.push(Stat { meta, content_encoding });
241        }
242        Ok(out)
243    }
244
245    /// Remove `key`'s sidecar and the directories that leaves empty.
246    fn drop_sidecar(&self, key: Key<'_>) -> Result<()> {
247        let enc_root = self.root.join(ENC_DIR);
248        if !enc_root.is_dir() {
249            return Ok(());
250        }
251        let side = self.sidecar(key);
252        self.contained(side.parent().unwrap_or(&self.root))?;
253        match fs::remove_file(&side) {
254            Ok(()) => {}
255            Err(e) if absent(&e) => return Ok(()),
256            Err(e) => return Err(e.into()),
257        }
258        let mut dir = side.parent();
259        while let Some(d) = dir.filter(|d| *d != enc_root) {
260            if fs::remove_dir(d).is_err() {
261                break;
262            }
263            dir = d.parent();
264        }
265        Ok(())
266    }
267
268    fn lock(&self) -> Result<File> {
269        Ok(fsio::lock(&self.root.join(LOCK_FILE))?)
270    }
271
272    fn next_generation(&self, current: Option<u64>) -> u64 {
273        let p = self.precision;
274        let now = SystemTime::now()
275            .duration_since(UNIX_EPOCH)
276            .map_or(0, |d| u64::try_from(d.as_nanos()).unwrap_or(u64::MAX));
277        let mut last = self.last.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
278        let floor = current.unwrap_or(0).max(*last).saturating_add(p);
279        let g = round_up(now.max(floor), p).max(p);
280        *last = g;
281        g
282    }
283
284    fn put_sync(
285        &self,
286        key: &str,
287        body: &Bytes,
288        cond: Cond,
289        encoding: Option<ContentEncoding>,
290    ) -> Result<u64> {
291        let key = Key::parse(key)?;
292        let path = self.path(key);
293        self.contained(path.parent().unwrap_or(&self.root))?;
294        let _lock = self.lock()?;
295        let current = generation_of(&path)?;
296        match (cond, current) {
297            (Cond::CreateOnly, Some(_)) => return Err(Error::Conflict),
298            (Cond::IfGeneration(want), cur) if cur != Some(want) => {
299                return Err(Error::PreconditionFailed)
300            }
301            _ => {}
302        }
303        let dir = path.parent().unwrap_or(&self.root);
304        self.contained(dir)?;
305        fs::create_dir_all(dir).map_err(|e| match e.kind() {
306            std::io::ErrorKind::NotADirectory | std::io::ErrorKind::AlreadyExists => COLLISION,
307            _ => e.into(),
308        })?;
309        self.contained(dir)?;
310        // An encoded write records its line before the body becomes visible,
311        // keeping the line of the body it replaces (a reader may hold it).
312        let side = match encoding {
313            None => None,
314            Some(e) => {
315                let side = self.sidecar(key);
316                let sdir = side.parent().unwrap_or(&self.root);
317                self.contained(sdir)?;
318                fs::create_dir_all(sdir).map_err(|e| match e.kind() {
319                    std::io::ErrorKind::NotADirectory | std::io::ErrorKind::AlreadyExists => {
320                        COLLISION
321                    }
322                    _ => e.into(),
323                })?;
324                self.contained(sdir)?;
325                let kept = match current {
326                    Some(c) => sidecar_entry(&side, c)?.map(|t| format!("{c} {t}\n")),
327                    None => None,
328                };
329                Some((side, e, kept.unwrap_or_default()))
330            }
331        };
332        let generation = self.next_generation(current);
333        // The generation is the file's modification time, stamped before the
334        // rename makes the object visible.
335        let stamp = UNIX_EPOCH + Duration::from_nanos(generation);
336        let mut recorded = generation;
337        fsio::write_atomic_with(&path, &[body], |f| {
338            f.set_modified(stamp)?;
339            if let Some((side, e, kept)) = &side {
340                recorded = mtime_generation(&f.metadata()?);
341                fsio::write_atomic(side, &[format!("{recorded} {e}\n{kept}").as_bytes()])?;
342            }
343            Ok(())
344        })?;
345        // Read back what the filesystem recorded: the generation callers will see.
346        let visible = generation_of(&path)?.unwrap_or(generation);
347        if let Some((side, e, kept)) = &side {
348            if visible != recorded {
349                // The filesystem moved the stamp at rename: rebind the line.
350                fsio::write_atomic(side, &[format!("{visible} {e}\n{kept}").as_bytes()])?;
351            }
352        }
353        Ok(visible)
354    }
355
356    fn list_sync(&self, prefix: &str) -> Result<Vec<Meta>> {
357        check_prefix(prefix)?;
358        // Walk only the deepest directory the prefix pins down.
359        let (dir_part, _) = prefix.rsplit_once('/').unwrap_or(("", prefix));
360        let mut start = self.root.clone();
361        if !dir_part.is_empty() {
362            start.extend(dir_part.split('/'));
363        }
364        let mut out = Vec::new();
365        let mut stack = vec![(start, dir_part.to_owned())];
366        while let Some((dir, rel)) = stack.pop() {
367            let entries = match fs::read_dir(&dir) {
368                Ok(e) => e,
369                Err(e) if absent(&e) => continue,
370                Err(e) => return Err(e.into()),
371            };
372            for entry in entries {
373                let entry = entry?;
374                let Some(name) = entry.file_name().to_str().map(str::to_owned) else { continue };
375                let key = if rel.is_empty() { name.clone() } else { format!("{rel}/{name}") };
376                if Key::parse(&key).is_err() {
377                    continue; // temporaries, lock/probe files, foreign names
378                }
379                let ft = entry.file_type()?; // symlinks are neither dirs nor files here: skipped
380                if ft.is_dir() {
381                    if prefix.starts_with(&format!("{key}/"))
382                        || key.starts_with(prefix)
383                        || prefix.is_empty()
384                    {
385                        stack.push((entry.path(), key));
386                    }
387                } else if ft.is_file() && key.starts_with(prefix) {
388                    let md = entry.metadata()?;
389                    out.push(Meta { key, size: md.len(), generation: mtime_generation(&md) });
390                }
391            }
392        }
393        out.sort();
394        Ok(out)
395    }
396}
397
398impl ObjectStore for LocalDir {
399    type Get<'a> = Ready<Result<Option<Object>>>;
400    type GetRange<'a> = Ready<Result<Bytes>>;
401    type Head<'a> = Ready<Result<Option<Meta>>>;
402    type Put<'a> = Ready<Result<u64>>;
403    type List<'a> = Ready<Result<Vec<Meta>>>;
404    type Delete<'a> = Ready<Result<()>>;
405
406    fn get<'a>(&'a self, key: &'a str) -> Self::Get<'a> {
407        ready((|| {
408            let Some((stored, generation, encoding)) = self.read_whole(Key::parse(key)?)? else {
409                return Ok(None);
410            };
411            let bytes = match encoding {
412                None => stored,
413                Some(e) => encoding::decode(e, &stored, self.max_inflated)?,
414            };
415            Ok(Some(Object { bytes, generation }))
416        })())
417    }
418
419    fn get_range<'a>(&'a self, key: &'a str, range: Range<u64>) -> Self::GetRange<'a> {
420        ready((|| {
421            let k = Key::parse(key)?;
422            let path = self.path(k);
423            self.contained(path.parent().unwrap_or(&self.root))?;
424            for attempt in 1..=READ_ATTEMPTS {
425                let _quiesce = if attempt == READ_ATTEMPTS { Some(self.lock()?) } else { None };
426                let Some(mut f) = open_existing(&path)? else { return Err(Error::NotFound) };
427                let md = f.metadata()?;
428                match self.seen(k, &path, mtime_generation(&md))? {
429                    Seen::Encoded(e) => {
430                        return Err(Error::EncodedRange { encoding: e.as_str().into() })
431                    }
432                    Seen::Moved => continue,
433                    Seen::Plain => {}
434                }
435                let r = clip(&range, md.len());
436                let mut buf = vec![0; r.len()];
437                f.seek(SeekFrom::Start(r.start as u64))?;
438                f.read_exact(&mut buf)?;
439                return Ok(Bytes::from(buf));
440            }
441            Err(Error::protocol("an object changed while the store lock was held"))
442        })())
443    }
444
445    fn head<'a>(&'a self, key: &'a str) -> Self::Head<'a> {
446        ready((|| {
447            let k = Key::parse(key)?;
448            let path = self.path(k);
449            self.contained(path.parent().unwrap_or(&self.root))?;
450            match fs::symlink_metadata(&path) {
451                Ok(md) if md.is_file() => Ok(Some(Meta {
452                    key: k.as_str().to_owned(),
453                    size: md.len(),
454                    generation: mtime_generation(&md),
455                })),
456                Ok(_) => Ok(None),
457                Err(e) if absent(&e) => Ok(None),
458                Err(e) => Err(e.into()),
459            }
460        })())
461    }
462
463    fn put<'a>(&'a self, key: &'a str, body: Bytes, cond: Cond) -> Self::Put<'a> {
464        ready(self.put_sync(key, &body, cond, None))
465    }
466
467    fn list<'a>(&'a self, prefix: &'a str) -> Self::List<'a> {
468        ready(self.list_sync(prefix))
469    }
470
471    fn delete<'a>(&'a self, key: &'a str) -> Self::Delete<'a> {
472        ready((|| {
473            let path = self.path(Key::parse(key)?);
474            self.contained(path.parent().unwrap_or(&self.root))?;
475            let _lock = self.lock()?;
476            // Only a regular file is an object: directories and symlinks stay.
477            match fs::symlink_metadata(&path) {
478                Ok(md) if md.is_file() => {}
479                Ok(_) => return Ok(()),
480                Err(e) if absent(&e) => return Ok(()),
481                Err(e) => return Err(e.into()),
482            }
483            match fs::remove_file(&path) {
484                Ok(()) => {}
485                Err(e) if absent(&e) => return Ok(()),
486                Err(e) => return Err(e.into()),
487            }
488            // After the body: a reader holding it still finds its line.
489            self.drop_sidecar(Key::parse(key)?)?;
490            // Prune directories the delete emptied, so the key space frees up
491            // (`k` is writable again once nothing is left under `k/`).
492            let mut dir = path.parent();
493            while let Some(d) = dir.filter(|d| *d != self.root) {
494                if fs::remove_dir(d).is_err() {
495                    break; // not empty (or raced): stop
496                }
497                dir = d.parent();
498            }
499            fsio::sync_dir(dir.unwrap_or(&self.root));
500            Ok(())
501        })())
502    }
503
504    fn put_encoded<'a>(
505        &'a self,
506        key: &'a str,
507        body: Bytes,
508        cond: Cond,
509        encoding: ContentEncoding,
510    ) -> impl std::future::Future<Output = Result<u64>> + 'a {
511        // Validate before compressing; compress before taking the lock.
512        ready(Key::parse(key).and_then(|_| {
513            let stored = encoding::encode(encoding, &body)?;
514            self.put_sync(key, &stored, cond, Some(encoding))
515        }))
516    }
517
518    fn stat<'a>(
519        &'a self,
520        key: &'a str,
521    ) -> impl std::future::Future<Output = Result<Option<Stat>>> + 'a {
522        ready(Key::parse(key).and_then(|k| self.stat_sync(k)))
523    }
524
525    fn list_stat<'a>(
526        &'a self,
527        prefix: &'a str,
528    ) -> impl std::future::Future<Output = Result<Vec<Stat>>> + 'a {
529        ready(self.list_stat_sync(prefix))
530    }
531}
532
533/// The encoding token a sidecar binds to `generation`, if any. Lines are
534/// `{generation} {token}`, newest first; at most two.
535fn sidecar_entry(side: &Path, generation: u64) -> Result<Option<String>> {
536    // Never follow a symlink planted at a sidecar path, and never read more
537    // than a sidecar can hold.
538    let Some(f) = open_existing(side)? else { return Ok(None) };
539    let mut text = Vec::with_capacity(64);
540    f.take(MAX_SIDECAR + 1).read_to_end(&mut text)?;
541    if text.len() as u64 > MAX_SIDECAR {
542        return Err(Error::corrupt("encoding sidecar too long"));
543    }
544    let text =
545        std::str::from_utf8(&text).map_err(|_| Error::corrupt("encoding sidecar is not text"))?;
546    for line in text.lines() {
547        let (g, token) =
548            line.split_once(' ').ok_or_else(|| Error::corrupt("malformed encoding sidecar"))?;
549        let g: u64 = g.parse().map_err(|_| Error::corrupt("malformed encoding sidecar"))?;
550        if g == generation {
551            return Ok(Some(token.to_owned()));
552        }
553    }
554    Ok(None)
555}
556
557/// A filesystem cannot hold both `a` and `a/b`; object stores can.
558const COLLISION: Error = Error::InvalidKey {
559    reason: "an object and a prefix collide (LocalDir cannot hold both `k` and `k/...`)",
560};
561
562/// Missing, or a path through an object as if it were a directory.
563fn absent(e: &std::io::Error) -> bool {
564    matches!(e.kind(), std::io::ErrorKind::NotFound | std::io::ErrorKind::NotADirectory)
565}
566
567fn open_existing(path: &Path) -> Result<Option<File>> {
568    // An object is a regular file; a symlink at an object path is never followed.
569    match fs::symlink_metadata(path) {
570        Ok(md) if !md.file_type().is_file() => return Ok(None),
571        Ok(_) => {}
572        Err(e) if absent(&e) => return Ok(None),
573        Err(e) => return Err(e.into()),
574    }
575    match File::open(path) {
576        Ok(f) if f.metadata()?.is_file() => Ok(Some(f)),
577        Ok(_) => Ok(None),
578        Err(e) if absent(&e) => Ok(None),
579        Err(e) => Err(e.into()),
580    }
581}
582
583fn generation_of(path: &Path) -> Result<Option<u64>> {
584    match fs::symlink_metadata(path) {
585        Ok(md) if md.is_file() => Ok(Some(mtime_generation(&md))),
586        Ok(md) if md.is_dir() => Err(COLLISION),
587        Ok(_) => Err(Error::Protocol {
588            message: "a non-file entry exists where an object key is".into(),
589        }),
590        Err(e) if absent(&e) => Ok(None),
591        Err(e) => Err(e.into()),
592    }
593}
594
595fn mtime_generation(md: &fs::Metadata) -> u64 {
596    md.modified()
597        .ok()
598        .and_then(|t| t.duration_since(UNIX_EPOCH).ok())
599        .map_or(1, |d| u64::try_from(d.as_nanos()).unwrap_or(u64::MAX).max(1))
600}
601
602fn round_up(v: u64, step: u64) -> u64 {
603    v.div_ceil(step).saturating_mul(step)
604}
605
606/// The filesystem's timestamp precision: set a known nanosecond mtime and see
607/// what survives.
608fn probe_precision(root: &Path) -> Result<u64> {
609    const PROBE_NS: u64 = 1_700_000_000_123_456_789;
610    let path = root.join(PROBE_FILE);
611    let f = File::create(&path)?;
612    f.set_modified(UNIX_EPOCH + Duration::from_nanos(PROBE_NS))?;
613    drop(f);
614    let got = mtime_generation(&fs::metadata(&path)?);
615    let _ = fs::remove_file(&path);
616    for step in [
617        1,
618        10,
619        100,
620        1_000,
621        10_000,
622        100_000,
623        1_000_000,
624        10_000_000,
625        100_000_000,
626        1_000_000_000,
627        2_000_000_000,
628    ] {
629        if got == PROBE_NS / step * step {
630            return Ok(step);
631        }
632    }
633    Ok(2_000_000_000)
634}
635
636#[cfg(test)]
637mod tests {
638    use super::*;
639    use futures::executor::block_on;
640
641    #[test]
642    fn generations_increase_per_key_even_when_written_fast() {
643        let dir = tempfile::tempdir().unwrap();
644        let s = LocalDir::open(dir.path()).unwrap();
645        let mut last = 0;
646        for i in 0..50u8 {
647            let g = block_on(s.put("k", Bytes::from(vec![i]), Cond::None)).unwrap();
648            assert!(g > last, "generation {g} after {last}");
649            last = g;
650        }
651        assert_eq!(block_on(s.head("k")).unwrap().unwrap().generation, last);
652    }
653
654    #[test]
655    fn no_temporaries_left_and_bytes_are_exact() {
656        let dir = tempfile::tempdir().unwrap();
657        let s = LocalDir::open(dir.path()).unwrap();
658        block_on(s.put("a/b/c.bin", Bytes::from_static(b"\x00\x01payload"), Cond::CreateOnly))
659            .unwrap();
660        assert_eq!(fs::read(dir.path().join("a/b/c.bin")).unwrap(), b"\x00\x01payload");
661        let names: Vec<_> =
662            fs::read_dir(dir.path().join("a/b")).unwrap().map(|e| e.unwrap().file_name()).collect();
663        assert_eq!(names, ["c.bin"]);
664    }
665}