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}