Skip to main content

fs_transaction/
ordered.rs

1//! Ordered writes — durability and ordering without atomicity, for trees where
2//! every prefix is a legal state.
3//!
4//! A [`ChangeSet`](crate::ChangeSet) buys all-or-nothing: a journal, rollback,
5//! and a recovery precondition, because for the trees it serves a half-applied
6//! set is illegal and must be impossible to observe. Not every tree is like
7//! that. An append-only store — content-addressed blobs, operation logs, a
8//! write-once history — declares *every partially-written batch legal*: files
9//! that arrived without the record that names them are exactly what any
10//! interrupted transfer leaves, and the format already has a name for that
11//! state. Buying atomicity there pays for a journal to rule out states that
12//! were never illegal, and worse, it plants that journal (and a
13//! recover-before-read obligation) in a tree that never asked for either.
14//!
15//! What such a tree *does* need is narrower, and this module is exactly it:
16//!
17//! - **Ordering.** Nothing durable may name anything that is not durable yet.
18//!   The record naming a payload must never survive a crash the payload did
19//!   not — a store where it could would hold a name pointing at nothing,
20//!   which *is* an illegal state.
21//! - **Durability**, when asked for: once [`apply`](OrderedBatch::apply)
22//!   returns, the batch survives a power cut.
23//!
24//! ## The protocol
25//!
26//! An [`OrderedBatch`] is tiers of writes separated by
27//! [`barrier`](OrderedBatch::barrier)s. Within a tier nothing is ordered —
28//! payloads may land in any order, because nothing names them yet. At each
29//! barrier, everything staged so far is ordered ([`Durability::Ordered`])
30//! ahead of everything after it: each tier's files are flushed to the barrier
31//! and each directory that gained an entry is flushed too, since a name in a
32//! directory is its own write and persists separately from the bytes it
33//! names. The final tier is flushed to whatever `finality` the caller asks —
34//! [`Durable`](Durability::Durable) for "this write survives power loss",
35//! [`Ordered`](Durability::Ordered) for "consistent, but the tail may be
36//! lost with the crash that interrupted it".
37//!
38//! A crash therefore leaves **some prefix of the barriers**: every tier
39//! before the interruption whole and durable, the interrupted tier possibly
40//! partial, everything after it absent. For the store shaped as above, every
41//! one of those states is a state it already tolerates.
42//!
43//! ## What a partial tier can hold
44//!
45//! The two op kinds degrade differently inside the interrupted tier, and the
46//! difference is the port's, honestly inherited:
47//!
48//! - A [`write`](OrderedBatch::write) lands through the backend's own
49//!   [`Storage::replace`] — its override included — so a crash shows the
50//!   whole old file or the whole new one wherever the backend can promise
51//!   that, and the documented degrade where it cannot. Its flush rides with
52//!   the tier's, like everything else here: the bytes are barriered inside
53//!   the call, and the entry that publishes them is the directory flush's to
54//!   carry.
55//! - A [`create_new`](OrderedBatch::create_new) is an exclusive create under
56//!   its final name — decision-grade for concurrency (two writers racing to
57//!   one name see one winner), but a crash mid-write can leave the newest
58//!   tier's file **torn**. A consumer whose names promise their contents
59//!   (a digest-named blob) must be able to recognize and discard a torn
60//!   file nothing names yet; the barrier guarantees the "nothing names it
61//!   yet" half.
62//!
63//! ## What this does not do
64//!
65//! No journal, no rollback, no recovery step, no stale-journal refusal. An
66//! error mid-apply returns immediately and the tree holds a consistent
67//! prefix — the same shape a crash leaves — for the caller to complete,
68//! retry, or garbage-collect on its own terms. Single writer, like everything
69//! in this crate: two appliers against one tree race, and exclusivity beyond
70//! one `create_new` name is the caller's to arrange.
71
72use std::collections::BTreeSet;
73use std::path::{Path, PathBuf};
74
75use crate::error::Result;
76use crate::fs::{Durability, Storage, parent_dir};
77use crate::path::guard_in_root;
78
79/// One staged op of an [`OrderedBatch`]. Paths are **root-relative**, joined
80/// onto the root at [`apply`](OrderedBatch::apply) time, exactly as
81/// [`FileOp`](crate::FileOp)'s are.
82#[derive(Debug, Clone, PartialEq, Eq)]
83pub enum BatchOp {
84    /// Write `bytes` to `path`, creating it (and any missing parent directory)
85    /// or replacing it wholesale — through [`Storage::replace`], the backend's
86    /// own protocol, override included, so the atomicity promise is exactly
87    /// the backend's
88    /// ([`atomic_replace`](crate::fs::Capabilities::atomic_replace)). Its
89    /// durability is the tier's, batched with everything else's.
90    Write {
91        /// The file to write.
92        path: PathBuf,
93        /// Its full new contents.
94        bytes: Vec<u8>,
95    },
96    /// Create `path`, which must not already exist, holding `bytes` — through
97    /// [`Storage::create_new`], so the exclusivity is the backend's own.
98    ///
99    /// An occupied path surfaces as
100    /// [`AlreadyExists`](std::io::ErrorKind::AlreadyExists) and stops the
101    /// apply where it stands; what the collision *means* — a race lost
102    /// harmlessly to a writer producing the same bytes, or a real conflict —
103    /// is the caller's to decide, because only the caller knows whether its
104    /// names promise their contents.
105    CreateNew {
106        /// The file to create.
107        path: PathBuf,
108        /// Its contents.
109        bytes: Vec<u8>,
110    },
111}
112
113impl BatchOp {
114    /// The file this op lands at. What a dry run lists.
115    pub fn path(&self) -> &Path {
116        match self {
117            BatchOp::Write { path, .. } | BatchOp::CreateNew { path, .. } => path,
118        }
119    }
120}
121
122/// Tiers of writes separated by barriers, applied in order by
123/// [`apply`](OrderedBatch::apply): within a tier nothing is ordered, across a
124/// [`barrier`](OrderedBatch::barrier) everything is.
125///
126/// Built the way a store writes: payloads and records staged into the tier
127/// they belong to, a barrier wherever a later write will *name* an earlier
128/// one. The batch is a value describing writes without performing them, so —
129/// like a [`ChangeSet`](crate::ChangeSet) — it is equally the answer to "what
130/// would this do?": [`tiers`](OrderedBatch::tiers) is the dry-run view, and it
131/// is the same sequence `apply` executes.
132///
133/// ```
134/// use fs_transaction::{OrderedBatch, InMemoryFs, exec::block_on};
135/// use fs_transaction::fs::Durability;
136/// use std::path::Path;
137///
138/// let mut batch = OrderedBatch::new();
139/// batch.create_new("blobs/9f86d081", "payload");
140/// batch.create_new("ops/3a7bd3e2.op", "op-document");
141/// batch.barrier(); // nothing below may be seen without everything above
142/// batch.create_new("revisions/50d858e0.rev", "the record naming both");
143/// block_on(batch.apply(&InMemoryFs::new(), Path::new("store"), Durability::Durable))?;
144/// # Ok::<(), fs_transaction::Error>(())
145/// ```
146#[derive(Debug, Clone, Default, PartialEq, Eq)]
147pub struct OrderedBatch {
148    tiers: Vec<Vec<BatchOp>>,
149}
150
151impl OrderedBatch {
152    /// An empty batch.
153    pub fn new() -> Self {
154        Self::default()
155    }
156
157    /// Stage a write of `contents` to `path` (root-relative) in the current
158    /// tier, creating or atomically replacing the file.
159    pub fn write(&mut self, path: impl Into<PathBuf>, contents: impl Into<Vec<u8>>) -> &mut Self {
160        self.stage(BatchOp::Write {
161            path: path.into(),
162            bytes: contents.into(),
163        })
164    }
165
166    /// Stage an exclusive create of `path` (root-relative) holding `contents`
167    /// in the current tier. See [`BatchOp::CreateNew`] for what an occupied
168    /// path does to the apply.
169    pub fn create_new(
170        &mut self,
171        path: impl Into<PathBuf>,
172        contents: impl Into<Vec<u8>>,
173    ) -> &mut Self {
174        self.stage(BatchOp::CreateNew {
175            path: path.into(),
176            bytes: contents.into(),
177        })
178    }
179
180    /// Close the current tier: everything staged so far must land before
181    /// anything staged after this call. Adjacent barriers, and barriers at the
182    /// edges, cost nothing — an empty tier orders nothing and is skipped.
183    pub fn barrier(&mut self) -> &mut Self {
184        if !self.tiers.last().is_none_or(Vec::is_empty) {
185            self.tiers.push(Vec::new());
186        }
187        self
188    }
189
190    /// The staged tiers, in execution order. The dry-run view; a trailing
191    /// empty tier from a final [`barrier`](OrderedBatch::barrier) never
192    /// appears, since barriers only exist *between* writes.
193    pub fn tiers(&self) -> &[Vec<BatchOp>] {
194        match self.tiers.split_last() {
195            Some((last, rest)) if last.is_empty() => rest,
196            _ => &self.tiers,
197        }
198    }
199
200    /// Whether nothing is staged — [`apply`](OrderedBatch::apply) would be a
201    /// no-op.
202    pub fn is_empty(&self) -> bool {
203        self.tiers.iter().all(Vec::is_empty)
204    }
205
206    /// The number of staged ops, across every tier.
207    pub fn len(&self) -> usize {
208        self.tiers.iter().map(Vec::len).sum()
209    }
210
211    fn stage(&mut self, op: BatchOp) -> &mut Self {
212        match self.tiers.last_mut() {
213            Some(tier) => tier.push(op),
214            None => self.tiers.push(vec![op]),
215        }
216        self
217    }
218
219    /// Execute every staged op against `fs`, rooted at `root`, tier by tier:
220    /// each tier's files and freshly-named directories are flushed
221    /// [`Ordered`](Durability::Ordered) before the next tier begins, and the
222    /// last tier is flushed to `finality`.
223    ///
224    /// `finality` is the strength of the batch's own landing:
225    /// [`Durable`](Durability::Durable) makes "this call returned" mean "this
226    /// batch survives a power cut", [`Ordered`](Durability::Ordered) makes it
227    /// mean only "no crash shows a later tier without an earlier one" — the
228    /// batch itself may vanish with the crash, wholly or from some barrier
229    /// on, and for a caller that treats its tree as append-only that is often
230    /// enough, at the cost of not one `Durable` request anywhere. (Whether a
231    /// request is literally a drain is the backend's affair: without
232    /// `barrier-fsync`, `StdFs` answers even `Ordered` with the full flush —
233    /// stronger than asked, as ever.)
234    ///
235    /// On an error the apply stops where it stands and the tree holds a
236    /// consistent prefix — see the module docs for exactly what that means
237    /// inside the interrupted tier. Every staged path is clamped to the root
238    /// before anything is written, on the same terms as
239    /// [`ChangeSet::apply`](crate::ChangeSet::apply).
240    pub async fn apply<FS: Storage>(
241        &self,
242        fs: &FS,
243        root: &Path,
244        finality: Durability,
245    ) -> Result<()> {
246        for op in self.tiers.iter().flatten() {
247            guard_in_root(op.path())?;
248        }
249        let tiers: Vec<&Vec<BatchOp>> = self.tiers.iter().filter(|t| !t.is_empty()).collect();
250        let Some((last, earlier)) = tiers.split_last() else {
251            return Ok(());
252        };
253
254        for tier in earlier {
255            apply_tier(fs, root, tier, Durability::Ordered).await?;
256        }
257        apply_tier(fs, root, last, finality).await
258    }
259}
260
261/// Land one tier and flush it to `need`.
262///
263/// Files first, in staged order; then one flush per file that still owes one;
264/// then one flush per directory an op landed in (gained a name or not — an
265/// extra barrier on an unchanged directory costs less than proving it
266/// unchanged). The directory flushes come last because they are what publish
267/// the tier's *names* — a name must never be ordered ahead of the bytes it
268/// stands for.
269async fn apply_tier<FS: Storage>(
270    fs: &FS,
271    root: &Path,
272    tier: &[BatchOp],
273    need: Durability,
274) -> Result<()> {
275    let atomic_replace = fs.capabilities().atomic_replace;
276    // `BTreeSet` for a deterministic flush order — nothing correctness-shaped
277    // hangs on it, but a deterministic apply is one a fault-injection test can
278    // pin down.
279    let mut dirs: BTreeSet<PathBuf> = BTreeSet::new();
280    let mut flush: Vec<PathBuf> = Vec::new();
281
282    for op in tier {
283        let full = root.join(op.path());
284        if let Some(dir) = parent_dir(&full) {
285            dirs.insert(dir.to_path_buf());
286            // Directories the op's path freshly mints are entries of their
287            // own, each persisting separately from the file that prompted
288            // them — so every one of them (and the pre-existing ancestor
289            // that received the topmost new name) joins the tier's flush
290            // list, or a power cut takes the whole chain back out from
291            // under a durably-flushed file.
292            for made in crate::fs::create_dir_all_traced(fs, dir).await? {
293                dirs.insert(made);
294            }
295        }
296        match op {
297            // Through the backend's own `replace` — override included, so a
298            // native atomic replacement (a locked in-memory swap, a
299            // transactional store) is honored rather than bypassed. The
300            // bytes are barriered inside the call; the rename-published
301            // entry rides on the tier's directory flush, and a backend that
302            // cannot replace atomically leaves its plainly-written bytes as
303            // an extra debt for the same flush.
304            BatchOp::Write { bytes, .. } => {
305                fs.replace(&full, bytes).await?;
306                if !atomic_replace {
307                    flush.push(full);
308                }
309            }
310            BatchOp::CreateNew { bytes, .. } => {
311                fs.create_new(&full, bytes).await?;
312                flush.push(full);
313            }
314        }
315    }
316    // The tier's whole debt in one pass: files first, then the directories
317    // that publish their names. An `Ordered` tier is barriers throughout; a
318    // `Durable` one is barriers capped by a single drain — the corollary
319    // [`Durability::Ordered`] documents, cashed in.
320    let debts = flush.into_iter().chain(dirs);
321    match need {
322        Durability::Ordered => {
323            for path in debts {
324                fs.sync(&path, Durability::Ordered).await?;
325            }
326            Ok(())
327        }
328        Durability::Durable => Ok(crate::fs::flush_all_durable(fs, debts, root).await?),
329    }
330}
331
332#[cfg(test)]
333mod tests {
334    use super::*;
335    use crate::error::Error;
336    use crate::exec::block_on;
337    use crate::fs::{InMemoryFs, ReadStorage, StdFs};
338    use crate::fs_faults::{FailAtWrite, FsEvent, RecordingFs};
339
340    fn tmp(name: &str) -> PathBuf {
341        let dir = std::env::temp_dir().join(format!("fstx-ordered-{name}"));
342        let _ = std::fs::remove_dir_all(&dir);
343        std::fs::create_dir_all(&dir).unwrap();
344        dir
345    }
346
347    fn read(root: &Path, rel: &str) -> Option<String> {
348        std::fs::read_to_string(root.join(rel)).ok()
349    }
350
351    #[test]
352    fn lands_every_tier_in_order() {
353        let root = tmp("apply");
354        let mut batch = OrderedBatch::new();
355        batch.create_new("blobs/payload", "bytes");
356        batch.barrier();
357        batch.create_new("revisions/rev", "names the payload");
358        batch.write("bookmark", "points at the revision");
359        block_on(batch.apply(&StdFs, &root, Durability::Durable)).unwrap();
360
361        assert_eq!(read(&root, "blobs/payload").as_deref(), Some("bytes"));
362        assert_eq!(
363            read(&root, "revisions/rev").as_deref(),
364            Some("names the payload")
365        );
366        assert_eq!(
367            read(&root, "bookmark").as_deref(),
368            Some("points at the revision")
369        );
370    }
371
372    #[test]
373    fn flushes_each_tier_to_the_barrier_and_the_last_to_finality() {
374        // The protocol, pinned event by event: tier files flush `Ordered`
375        // with their directory after them, and only the last tier sees the
376        // caller's finality.
377        let root = tmp("events");
378        let fs = RecordingFs::local();
379        let mut batch = OrderedBatch::new();
380        batch.create_new("blobs/a", "a");
381        batch.barrier();
382        batch.create_new("rev", "names a");
383        block_on(batch.apply(&fs, &root, Durability::Durable)).unwrap();
384
385        assert_eq!(
386            fs.events(),
387            vec![
388                FsEvent::CreateNew(root.join("blobs/a")),
389                FsEvent::Sync(root.join("blobs/a"), Durability::Ordered),
390                // The root is flushed too: it gained the `blobs` entry, and
391                // a directory entry persists separately from what it names.
392                FsEvent::Sync(root.clone(), Durability::Ordered),
393                FsEvent::Sync(root.join("blobs"), Durability::Ordered),
394                FsEvent::CreateNew(root.join("rev")),
395                // The `Durable` tier is barriers capped by one drain — the
396                // corollary, cashed in: the directory flush at the end
397                // carries everything barriered before it.
398                FsEvent::Sync(root.join("rev"), Durability::Ordered),
399                FsEvent::Sync(root.clone(), Durability::Durable),
400            ]
401        );
402    }
403
404    #[test]
405    fn a_freshly_minted_directory_chain_is_flushed_link_by_link() {
406        // Every directory the op's path creates is an entry of its own — a
407        // durable file inside a chain of unflushed names is a file a power
408        // cut can orphan. The flush list must hold the whole chain plus the
409        // pre-existing ancestor that received the topmost new name.
410        let root = tmp("chain");
411        let fs = RecordingFs::local();
412        let mut batch = OrderedBatch::new();
413        batch.create_new("a/b/c/blob", "bytes");
414        block_on(batch.apply(&fs, &root, Durability::Durable)).unwrap();
415
416        for dir in [
417            root.clone(),
418            root.join("a"),
419            root.join("a/b"),
420            root.join("a/b/c"),
421        ] {
422            assert!(
423                fs.events()
424                    .iter()
425                    .any(|e| matches!(e, FsEvent::Sync(p, _) if *p == dir)),
426                "{} never flushed; events: {:?}",
427                dir.display(),
428                fs.events()
429            );
430        }
431        // Barriers throughout, capped by exactly one drain that makes them
432        // all durable — which must therefore come last.
433        let drains: Vec<usize> = fs
434            .events()
435            .iter()
436            .enumerate()
437            .filter(|(_, e)| matches!(e, FsEvent::Sync(_, Durability::Durable)))
438            .map(|(i, _)| i)
439            .collect();
440        assert_eq!(drains.len(), 1, "events: {:?}", fs.events());
441        assert_eq!(
442            drains[0],
443            fs.events().len() - 1,
444            "events: {:?}",
445            fs.events()
446        );
447    }
448
449    #[test]
450    fn an_ordered_finality_never_drains_the_device() {
451        // The cheap contract: consistent, but the tail may go with the crash.
452        // Not one `Durable` request anywhere — writes included, since a
453        // `replace`'s durability is the tier's to grant, and an `Ordered`
454        // tier grants none.
455        let root = tmp("ordered-finality");
456        let fs = RecordingFs::local();
457        let mut batch = OrderedBatch::new();
458        batch.create_new("blobs/a", "a");
459        batch.barrier();
460        batch.create_new("rev", "names a");
461        batch.write("bookmark", "points at rev");
462        block_on(batch.apply(&fs, &root, Durability::Ordered)).unwrap();
463
464        assert!(
465            fs.events()
466                .iter()
467                .all(|e| !matches!(e, FsEvent::Sync(_, Durability::Durable))),
468            "events: {:?}",
469            fs.events()
470        );
471    }
472
473    #[test]
474    fn a_replaced_write_lands_through_the_backends_replace() {
475        // Delegated, not re-implemented: over a backend that leaves the
476        // default in place this is the familiar temp-sibling staging, and
477        // its durability is the tier's single flush — not a per-file drain.
478        let root = tmp("write-durable");
479        std::fs::write(root.join("bookmark"), "old").unwrap();
480        let fs = RecordingFs::local();
481        let mut batch = OrderedBatch::new();
482        batch.write("bookmark", "new");
483        block_on(batch.apply(&fs, &root, Durability::Durable)).unwrap();
484
485        let tmp_name = crate::fs::temp_sibling(&root.join("bookmark"));
486        assert_eq!(
487            fs.events(),
488            vec![
489                FsEvent::Write(tmp_name.clone()),
490                FsEvent::Sync(tmp_name.clone(), Durability::Ordered),
491                FsEvent::Rename(tmp_name, root.join("bookmark")),
492                FsEvent::Sync(root.clone(), Durability::Durable),
493            ]
494        );
495        assert_eq!(read(&root, "bookmark").as_deref(), Some("new"));
496    }
497
498    #[test]
499    fn a_replaced_write_respects_a_backends_native_atomic_replace() {
500        // The delegation is the point: `InMemoryFs` overrides `write_atomic`
501        // with its locked single write, and its `rename` refuses to clobber —
502        // a batch that re-implemented the temp-then-rename dance would fail
503        // with AlreadyExists on exactly this, the commonest replace there is.
504        let fs = InMemoryFs::new();
505        block_on(fs.write(Path::new("store/bookmark"), b"old")).unwrap();
506        let mut batch = OrderedBatch::new();
507        batch.write("bookmark", "new");
508        block_on(batch.apply(&fs, Path::new("store"), Durability::Durable)).unwrap();
509        assert_eq!(
510            block_on(fs.read_to_string(Path::new("store/bookmark"))).unwrap(),
511            "new"
512        );
513    }
514
515    #[test]
516    fn an_error_leaves_a_consistent_prefix() {
517        // The whole contract on one failure: everything before the barrier
518        // stands, nothing after the failed op exists, and there is no journal
519        // anywhere asking to be recovered.
520        let root = tmp("prefix");
521        let mut batch = OrderedBatch::new();
522        batch.create_new("blobs/a", "a");
523        batch.barrier();
524        batch.create_new("rev", "never lands");
525        batch.create_new("after", "never reached");
526        let err =
527            block_on(batch.apply(&FailAtWrite::nth(1), &root, Durability::Durable)).unwrap_err();
528        assert!(err.to_string().contains("disk full"), "{err}");
529
530        assert_eq!(read(&root, "blobs/a").as_deref(), Some("a"));
531        assert_eq!(read(&root, "rev"), None);
532        assert_eq!(read(&root, "after"), None);
533        assert!(
534            !crate::journal::Journal::default().path_in(&root).exists(),
535            "an ordered batch must never plant a journal"
536        );
537    }
538
539    #[test]
540    fn an_occupied_create_surfaces_already_exists_and_stops() {
541        let root = tmp("occupied");
542        std::fs::create_dir_all(root.join("blobs")).unwrap();
543        std::fs::write(root.join("blobs/a"), "already here").unwrap();
544        let mut batch = OrderedBatch::new();
545        batch.create_new("blobs/a", "different bytes");
546        batch.barrier();
547        batch.create_new("rev", "never lands");
548        let err = block_on(batch.apply(&StdFs, &root, Durability::Durable)).unwrap_err();
549        match err {
550            Error::Io(e) => assert_eq!(e.kind(), std::io::ErrorKind::AlreadyExists),
551            other => panic!("expected Io(AlreadyExists), got {other:?}"),
552        }
553        assert_eq!(read(&root, "blobs/a").as_deref(), Some("already here"));
554        assert_eq!(read(&root, "rev"), None);
555    }
556
557    #[test]
558    fn a_path_escaping_the_root_is_refused_before_anything_lands() {
559        let root = tmp("escape");
560        let mut batch = OrderedBatch::new();
561        batch.create_new("fine", "fine");
562        batch.barrier();
563        batch.write("../outside", "never");
564        let err = block_on(batch.apply(&StdFs, &root, Durability::Durable)).unwrap_err();
565        assert!(matches!(err, Error::Escape(_)), "{err:?}");
566        assert_eq!(
567            read(&root, "fine"),
568            None,
569            "the guard must run before the first tier, not between tiers"
570        );
571    }
572
573    #[test]
574    fn barriers_at_the_edges_and_doubled_up_cost_nothing() {
575        let mut batch = OrderedBatch::new();
576        batch.barrier();
577        batch.create_new("a", "a");
578        batch.barrier();
579        batch.barrier();
580        batch.create_new("b", "b");
581        batch.barrier();
582        assert_eq!(batch.tiers().len(), 2);
583        assert_eq!(batch.len(), 2);
584
585        let fs = InMemoryFs::new();
586        block_on(batch.apply(&fs, Path::new("root"), Durability::Durable)).unwrap();
587        assert_eq!(
588            block_on(fs.read_to_string(Path::new("root/a"))).unwrap(),
589            "a"
590        );
591        assert_eq!(
592            block_on(fs.read_to_string(Path::new("root/b"))).unwrap(),
593            "b"
594        );
595    }
596
597    #[test]
598    fn an_empty_batch_applies_as_nothing() {
599        let fs = RecordingFs::local();
600        let batch = OrderedBatch::new();
601        assert!(batch.is_empty());
602        block_on(batch.apply(&fs, Path::new("/nonexistent"), Durability::Durable)).unwrap();
603        assert!(fs.events().is_empty());
604    }
605
606    #[test]
607    fn works_over_a_backend_that_cannot_flush_at_all() {
608        // `InMemoryFs` declines every sync; the batch still lands, it simply
609        // carries no crash promise — the capabilities said so.
610        let fs = InMemoryFs::new();
611        let mut batch = OrderedBatch::new();
612        batch.create_new("blobs/a", "a");
613        batch.barrier();
614        batch.create_new("rev", "names a");
615        block_on(batch.apply(&fs, Path::new("store"), Durability::Durable)).unwrap();
616        assert_eq!(
617            block_on(fs.read_to_string(Path::new("store/rev"))).unwrap(),
618            "names a"
619        );
620    }
621}