Skip to main content

mkit_core/
batch.rs

1//! Batched-durability object writes.
2//!
3//! [`WriteBatch`] amortises the cost of crash durability across every
4//! object written by one logical command (an `add`, a `commit`, a pack
5//! unpack): objects are staged as barrier-synced temp files and become
6//! durable *and visible together* at [`WriteBatch::commit`], with **one**
7//! full flush per batch instead of two per object.
8//!
9//! # Durability contract
10//!
11//! The invariant the store actually needs is not "every object is
12//! durable the moment it is written" — it is:
13//!
14//! > A ref or index file is only ever written after every object it
15//! > references is durable, and a crash never produces a visible object
16//! > that fails the read-time hash check.
17//!
18//! `WriteBatch` preserves both halves:
19//!
20//! * Staged objects are **invisible** until `commit()` — renames are
21//!   deferred until after the batch's full flush, so another process's
22//!   `contains()` dedup can never observe (and then reference) an
23//!   object whose bytes are not yet durable.
24//! * `commit()` returns only after one full flush, every rename, and a
25//!   deduplicated flush of each touched shard directory. Callers MUST
26//!   order their ref/index writes after `commit()`.
27//! * A dropped (never committed) batch unlinks its temp files and
28//!   leaves the store untouched — aborting is free.
29//!
30//! # How the single flush is enough
31//!
32//! This is git's `core.fsyncMethod=batch` design (bulk-checkin) and
33//! `SQLite`'s macOS sync strategy:
34//!
35//! Every staged file gets a real per-file writeback at commit time —
36//! Apple `fcntl(F_BARRIERFSYNC)`, Linux `fdatasync`, Windows
37//! `FlushFileBuffers` — issued **concurrently** from a scoped-thread
38//! pool so the cost is device latency at queue depth, not
39//! latency×objects. The trailing constant-count full flushes
40//! (`F_FULLFSYNC` on Apple) cover ordering and the dirent updates.
41//! This holds on every filesystem; it does not depend on ext4
42//! ordered-data journaling.
43//!
44//! Workloads that prefer the historical schedule can select
45//! [`SyncPolicy::PerObject`] (config key `durability.objects =
46//! per-object`), which reproduces the old write path exactly.
47
48use std::collections::{HashMap, HashSet};
49use std::fmt;
50use std::fs::{self, File, OpenOptions};
51use std::io::{self, Write};
52use std::path::{Path, PathBuf};
53use std::sync::Mutex;
54
55use tempfile::TempPath;
56
57use crate::hash::Hash;
58use crate::store::{
59    MAX_RAW_OBJECT_SIZE, ObjectSink, ObjectStore, StoreError, StoreResult, sync_parent_dir,
60    temp_file_in,
61};
62
63/// When object writes become durable.
64#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
65pub enum SyncPolicy {
66    /// Historical behaviour: full flush + dir flush per object, object
67    /// visible immediately. O(objects) full flushes.
68    PerObject,
69    /// Stage now, make durable + visible together at
70    /// [`WriteBatch::commit`]. O(1) full flushes per batch. Default.
71    ///
72    /// (There is deliberately no "no flushes" policy: a visible object
73    /// MUST be durable — SPEC-OBJECTS §10.1 — because every writer's
74    /// content-addressed dedup trusts visibility. Ephemeral snapshots
75    /// belong in [`crate::store::EphemeralSink`], which never touches
76    /// the store.)
77    #[default]
78    Batch,
79}
80
81/// Flush/rename primitive seam between the store and the OS.
82///
83/// Production code uses [`RealSyncer`]; unit tests inject a recording
84/// double to assert flush *ordering* and *counts* — the tests in this
85/// module are the proof (and CI regression guard) of the O(1) full
86/// flushes per batch claim. Distinct from [`SyncPolicy`], which is a
87/// production knob deciding *whether* to flush; the syncer decides
88/// *how*.
89pub(crate) trait Syncer: Send + Sync + fmt::Debug {
90    /// Writeback + ordering barrier for one staged file. Must guarantee
91    /// the file's bytes reach the device before any later
92    /// [`Syncer::full`] completes, without forcing a device cache
93    /// flush.
94    fn barrier(&self, file: &File, path: &Path) -> io::Result<()>;
95    /// Full durable flush (device cache included) of `file`.
96    fn full(&self, file: &File, path: &Path) -> io::Result<()>;
97    /// Atomically rename a staged temp file into its final path,
98    /// replacing any existing file.
99    fn rename(&self, tmp: TempPath, final_path: &Path) -> io::Result<()>;
100    /// Durably flush the directory entry updates of `dir` — the legacy
101    /// per-object schedule ([`SyncPolicy::PerObject`] and
102    /// `ObjectStore::write`).
103    fn dir_sync(&self, dir: &Path) -> io::Result<()>;
104    /// Writeback + ordering barrier for the dirent updates of `dir`,
105    /// without forcing a device cache flush. Must guarantee the dirents
106    /// reach the device before a later [`Syncer::device_flush`]
107    /// completes. Batched schedule only; needs the trailing
108    /// `device_flush` to be durable.
109    fn dir_barrier(&self, dir: &Path) -> io::Result<()>;
110    /// Terminal full flush of the device write cache, anchored at the
111    /// store's `objects/` root. Makes everything previously
112    /// barrier-ordered (file data and dirents alike) durable.
113    fn device_flush(&self, objects_root: &Path) -> io::Result<()>;
114}
115
116/// Production [`Syncer`].
117#[derive(Debug)]
118pub(crate) struct RealSyncer;
119
120impl RealSyncer {
121    /// Per-file write barrier: order this file's writeback ahead of the
122    /// batch's terminal device flush, as cheaply as the platform allows.
123    #[cfg(any(target_os = "macos", target_os = "ios"))]
124    fn file_barrier(file: &File) -> io::Result<()> {
125        use std::os::unix::io::AsRawFd;
126        // Apple: `File::sync_data()`/`sync_all()` both map to
127        // `fcntl(F_FULLFSYNC)` — a full device-cache flush. F_BARRIERFSYNC
128        // is the cheaper primitive the module docs assume: it forces this
129        // file's writeback and orders it ahead of later writes WITHOUT a
130        // device flush. The single terminal `device_flush` (one
131        // F_FULLFSYNC) still makes the whole batch durable, so the per-
132        // file step stays a true barrier rather than N full flushes.
133        //
134        // SAFETY: `fcntl(2)` with `F_BARRIERFSYNC` takes only the fd and
135        // the command — it reads/writes no user memory, and the fd is
136        // valid for the borrow of `file`.
137        #[allow(unsafe_code)]
138        let rc = unsafe { libc::fcntl(file.as_raw_fd(), libc::F_BARRIERFSYNC) };
139        if rc == -1 {
140            // Some filesystems reject the fcntl — fall back to the full
141            // flush rather than weaken durability.
142            return file.sync_data();
143        }
144        Ok(())
145    }
146
147    /// Linux: `fdatasync`. Windows: `FlushFileBuffers` (requires the
148    /// write-capable handle the commit path now opens).
149    #[cfg(not(any(target_os = "macos", target_os = "ios")))]
150    fn file_barrier(file: &File) -> io::Result<()> {
151        file.sync_data()
152    }
153}
154
155impl Syncer for RealSyncer {
156    fn barrier(&self, file: &File, _path: &Path) -> io::Result<()> {
157        // Per-file writeback on EVERY platform — Apple: fcntl
158        // F_BARRIERFSYNC (writeback + ordering barrier, no device-cache
159        // flush); Linux: fdatasync; Windows: FlushFileBuffers. This is
160        // what makes a committed batch durable on all filesystems, not
161        // just metadata-journaling ones in ordered-data mode — XFS,
162        // btrfs, ext4 data=writeback, and NTFS get the same guarantee.
163        // The cost is bounded: barriers are issued concurrently from a
164        // thread pool at commit, so wall-clock is latency/queue-depth,
165        // not latency×objects.
166        Self::file_barrier(file)
167    }
168
169    fn full(&self, file: &File, _path: &Path) -> io::Result<()> {
170        file.sync_all()
171    }
172
173    fn rename(&self, tmp: TempPath, final_path: &Path) -> io::Result<()> {
174        // Cross-platform atomic replace: rename(2) on Unix, MoveFileExW
175        // with MOVEFILE_REPLACE_EXISTING on Windows.
176        tmp.persist(final_path).map_err(|e| e.error)?;
177        Ok(())
178    }
179
180    fn dir_sync(&self, dir: &Path) -> io::Result<()> {
181        sync_parent_dir(dir)
182    }
183
184    #[cfg(any(target_os = "macos", target_os = "ios"))]
185    fn dir_barrier(&self, dir: &Path) -> io::Result<()> {
186        // F_BARRIERFSYNC on the directory fd: pushes the dirent
187        // updates toward the device and orders them ahead of the
188        // batch's terminal F_FULLFSYNC, at a fraction of its cost.
189        // Some filesystems reject the fcntl on directories — fall back
190        // to the full dir fsync rather than weaken durability.
191        match File::open(dir) {
192            Ok(d) => d.sync_data().or_else(|_| d.sync_all()),
193            Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(()),
194            Err(e) => Err(e),
195        }
196    }
197
198    #[cfg(not(any(target_os = "macos", target_os = "ios")))]
199    fn dir_barrier(&self, dir: &Path) -> io::Result<()> {
200        // Linux: the directory fsync IS the durability mechanism (the
201        // journal commit orders ordered-mode file data ahead of the
202        // dirents), so the "barrier" must stay a real fsync. Windows:
203        // no directory flush primitive — no-op, the device_flush
204        // covers what the OS exposes.
205        sync_parent_dir(dir)
206    }
207
208    #[cfg(unix)]
209    fn device_flush(&self, objects_root: &Path) -> io::Result<()> {
210        // macOS: sync_all on any fd is F_FULLFSYNC — flushes the whole
211        // device write cache, making every prior barrier durable.
212        // Linux: fsync of the objects root; cheap insurance on top of
213        // the per-dir fsyncs above.
214        match File::open(objects_root) {
215            Ok(d) => d.sync_all(),
216            Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(()),
217            Err(e) => Err(e),
218        }
219    }
220
221    #[cfg(not(unix))]
222    #[allow(clippy::unnecessary_wraps)]
223    fn device_flush(&self, _objects_root: &Path) -> io::Result<()> {
224        Ok(())
225    }
226}
227
228#[derive(Debug, Default)]
229struct BatchState {
230    /// hash → staged-but-invisible temp file, insertion-deduped. The
231    /// final path is derived from the hash at commit (`path_for`) — a
232    /// single source of truth for the layout. Handles are closed
233    /// (`TempPath`) so a large batch does not exhaust the fd limit;
234    /// deletion-on-drop is retained for abort cleanup.
235    staged: HashMap<Hash, TempPath>,
236    /// Shard directories whose dirents this batch must flush at commit.
237    /// Includes dedup hits: an object made visible by another process
238    /// may not have a durable dirent yet, and our commit is about to
239    /// reference it.
240    touched_shards: HashSet<PathBuf>,
241    /// Shard directories this batch has already `create_dir_all`'d —
242    /// at most 256 exist, so memoizing saves ~one mkdir syscall per
243    /// object on large ingests.
244    created_shards: HashSet<PathBuf>,
245}
246
247/// A set of object writes that become durable and visible together.
248/// Created by [`ObjectStore::batch`]. See the module docs for the
249/// durability contract.
250#[derive(Debug)]
251pub struct WriteBatch<'s> {
252    store: &'s ObjectStore,
253    policy: SyncPolicy,
254    // Interior mutability so `&self` writes work and a future parallel
255    // ingest can share one batch across worker threads.
256    inner: Mutex<BatchState>,
257}
258
259impl<'s> WriteBatch<'s> {
260    pub(crate) fn new(store: &'s ObjectStore, policy: SyncPolicy) -> Self {
261        Self {
262            store,
263            policy,
264            inner: Mutex::new(BatchState::default()),
265        }
266    }
267
268    /// Hash `bytes`, dedup against staged and on-disk objects, and
269    /// stage (policy `Batch`) or durably write (policy `PerObject`) the
270    /// object. Returns the BLAKE3 hash either way.
271    pub fn write(&self, bytes: &[u8]) -> StoreResult<Hash> {
272        self.write_parts(&[bytes])
273    }
274
275    /// [`Self::write`] for an object whose bytes are the concatenation
276    /// of `parts`, hashed and written streaming — no concatenated
277    /// buffer is materialised.
278    ///
279    /// # Panics
280    ///
281    /// Panics only if the internal hash-to-path mapping produces a path
282    /// without a parent directory (impossible by construction) or if a
283    /// previous write panicked while holding the batch mutex.
284    pub fn write_parts(&self, parts: &[&[u8]]) -> StoreResult<Hash> {
285        let mut total: usize = 0;
286        for p in parts {
287            total = total
288                .checked_add(p.len())
289                .ok_or(StoreError::ObjectTooLarge)?;
290        }
291        if total > MAX_RAW_OBJECT_SIZE {
292            return Err(StoreError::ObjectTooLarge);
293        }
294        // One id dispatch shared by every part-wise sink: a merkle type
295        // buffers + uses its BMT root, a byte-hashed type streams.
296        self.write_prehashed(crate::object::object_id_from_parts(parts), parts)
297    }
298
299    /// Stage `parts` under the caller-supplied content hash, skipping
300    /// the BLAKE3 pass. `pub(crate)` and reserved for callers that have
301    /// PROVABLY just hashed the same bytes (pack unpack hashes every
302    /// entry to build its report; re-hashing in the batch doubled the
303    /// CPU of every clone/fetch). A wrong hash here would corrupt the
304    /// content addressing — never expose this publicly.
305    pub(crate) fn write_prehashed(&self, h: Hash, parts: &[&[u8]]) -> StoreResult<Hash> {
306        let final_path = self.store.path_for(&h);
307        let shard_dir = final_path
308            .parent()
309            .expect("object path always has a 2-hex parent")
310            .to_path_buf();
311
312        // Short lock: staged-dedup check + mkdir memoization decision.
313        // The file I/O below runs OUTSIDE the lock so concurrent
314        // writers sharing one batch don't convoy on each other's
315        // write_all calls.
316        let need_mkdir = {
317            let st = self.inner.lock().expect("batch state mutex poisoned");
318            if st.staged.contains_key(&h) {
319                return Ok(h);
320            }
321            !st.created_shards.contains(&shard_dir)
322        };
323        if final_path.exists() {
324            // Dedup hit: the object is visible, but if another process
325            // renamed it and has not yet flushed the dirent, it may not
326            // be durable. We are about to reference it, so flush its
327            // shard dir at commit.
328            self.inner
329                .lock()
330                .expect("batch state mutex poisoned")
331                .touched_shards
332                .insert(shard_dir);
333            return Ok(h);
334        }
335        if need_mkdir {
336            fs::create_dir_all(&shard_dir)?;
337        }
338        let file_name = final_path
339            .file_name()
340            .expect("object path has file name")
341            .to_string_lossy();
342        let mut tmp = temp_file_in(&shard_dir, &file_name)?;
343        for p in parts {
344            tmp.as_file_mut().write_all(p)?;
345        }
346        let syncer = self.store.syncer();
347        match self.policy {
348            SyncPolicy::PerObject => {
349                // Historical write path, immediately durable + visible.
350                syncer.full(tmp.as_file(), tmp.path())?;
351                syncer.rename(tmp.into_temp_path(), &final_path)?;
352                syncer.dir_sync(&shard_dir)?;
353                let mut st = self.inner.lock().expect("batch state mutex poisoned");
354                st.created_shards.insert(shard_dir);
355            }
356            SyncPolicy::Batch => {
357                // No flush here: barriers for every staged file are
358                // issued concurrently at commit() — sequential
359                // per-file barriers would re-serialise the batch on
360                // device latency (measured ~4.6ms per F_BARRIERFSYNC
361                // on Apple SSDs, ~8s for a 100 MiB ingest).
362                let mut st = self.inner.lock().expect("batch state mutex poisoned");
363                // Lost race against a concurrent writer of the same
364                // object within this batch: keep theirs, drop our tmp
365                // (content-addressed — byte-identical by construction).
366                st.staged.entry(h).or_insert_with(|| tmp.into_temp_path());
367                st.touched_shards.insert(shard_dir.clone());
368                st.created_shards.insert(shard_dir);
369            }
370        }
371        Ok(h)
372    }
373
374    /// True when `h` is staged in this batch or already in the store.
375    ///
376    /// # Panics
377    ///
378    /// Panics only if a previous write panicked while holding the batch
379    /// mutex.
380    #[must_use]
381    pub fn contains(&self, h: &Hash) -> bool {
382        self.inner
383            .lock()
384            .expect("batch state mutex poisoned")
385            .staged
386            .contains_key(h)
387            || self.store.contains(h)
388    }
389
390    /// Make every staged object durable and visible: one full flush,
391    /// then all renames, then deduplicated shard-directory flushes.
392    ///
393    /// After `commit()` returns `Ok`, every hash returned by
394    /// [`Self::write`]/[`Self::write_parts`] is durable AND visible.
395    /// Callers MUST call this before reading any object written by this
396    /// batch and before writing any ref/index that references one.
397    ///
398    /// If `commit()` fails partway, already-renamed objects remain
399    /// visible (content-addressing makes re-running the command
400    /// idempotent) and not-yet-renamed temp files are unlinked on drop.
401    ///
402    /// # Panics
403    ///
404    /// Panics only if a previous write panicked while holding the batch
405    /// mutex.
406    pub fn commit(self) -> StoreResult<()> {
407        let st = self.inner.into_inner().expect("batch state mutex poisoned");
408        let syncer = self.store.syncer();
409        match self.policy {
410            // Every write was already made durable and visible.
411            SyncPolicy::PerObject => Ok(()),
412            SyncPolicy::Batch => {
413                let staged: Vec<(Hash, TempPath)> = st.staged.into_iter().collect();
414                // 1. Barrier every staged file, concurrently. Each
415                //    barrier initiates writeback for its file and
416                //    orders it ahead of the full flush below; issuing
417                //    them from worker threads overlaps their device
418                //    latency (queue depth) instead of paying it
419                //    serially per file. All barriers complete (joined)
420                //    before the flush is issued.
421                parallel_io(staged.len(), |i| {
422                    // Write-capable handle: the barrier maps to
423                    // `FlushFileBuffers` on Windows, which rejects a
424                    // read-only handle. `write(true)` opens the existing
425                    // temp without truncating it.
426                    let f = OpenOptions::new().write(true).open(&staged[i].1)?;
427                    syncer.barrier(&f, &staged[i].1)
428                })?;
429                // 2. One full flush — any staged file serves as the
430                //    anchor; the barriers ordered every staged write
431                //    ahead of it (see module docs). Pure-dedup batches
432                //    (nothing staged) skip it: the objects were made
433                //    durable by whoever renamed them into visibility.
434                if let Some((_, tmp)) = staged.first() {
435                    // Write-capable handle for the same Windows reason as
436                    // the barrier above (`sync_all` → `FlushFileBuffers`).
437                    let f = OpenOptions::new().write(true).open(tmp)?;
438                    syncer.full(&f, tmp)?;
439                }
440                // 3. Renames: objects become visible only now, after
441                //    their bytes are durable — another process's dedup
442                //    can never reference a non-durable object. Final
443                //    paths derive from the hashes (single layout rule).
444                for (h, tmp) in staged {
445                    syncer.rename(tmp, &self.store.path_for(&h))?;
446                }
447                // 4. Dirent barriers, once per touched shard dir,
448                //    concurrently (sorted first so the work list is
449                //    deterministic).
450                let mut shards: Vec<PathBuf> = st.touched_shards.into_iter().collect();
451                shards.sort();
452                if !shards.is_empty() {
453                    parallel_io(shards.len(), |i| syncer.dir_barrier(&shards[i]))?;
454                    // 5. Terminal device flush: makes the dirent
455                    //    barriers (and, on platforms where step 2 was
456                    //    a barrier-anchored flush, everything) durable.
457                    syncer.device_flush(self.store.objects_root())?;
458                }
459                Ok(())
460            }
461        }
462    }
463}
464
465/// Worker-pool cap for [`parallel_io`]. Deliberately NOT
466/// `std::thread::available_parallelism()`: a worker here spends nearly
467/// all its time blocked in the kernel on a barrier/fsync-class syscall,
468/// not consuming CPU, so CPU core count is the wrong resource to size
469/// against (the classic pool-sizing formula — thread count scaling with
470/// `1 + wait/compute`, not compute alone — puts this workload's ideal
471/// count far above core count; `tokio::task::spawn_blocking`'s default
472/// pool of 512 threads exists for the identical reason).
473///
474/// 64 is chosen from a benchmark sweep (16/32/64/128 workers, 1000-object
475/// batches) on macOS/APFS (issue #864), repeated with 25-sample mean/
476/// stddev tracking after an initial small-sample pass overstated its
477/// confidence in the 128 result. The 16-vs-64 comparison is robust and
478/// reproduced across three independent runs: 64 is consistently ~20-30%
479/// faster than 16 with visibly tighter variance (stddev in the 10-30ms
480/// range vs 16's 25-40ms, non-overlapping in every run). 32 and 128,
481/// by contrast, showed high run-to-run variance (stddev over 100ms) and
482/// no reliably-ordered result against 64 — plausibly 128 threads hitting
483/// real scheduling contention on a 16-core machine, but not confidently
484/// distinguishable from noise at the sample sizes tested here. Treat
485/// "64" as validated; treat any specific claim about 32 or 128 as
486/// unresolved, not as "128 regresses." fsync concurrency is ultimately
487/// gated by filesystem journal serialization, not raw device queue
488/// depth, so diminishing (and possibly reversing) returns somewhere
489/// past 64 is expected in principle even if this data can't yet pin
490/// down exactly where. Linux/ext4 numbers are not gathered at all; re-tune
491/// if they diverge meaningfully from the macOS data.
492const MAX_SYNC_WORKERS: usize = 64;
493
494/// Run `op(0..count)` across a pool of up to [`MAX_SYNC_WORKERS`] scoped
495/// threads, joining them all before returning. Concurrency overlaps
496/// per-item device latency (~ms per flush primitive on Apple SSDs) that
497/// would otherwise serialise a batch; correctness only needs *all*
498/// items complete before the caller proceeds, which the join
499/// guarantees. Returns the first error observed, if any.
500fn parallel_io(count: usize, op: impl Fn(usize) -> io::Result<()> + Sync) -> io::Result<()> {
501    if count == 0 {
502        return Ok(());
503    }
504    let workers = MAX_SYNC_WORKERS.min(count);
505    if workers == 1 {
506        return (0..count).try_for_each(op);
507    }
508    let next = std::sync::atomic::AtomicUsize::new(0);
509    let mut results: Vec<io::Result<()>> = Vec::new();
510    std::thread::scope(|scope| {
511        let handles: Vec<_> = (0..workers)
512            .map(|_| {
513                let next = &next;
514                let op = &op;
515                scope.spawn(move || -> io::Result<()> {
516                    loop {
517                        let i = next.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
518                        if i >= count {
519                            return Ok(());
520                        }
521                        op(i)?;
522                    }
523                })
524            })
525            .collect();
526        for h in handles {
527            results.push(h.join().expect("parallel io worker panicked"));
528        }
529    });
530    results.into_iter().collect()
531}
532
533impl ObjectSink for WriteBatch<'_> {
534    fn put(&self, bytes: &[u8]) -> StoreResult<Hash> {
535        self.write(bytes)
536    }
537
538    fn put_parts(&self, parts: &[&[u8]]) -> StoreResult<Hash> {
539        self.write_parts(parts)
540    }
541
542    fn has(&self, h: &Hash) -> bool {
543        self.contains(h)
544    }
545}
546
547/// Test doubles shared with other modules' sync-behaviour tests
548/// (`pack.rs` asserts unpack costs one flush; later, `worktree.rs` and
549/// `ops/diff.rs` assert their flush budgets).
550#[cfg(test)]
551pub(crate) mod testing {
552    use super::*;
553
554    /// Every Syncer call, in order. `Rename` records the temp path so
555    /// tests can pair a rename with the `Barrier` that staged it. The
556    /// terminal device flush is recorded as `Full` so "full flush
557    /// count" stays the single number the O(1) tests bound.
558    #[derive(Debug, Clone, PartialEq, Eq)]
559    pub(crate) enum Ev {
560        Barrier(PathBuf),
561        Full(PathBuf),
562        Rename { tmp: PathBuf, dst: PathBuf },
563        DirSync(PathBuf),
564        DirBarrier(PathBuf),
565    }
566
567    /// Recording double: logs ordering, performs renames for real (so
568    /// the store stays functional under test), skips actual flushes
569    /// (they have no observable filesystem effect).
570    #[derive(Debug, Default)]
571    pub(crate) struct RecordingSyncer {
572        events: Mutex<Vec<Ev>>,
573    }
574
575    impl RecordingSyncer {
576        pub(crate) fn events(&self) -> Vec<Ev> {
577            self.events.lock().unwrap().clone()
578        }
579    }
580
581    impl Syncer for RecordingSyncer {
582        fn barrier(&self, _file: &File, path: &Path) -> io::Result<()> {
583            self.events
584                .lock()
585                .unwrap()
586                .push(Ev::Barrier(path.to_path_buf()));
587            Ok(())
588        }
589
590        fn full(&self, _file: &File, path: &Path) -> io::Result<()> {
591            self.events
592                .lock()
593                .unwrap()
594                .push(Ev::Full(path.to_path_buf()));
595            Ok(())
596        }
597
598        fn rename(&self, tmp: TempPath, final_path: &Path) -> io::Result<()> {
599            self.events.lock().unwrap().push(Ev::Rename {
600                tmp: tmp.to_path_buf(),
601                dst: final_path.to_path_buf(),
602            });
603            tmp.persist(final_path).map_err(|e| e.error)?;
604            Ok(())
605        }
606
607        fn dir_sync(&self, dir: &Path) -> io::Result<()> {
608            self.events
609                .lock()
610                .unwrap()
611                .push(Ev::DirSync(dir.to_path_buf()));
612            Ok(())
613        }
614
615        fn dir_barrier(&self, dir: &Path) -> io::Result<()> {
616            self.events
617                .lock()
618                .unwrap()
619                .push(Ev::DirBarrier(dir.to_path_buf()));
620            Ok(())
621        }
622
623        fn device_flush(&self, objects_root: &Path) -> io::Result<()> {
624            self.events
625                .lock()
626                .unwrap()
627                .push(Ev::Full(objects_root.to_path_buf()));
628            Ok(())
629        }
630    }
631}
632
633#[cfg(test)]
634mod tests {
635    use super::testing::{Ev, RecordingSyncer};
636    use super::*;
637    use crate::hash;
638    use proptest::prelude::*;
639    use std::sync::Arc;
640    use tempfile::TempDir;
641
642    fn fresh_store() -> (TempDir, ObjectStore) {
643        let dir = TempDir::new().expect("tempdir");
644        let store =
645            ObjectStore::init(&crate::layout::RepoLayout::single(dir.path())).expect("init");
646        (dir, store)
647    }
648
649    /// Store with a shared `RecordingSyncer` injected.
650    fn recording_store() -> (TempDir, ObjectStore, Arc<RecordingSyncer>) {
651        let (dir, mut store) = fresh_store();
652        let rec = Arc::new(RecordingSyncer::default());
653        store.set_syncer(rec.clone());
654        (dir, store, rec)
655    }
656
657    /// Count object files (62-hex names) under `objects/`, recursively.
658    fn object_file_count(store: &ObjectStore) -> usize {
659        store.iter_object_hashes().unwrap().len()
660    }
661
662    /// Count ALL files under `objects/` including temp files.
663    fn any_file_count(store: &ObjectStore) -> usize {
664        fn walk(dir: &Path, n: &mut usize) {
665            if let Ok(rd) = fs::read_dir(dir) {
666                for e in rd.flatten() {
667                    let p = e.path();
668                    if p.is_dir() {
669                        walk(&p, n);
670                    } else {
671                        *n += 1;
672                    }
673                }
674            }
675        }
676        let mut n = 0;
677        walk(store.objects_root(), &mut n);
678        n
679    }
680
681    // ---- Cycle 1: staging invisibility --------------------------------
682
683    #[test]
684    fn staged_object_is_invisible_until_commit() {
685        let (dir, store) = fresh_store();
686        let batch = store.batch();
687        let h = batch.write(b"staged bytes").unwrap();
688
689        // A second, independent handle must not see the object yet.
690        let other = ObjectStore::open(&crate::layout::RepoLayout::single(dir.path())).unwrap();
691        assert!(
692            !other.contains(&h),
693            "staged object must be invisible before commit"
694        );
695        assert!(other.read(&h).is_err());
696
697        batch.commit().unwrap();
698        assert!(other.contains(&h), "committed object must be visible");
699        assert_eq!(other.read(&h).unwrap(), b"staged bytes");
700    }
701
702    #[test]
703    fn batch_contains_sees_staged_and_disk() {
704        let (_dir, store) = fresh_store();
705        let on_disk = store.write(b"already stored").unwrap();
706        let batch = store.batch();
707        let staged = batch.write(b"only staged").unwrap();
708
709        assert!(batch.contains(&on_disk), "must see on-disk objects");
710        assert!(batch.contains(&staged), "must see its own staged objects");
711        let phony = hash::hash(b"never written");
712        assert!(!batch.contains(&phony));
713    }
714
715    #[test]
716    fn dropped_batch_leaves_no_tmp_files_and_no_objects() {
717        let (_dir, store) = fresh_store();
718        {
719            let batch = store.batch();
720            batch.write(b"abort me 1").unwrap();
721            batch.write(b"abort me 2").unwrap();
722            batch.write(b"abort me 3").unwrap();
723            // dropped without commit
724        }
725        assert_eq!(object_file_count(&store), 0, "no objects may be visible");
726        assert_eq!(
727            any_file_count(&store),
728            0,
729            "no temp files may leak from an aborted batch"
730        );
731    }
732
733    // ---- Cycle 2: flush ordering (the O(1) proof) ----------------------
734
735    fn fifty_distinct_objects() -> Vec<Vec<u8>> {
736        (0u32..50)
737            .map(|i| format!("object #{i}").into_bytes())
738            .collect()
739    }
740
741    #[test]
742    fn batch_commit_full_flush_count_is_constant() {
743        // The O(1) proof: a committed batch costs exactly TWO full
744        // flushes — one covering staged file data (pre-rename), one
745        // terminal device flush covering the dirent updates —
746        // regardless of how many objects it stages.
747        for count in [3usize, 50] {
748            let (_dir, store, rec) = recording_store();
749            let batch = store.batch();
750            for bytes in fifty_distinct_objects().into_iter().take(count) {
751                batch.write(&bytes).unwrap();
752            }
753            // Duplicates must not add flushes either.
754            batch.write(b"object #0").unwrap();
755            batch.commit().unwrap();
756
757            let fulls = rec
758                .events()
759                .iter()
760                .filter(|e| matches!(e, Ev::Full(_)))
761                .count();
762            assert_eq!(
763                fulls, 2,
764                "a {count}-object batch must cost exactly two full flushes"
765            );
766        }
767    }
768
769    #[test]
770    fn every_rename_is_preceded_by_its_barrier_and_the_full_flush() {
771        let (_dir, store, rec) = recording_store();
772        let batch = store.batch();
773        for bytes in fifty_distinct_objects() {
774            batch.write(&bytes).unwrap();
775        }
776        batch.commit().unwrap();
777
778        let evs = rec.events();
779        let full_pos = evs
780            .iter()
781            .position(|e| matches!(e, Ev::Full(_)))
782            .expect("one full flush");
783        // Every barrier must complete before the full flush is issued —
784        // the flush only covers writes the barriers pushed ahead of it.
785        let last_barrier = evs
786            .iter()
787            .rposition(|e| matches!(e, Ev::Barrier(_)))
788            .expect("barriers recorded");
789        assert!(
790            last_barrier < full_pos,
791            "all barriers (last at {last_barrier}) must precede the full flush at {full_pos}"
792        );
793        for (i, ev) in evs.iter().enumerate() {
794            if let Ev::Rename { tmp, .. } = ev {
795                assert!(
796                    i > full_pos,
797                    "rename at {i} must come after the full flush at {full_pos}"
798                );
799                let barrier_pos = evs
800                    .iter()
801                    .position(|e| matches!(e, Ev::Barrier(p) if p == tmp))
802                    .unwrap_or_else(|| panic!("no barrier recorded for {}", tmp.display()));
803                assert!(
804                    barrier_pos < i,
805                    "barrier for {} must precede its rename",
806                    tmp.display()
807                );
808            }
809        }
810    }
811
812    #[test]
813    fn dir_syncs_come_after_all_renames_and_are_deduped() {
814        let (_dir, store, rec) = recording_store();
815        let batch = store.batch();
816        let mut shards = HashSet::new();
817        for bytes in fifty_distinct_objects() {
818            let h = batch.write(&bytes).unwrap();
819            shards.insert(store.path_for(&h).parent().unwrap().to_path_buf());
820        }
821        batch.commit().unwrap();
822
823        let evs = rec.events();
824        let last_rename = evs
825            .iter()
826            .rposition(|e| matches!(e, Ev::Rename { .. }))
827            .expect("renames recorded");
828        let dir_barriers: Vec<(usize, &PathBuf)> = evs
829            .iter()
830            .enumerate()
831            .filter_map(|(i, e)| match e {
832                Ev::DirBarrier(p) => Some((i, p)),
833                _ => None,
834            })
835            .collect();
836
837        let synced: HashSet<PathBuf> = dir_barriers.iter().map(|(_, p)| (*p).clone()).collect();
838        assert_eq!(
839            dir_barriers.len(),
840            synced.len(),
841            "each shard dir must be flushed exactly once"
842        );
843        assert_eq!(synced, shards, "exactly the touched shards are flushed");
844        for (i, p) in &dir_barriers {
845            assert!(
846                *i > last_rename,
847                "dir barrier of {} at {i} must come after the last rename at {last_rename}",
848                p.display()
849            );
850        }
851        // The terminal device flush makes the dirent barriers durable —
852        // it must be the last sync event of the batch.
853        let last_full = evs
854            .iter()
855            .rposition(|e| matches!(e, Ev::Full(_)))
856            .expect("device flush recorded");
857        let last_dir_barrier = dir_barriers.last().expect("dir barriers recorded").0;
858        assert!(
859            last_full > last_dir_barrier,
860            "device flush at {last_full} must follow the last dir barrier at {last_dir_barrier}"
861        );
862    }
863
864    #[test]
865    fn dedup_hit_still_dir_syncs_at_commit() {
866        let (_dir, store, rec) = recording_store();
867        // Pre-store the object (e.g. another process raced us there).
868        let h = store.write(b"already present").unwrap();
869        let shard = store.path_for(&h).parent().unwrap().to_path_buf();
870
871        let batch = store.batch();
872        let h2 = batch.write(b"already present").unwrap();
873        assert_eq!(h, h2);
874        let before = rec.events().len();
875        batch.commit().unwrap();
876
877        let evs = rec.events()[before..].to_vec();
878        assert!(
879            !evs.iter().any(|e| matches!(e, Ev::Rename { .. })),
880            "dedup hit must not stage or rename anything"
881        );
882        assert!(
883            evs.contains(&Ev::DirBarrier(shard)),
884            "commit must still flush the dedup-hit shard dir: its dirent \
885             may not be durable yet and we are about to reference it"
886        );
887        assert!(
888            matches!(evs.last(), Some(Ev::Full(_))),
889            "the dir barrier needs a trailing device flush to be durable"
890        );
891    }
892
893    #[test]
894    fn sync_policy_per_object_matches_legacy_event_pattern() {
895        // Legacy pattern = ObjectStore::write: Full(tmp), Rename, DirSync
896        // per object, in that order, objects visible immediately.
897        let (_dir, store, rec) = recording_store();
898        let legacy_h = store.write(b"legacy path").unwrap();
899        let legacy: Vec<Ev> = rec.events();
900
901        let batch = store.batch_with_policy(SyncPolicy::PerObject);
902        let h = batch.write(b"per object path").unwrap();
903        assert!(
904            store.contains(&h),
905            "PerObject writes must be visible immediately, pre-commit"
906        );
907        let per_object: Vec<Ev> = rec.events()[legacy.len()..].to_vec();
908
909        let kinds = |evs: &[Ev]| -> Vec<u8> {
910            evs.iter()
911                .map(|e| match e {
912                    Ev::Barrier(_) => 0u8,
913                    Ev::Full(_) => 1,
914                    Ev::Rename { .. } => 2,
915                    Ev::DirSync(_) => 3,
916                    Ev::DirBarrier(_) => 4,
917                })
918                .collect()
919        };
920        assert_eq!(
921            kinds(&per_object),
922            kinds(&legacy),
923            "PerObject batch must reproduce the legacy per-write sync pattern"
924        );
925        // commit() of a PerObject batch is a no-op.
926        let before = rec.events().len();
927        batch.commit().unwrap();
928        assert_eq!(rec.events().len(), before);
929        let _ = legacy_h;
930    }
931
932    // ---- Cycle 3: equivalence & limits ---------------------------------
933
934    #[test]
935    fn idempotent_duplicate_writes_in_one_batch_stage_once() {
936        let (_dir, store, rec) = recording_store();
937        let batch = store.batch();
938        let h1 = batch.write(b"twice staged").unwrap();
939        let h2 = batch.write(b"twice staged").unwrap();
940        assert_eq!(h1, h2);
941        batch.commit().unwrap();
942
943        let evs = rec.events();
944        let barriers = evs.iter().filter(|e| matches!(e, Ev::Barrier(_))).count();
945        let renames = evs
946            .iter()
947            .filter(|e| matches!(e, Ev::Rename { .. }))
948            .count();
949        assert_eq!(barriers, 1, "duplicate must not re-stage");
950        assert_eq!(renames, 1, "duplicate must not re-rename");
951    }
952
953    #[test]
954    fn batch_write_rejects_oversize() {
955        // Mirrors store::tests::write_rejects_oversize: a REAL cap+1
956        // body (lazily mapped zero pages via `alloc_zeroed`, so no RSS
957        // cost) must be refused by the batch's size guard before any
958        // hashing or staging.
959        let (_dir, store) = fresh_store();
960        let batch = store.batch();
961        let oversize = vec![0u8; MAX_RAW_OBJECT_SIZE + 1];
962        let err = batch.write(&oversize).unwrap_err();
963        assert!(matches!(err, StoreError::ObjectTooLarge), "got {err:?}");
964        drop(oversize);
965        // The batch stays usable after the rejection.
966        let h = batch.write(&[0u8; 16]).unwrap();
967        batch.commit().unwrap();
968        assert!(store.contains(&h));
969    }
970
971    proptest! {
972        #[test]
973        fn batch_write_hash_equals_store_write_hash(bytes in proptest::collection::vec(any::<u8>(), 0..4096)) {
974            let (_dir, store) = fresh_store();
975            let batch = store.batch();
976            let h_batch = batch.write(&bytes).unwrap();
977            batch.commit().unwrap();
978            let on_disk_via_batch = store.read(&h_batch).unwrap();
979
980            let (_dir2, store2) = fresh_store();
981            let h_store = store2.write(&bytes).unwrap();
982            prop_assert_eq!(h_batch, h_store, "batch and store writes must agree on the hash");
983            prop_assert_eq!(on_disk_via_batch, bytes, "on-disk bytes must round-trip");
984        }
985
986        #[test]
987        fn write_parts_equals_concatenated_write(
988            parts in proptest::collection::vec(proptest::collection::vec(any::<u8>(), 0..512), 0..8)
989        ) {
990            let (_dir, store) = fresh_store();
991            let concatenated: Vec<u8> = parts.iter().flatten().copied().collect();
992
993            let batch = store.batch();
994            let slices: Vec<&[u8]> = parts.iter().map(Vec::as_slice).collect();
995            let h_parts = batch.write_parts(&slices).unwrap();
996            let h_whole = batch.write(&concatenated).unwrap();
997            batch.commit().unwrap();
998
999            prop_assert_eq!(h_parts, h_whole, "parts and whole must hash identically");
1000            prop_assert_eq!(store.read(&h_parts).unwrap(), concatenated);
1001        }
1002    }
1003}