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}