Skip to main content

znippy_plugin_git/
archive_write.rs

1//! The push path's **archive writer**: one trait, three durability contracts.
2//!
3//! A git client pushes a packfile. Its bytes are already zlib-deflated and often
4//! delta-encoded, so they go into the znippy Apache Arrow IPC archive
5//! **verbatim** — never re-compressed, never re-encoded (the reason is measured
6//! in this crate's own module docs: re-inflating per oid cost 3.4× the input on
7//! 60 000 objects). What *does* vary is how much durability the server buys
8//! before it sends the client its ack, and that is the only axis this module
9//! exposes.
10//!
11//! The index tables are **not** built here. [`append`](ArchiveWrite::append)
12//! returns a byte extent; the extent goes over a channel to the per-account
13//! indexer in [`crate::indexer`], which builds the Arrow index tables *after*
14//! the bytes are down. See that module for why a read arriving before the index
15//! is ready still cannot be wrong.
16//!
17//! # The three arms, and what each one actually promises
18//!
19//! | impl | on return, the bytes are… | a crash right after return |
20//! |---|---|---|
21//! | [`FastWriter`] | in the **page cache** | **loses them** |
22//! | [`SafeWriter`] | on the platter, and a journal row points at them | keeps them |
23//! | [`UringWriter`] | on the platter, and a journal row points at them | keeps them |
24//!
25//! [`FastWriter`] **bounds** the other two: it is the ceiling that the cost of
26//! durability is measured against. It is also a **selectable arm** — see
27//! [`crate::arms::WriterArm`] — because there are workloads whose contract is
28//! not git's (a rebuildable mirror, a bulk import that is re-run on failure, a
29//! benchmark), and the operator who picks it is choosing the row above. Nothing
30//! here refuses it; the table is what it promises.
31//!
32//! # The ordering the two durable arms both obey
33//!
34//! Taken from `znippy-common/src/hot.rs` (:92, :310-327) and not re-invented:
35//!
36//! ```text
37//!   blob bytes  ->  fsync(blobs)  ->  journal row  ->  fsync(journal)
38//! ```
39//!
40//! The blob bytes are durable **before** any row references them. A crash
41//! between the two leaves **orphan bytes nobody points at** — dead payload the
42//! seal drops. The reverse order would leave an index row pointing into a hole,
43//! which is a corrupt archive rather than a lost append.
44//!
45//! # The BufWriter trap
46//!
47//! The journal is written through a `std::io::BufWriter`. `flush()` moves
48//! userspace → kernel; `sync_all()` moves kernel → platter. **`sync_all()`
49//! without a prior `flush()` syncs nothing you just wrote and looks perfectly
50//! durable** — the call succeeds, the fsync is real, and the bytes are still
51//! sitting in a userspace `Vec`. [`SafeWriter`] deliberately keeps the userspace
52//! buffer so that this ordering is load-bearing and a test can see it fail; the
53//! guard `journal_flush_before_sync_is_load_bearing` in `tests/archive_write.rs`
54//! is the one that watched it.
55//!
56//! # The journal is a LOG, and a reopen appends to it
57//!
58//! One Arrow IPC schema message at the head of the file, then one batch message
59//! per acked pack, for the life of the archive. A writer opened over an archive
60//! that already has a journal **appends** — it writes no second schema and it
61//! truncates nothing.
62//!
63//! There is exactly one other kind of row and it is appended the same way: a
64//! **tombstone** ([`retire_packs`]), written by a `gc` that found every object of
65//! a pack dead. It retires that pack's extent without removing its row, because
66//! removing a row is the one thing this file's contract forbids. See
67//! [`JOURNAL_TOMBSTONE`] for the encoding and [`JournalRow`] for why a pack's
68//! ordinal counts pack rows rather than raw rows.
69//!
70//! That is not a style choice, it is the crash-recovery contract. The extents in
71//! this file are one half of §13.12's `indexed` bit: a pack is unabsorbed *iff*
72//! its extent is here and its rows are not in the index, and
73//! [`GitStore::open_with`](crate::git_ops::GitStore::open_with) diffs the two on
74//! every open. A journal truncated by the reopen would erase the evidence that a
75//! durable pack was ever acked, so the pack's bytes would stay on disk with
76//! nothing pointing at them and nothing able to re-queue them — the exact failure
77//! the ordering above exists to prevent, arriving one restart later. Emitting a
78//! second schema message mid-file would be no better: arrow's `StreamReader`
79//! stops at it, so [`read_journal()`] would silently return only the rows written
80//! before this process started.
81
82use std::fs::{File, OpenOptions};
83use std::io::{BufWriter, Write};
84use std::os::unix::fs::FileExt;
85use std::path::{Path, PathBuf};
86use std::sync::atomic::{AtomicU64, Ordering};
87use std::sync::{Arc, Mutex};
88
89use anyhow::{Result, anyhow, bail};
90use znippy_common::arrow;
91use znippy_common::arrow::array::{ArrayRef, UInt64Array};
92use znippy_common::arrow::datatypes::{DataType, Field, Schema, SchemaRef};
93use znippy_common::arrow::ipc::MetadataVersion;
94use znippy_common::arrow::ipc::writer::{
95    DictionaryTracker, IpcDataGenerator, IpcWriteOptions, write_message,
96};
97use znippy_common::arrow::record_batch::RecordBatch;
98
99/// A byte range inside the archive: `(offset, len)`. Same shape as
100/// [`crate::store::Extent`].
101pub type Extent = (u64, u64);
102
103/// Append pushed packfile bytes to an archive, verbatim.
104///
105/// One trait, three durability contracts. The implementation decides **when**
106/// `append` is allowed to return; it never decides what the bytes are.
107pub trait ArchiveWrite: Send + Sync {
108    /// Append these bytes and return when this implementation's durability
109    /// contract is met. Returns the byte extent written.
110    ///
111    /// The bytes are stored exactly as handed over: no compression, no framing,
112    /// no re-encoding. The returned `(offset, len)` addresses them inside the
113    /// archive's blob region and is the *only* thing that goes to the indexer.
114    fn append(&self, bytes: &[u8]) -> Result<Extent>;
115
116    /// Name it on a bench row.
117    fn name(&self) -> &'static str;
118
119    /// One-line statement of what a crash immediately after `append` returns
120    /// does to the bytes. On the bench table next to the throughput, because a
121    /// throughput without this is not a comparison.
122    fn durability(&self) -> &'static str;
123
124    /// **This arm's open file description on the blob file**, for the readers
125    /// in the same process.
126    ///
127    /// Every arm opens the file once, read/write, through
128    /// [`open_blobs`]; a reader `pread`s at an explicit offset and shares no
129    /// cursor with `append`, so handing the same description out is a
130    /// descriptor saved and nothing else changed. It is on the trait rather
131    /// than on the three concrete types because the one caller that matters —
132    /// [`crate::indexer::PushPath::with_absorber`] — holds a
133    /// `Box<dyn ArchiveWrite>` and would otherwise have to open its own.
134    ///
135    /// `None` is reserved for an arm that has no such file. There is none
136    /// today, and an arm that returns it gets its own handle rather than an
137    /// error.
138    fn blobs_shared(&self) -> Option<Arc<File>>;
139}
140
141// ── the journal, shared by every durable arm ────────────────────────────────
142
143/// Alignment of an IPC message in the journal. 8 is what
144/// `znippy_common::hot`'s journal segments use.
145pub const JOURNAL_ALIGNMENT: u8 = 8;
146
147/// The journal's schema: the reference from a row to the blob bytes.
148///
149/// Deliberately two `u64`s and nothing else. The journal's job is to say *these
150/// bytes exist at this extent*; everything derived from the bytes (object count,
151/// pack version, checksum) is the indexer's job and is built later, off this
152/// path.
153pub fn journal_schema() -> SchemaRef {
154    Arc::new(Schema::new(vec![
155        Field::new("blob_offset", DataType::UInt64, false),
156        Field::new("blob_size", DataType::UInt64, false),
157    ]))
158}
159
160/// The IPC write options every arm's journal uses, so the three arms' journals
161/// are byte-comparable.
162pub fn journal_options() -> Result<IpcWriteOptions> {
163    IpcWriteOptions::try_new(JOURNAL_ALIGNMENT as usize, false, MetadataVersion::V5)
164        .map_err(|e| anyhow!("journal write options: {e}"))
165}
166
167/// One journal row for one extent.
168pub fn journal_batch(offset: u64, len: u64) -> Result<RecordBatch> {
169    let off: ArrayRef = Arc::new(UInt64Array::from(vec![offset]));
170    let size: ArrayRef = Arc::new(UInt64Array::from(vec![len]));
171    RecordBatch::try_new(journal_schema(), vec![off, size])
172        .map_err(|e| anyhow!("journal batch: {e}"))
173}
174
175/// Serialize the journal's *schema* message — the header an Arrow IPC stream
176/// opens with, emitted once per segment.
177///
178/// Both durable arms call it, and each writes it **once per journal file**:
179/// [`SafeWriter`] when it finds the file empty,
180/// [`UringWriter`](crate::uring_write::UringWriter) at offset 0 of the one it
181/// creates. One encoder, one format, two transports (LAW 5) — and a
182/// second schema message inside one file is what would make [`read_journal()`]
183/// stop early, which is why neither arm can emit it per writer.
184pub fn encode_journal_schema() -> Result<Vec<u8>> {
185    let opts = journal_options()?;
186    let dg = IpcDataGenerator {};
187    let mut tracker = DictionaryTracker::new(false);
188    let encoded =
189        dg.schema_to_bytes_with_dictionary_tracker(journal_schema().as_ref(), &mut tracker, &opts);
190    let mut out = Vec::with_capacity(512);
191    write_message(&mut out, encoded, &opts).map_err(|e| anyhow!("journal schema encode: {e}"))?;
192    Ok(out)
193}
194
195/// Serialize one journal *batch* message for `(offset, len)`.
196pub fn encode_journal_row(offset: u64, len: u64) -> Result<Vec<u8>> {
197    let opts = journal_options()?;
198    let dg = IpcDataGenerator {};
199    let mut tracker = DictionaryTracker::new(false);
200    let batch = journal_batch(offset, len)?;
201    let (dicts, msg) = dg
202        .encode(&batch, &mut tracker, &opts, &mut Default::default())
203        .map_err(|e| anyhow!("journal batch encode: {e}"))?;
204    let mut out = Vec::with_capacity(512);
205    for d in dicts {
206        write_message(&mut out, d, &opts).map_err(|e| anyhow!("journal dict encode: {e}"))?;
207    }
208    write_message(&mut out, msg, &opts).map_err(|e| anyhow!("journal row encode: {e}"))?;
209    Ok(out)
210}
211
212/// Read every complete journal row back, tolerating a torn tail.
213///
214/// A journal is a **log**, not a document: a process killed mid-write leaves a
215/// partial final message, and every complete message before it is real. Same
216/// rule `znippy_common::hot`'s segment reader follows. Used by the guards to
217/// assert what a crash actually left behind rather than what the writer claimed.
218pub fn read_journal(path: &Path) -> Result<Vec<Extent>> {
219    let bytes = std::fs::read(path).map_err(|e| anyhow!("journal open {}: {e}", path.display()))?;
220    if bytes.is_empty() {
221        return Ok(Vec::new());
222    }
223    // Only the whole, well-formed messages reach arrow (the IPC guard's valid
224    // prefix): a hostile message reads as the torn tail it is treated as, never
225    // as an arrow-ipc panic or abort.
226    let whole = znippy_common::ipc_guard::valid_prefix(&bytes);
227    let reader = match arrow::ipc::reader::StreamReader::try_new(std::io::Cursor::new(whole), None) {
228        Ok(r) => r,
229        // A journal with a torn *schema* message carries no rows at all.
230        Err(_) => return Ok(Vec::new()),
231    };
232    let mut out = Vec::new();
233    for batch in reader {
234        let Ok(batch) = batch else { break }; // torn tail: stop, keep what is whole
235        let offs = batch
236            .columns()
237            .first()
238            .ok_or_else(|| anyhow!("journal has no column 0"))?
239            .as_any()
240            .downcast_ref::<UInt64Array>()
241            .ok_or_else(|| anyhow!("journal column 0 is not u64"))?;
242        let sizes = batch
243            .columns()
244            .get(1)
245            .ok_or_else(|| anyhow!("journal has no column 1"))?
246            .as_any()
247            .downcast_ref::<UInt64Array>()
248            .ok_or_else(|| anyhow!("journal column 1 is not u64"))?;
249        for i in 0..batch.num_rows() {
250            out.push((offs.value(i), sizes.value(i)));
251        }
252    }
253    Ok(out)
254}
255
256// ── retirement: the one row a `gc` appends ──────────────────────────────────
257
258/// The `blob_size` of a journal row that is **not** a pack: a **tombstone**,
259/// whose `blob_offset` names a pack this archive has retired.
260///
261/// `u64::MAX` rather than `0`, and that is the whole of the encoding decision.
262/// The journal's schema is two `u64`s and a third column cannot be added without
263/// making every existing journal file unreadable by the writer that appends to
264/// it, so the marker has to live inside a value that no real pack can take. A
265/// zero-length append is at least *conceivable* — a caller handing `append` an
266/// empty slice gets `(cursor, 0)` — while an extent of 2^64-1 bytes cannot
267/// exist on any filesystem this will ever run on. The unreachable value is the
268/// safe one.
269pub const JOURNAL_TOMBSTONE: u64 = u64::MAX;
270
271/// One journal row, read back and interpreted.
272///
273/// **The ordinal of a pack is its position among the [`Pack`](JournalRow::Pack)
274/// rows, not its row index.** Tombstones are appended to the same log — that is
275/// what keeps the journal append-only — so counting raw rows would renumber
276/// every pack acked before a `gc` and make the ordinals a live process holds
277/// disagree with the ones the next open derives. Counting pack rows cannot: a
278/// pack row is never removed and never reordered, so a pack's ordinal is fixed
279/// for the life of the archive.
280#[derive(Debug, Clone, Copy, PartialEq, Eq)]
281pub enum JournalRow {
282    /// A pack was acked at this extent.
283    Pack(Extent),
284    /// The pack that starts at this offset was retired: a `gc` found **every**
285    /// one of its objects dead and dropped every row it had. Its bytes are still
286    /// in the blob file (the blob file is append-only and nothing truncates it),
287    /// but no open may ever re-queue it for indexing again.
288    Retired(u64),
289}
290
291/// Interpret what [`read_journal`] returned.
292pub fn journal_rows(rows: &[Extent]) -> Vec<JournalRow> {
293    rows.iter()
294        .map(|&(offset, len)| {
295            if len == JOURNAL_TOMBSTONE {
296                JournalRow::Retired(offset)
297            } else {
298                JournalRow::Pack((offset, len))
299            }
300        })
301        .collect()
302}
303
304/// The acked pack extents, in **ordinal order** — index `i` is pack `i`.
305pub fn acked_packs(rows: &[Extent]) -> Vec<Extent> {
306    journal_rows(rows)
307        .into_iter()
308        .filter_map(|r| match r {
309            JournalRow::Pack(e) => Some(e),
310            JournalRow::Retired(_) => None,
311        })
312        .collect()
313}
314
315/// The offsets of the packs a `gc` has retired.
316pub fn retired_offsets(rows: &[Extent]) -> std::collections::HashSet<u64> {
317    journal_rows(rows)
318        .into_iter()
319        .filter_map(|r| match r {
320            JournalRow::Retired(o) => Some(o),
321            JournalRow::Pack(_) => None,
322        })
323        .collect()
324}
325
326/// **Retire these packs: one tombstone row each, durable before this returns.**
327///
328/// Called by `GitOps::gc` *before* it drops a single index row, and that order
329/// is the crash contract. The two possible interruptions are not symmetric:
330///
331/// * killed **before** the tombstones are durable — the rows are still in the
332///   index, so the next open sees packs that have rows, calls them absorbed, and
333///   nothing is lost or resurrected. The GC simply did not happen.
334/// * killed **after** — the rows may or may not have gone, and either way the
335///   next open reads a tombstone and refuses to re-queue the pack. The dead
336///   objects cannot come back.
337///
338/// The reverse order has a window in which the rows are gone and the journal
339/// still claims an unabsorbed pack, which is exactly the resurrection this
340/// function exists to close.
341///
342/// A torn write leaves a partial final message, which [`read_journal`] discards
343/// with the rest of a torn tail — the same tolerance a torn *pack* row gets, and
344/// safe for the same reason: a tombstone that did not land is a `gc` that did not
345/// happen.
346///
347/// All the rows go out in **one** `write(2)` on an `O_APPEND` fd, so a push
348/// appending through [`SafeWriter`]'s own fd at the same instant cannot land
349/// inside one of them.
350pub fn retire_packs(journal: &Path, offsets: &[u64]) -> Result<()> {
351    if offsets.is_empty() {
352        return Ok(());
353    }
354    let mut buf = Vec::with_capacity(offsets.len() * 256);
355    for &offset in offsets {
356        buf.extend_from_slice(&encode_journal_row(offset, JOURNAL_TOMBSTONE)?);
357    }
358    let f = OpenOptions::new()
359        .append(true)
360        .open(journal)
361        .map_err(|e| anyhow!("open journal {} to retire a pack: {e}", journal.display()))?;
362    if f.metadata()?.len() == 0 {
363        // No schema message, therefore no pack row, therefore nothing that could
364        // have been retired. Writing a tombstone into an empty stream would
365        // produce a journal whose first message is a batch, which reads back as
366        // no rows at all.
367        bail!(
368            "{} is empty — nothing was ever acked here, so there is no pack to retire",
369            journal.display()
370        );
371    }
372    (&f).write_all(&buf)
373        .map_err(|e| anyhow!("journal tombstone write: {e}"))?;
374    f.sync_all()
375        .map_err(|e| anyhow!("journal tombstone fsync: {e}"))?;
376    Ok(())
377}
378
379/// **The writer lock** — `P-014`. One writer per blob file, enforced by the
380/// kernel, held for as long as the writer's handle lives.
381///
382/// # What it is protecting against, measured
383///
384/// Every arm reserves its extent from an `AtomicU64` seeded here from the file's
385/// length **at open time**. Two processes that both open before either appends
386/// therefore hold two cursors with the same value and hand out *the same
387/// offsets*. Driven on t14s 2026-08-11, two processes × 16 one-blob packs on
388/// `SafeWriter`, released together: 32 packs acked, `objects.pack` never grew
389/// past one writer's worth, and **16 of 32 acked, fsynced, journalled objects
390/// were destroyed with two zero exit codes**. Neither writer errored, which is
391/// the whole problem.
392///
393/// # Why `flock` and not an `O_EXCL` lock file
394///
395/// `S-019`: *"the `O_EXCL` store lock does not self-clear; a killed appliance
396/// will not restart."* A lock that survives a crash is worse than the race it
397/// prevents. `flock(2)` is released by the kernel when the last descriptor for
398/// the open file description closes — including on `SIGKILL`, including on a
399/// panic — so a killed server comes back up. Verified rather than recited: a
400/// store held by one process, `kill -9`, and the next open succeeds.
401///
402/// [`std::fs::File::try_lock`] *is* `flock(LOCK_EX | LOCK_NB)` on unix, so this
403/// costs no dependency, and it is **the same mechanism redb already uses** on
404/// `objects.tail` (`redb-2.6.3/src/tree_store/page_store/file_backend/unix.rs:37`).
405/// One locking discipline in this store, not two.
406///
407/// # Why at open, and NOT around `append`
408///
409/// The ack path is the latency path: `append` returns to the client and the
410/// indexer runs after it. A lock taken per append would put a syscall on that
411/// path and would still not help, because the *cursor* is what is stale, not the
412/// write. Taken once here, the ack path takes **no lock at all** and nothing
413/// about `append`'s cost changes.
414///
415/// It is here, in `open_blobs`, because that is the one function
416/// [`FastWriter`], [`SafeWriter`] and
417/// [`UringWriter`](crate::uring_write::UringWriter) all call — one lock for
418/// three arms, rather than one arm fixed and two left. It also runs *before*
419/// `UringWriter::create`'s `File::create(journal)`, so a second process can no
420/// longer truncate the journal on its way to being refused.
421///
422/// # What it deliberately does NOT cover
423///
424/// **Two threads sharing one open handle.** An advisory lock is per open file
425/// *description*; a second `try_lock` on the same handle returns `Ok`. That case
426/// is `tests/concurrent_push.rs`'s second test, and it needs no lock: the
427/// `fetch_add` cursor every arm shares makes two concurrent appends touch
428/// provably disjoint ranges. A mutex there would serialise the ack path to fix a
429/// race that cannot happen.
430///
431/// # Refuse, do not wait
432///
433/// `LOCK_NB`, matching redb. A server that blocked here would hang on startup
434/// behind a process it cannot see, with no error and no timeout; a refusal names
435/// the file and the caller already handles one, because redb's has been throwing
436/// it all along.
437fn lock_for_writing(f: &File, path: &Path) -> Result<()> {
438    match f.try_lock() {
439        Ok(()) => Ok(()),
440        Err(std::fs::TryLockError::WouldBlock) => bail!(
441            "another writer already holds {} — one process at a time appends to a store's \
442             blob file. Two would share a stale append cursor and overwrite each other's \
443             acked packs without either one erroring. The lock is an advisory flock and the \
444             kernel drops it when that process dies, so nothing has to be cleaned up by hand.",
445            path.display()
446        ),
447        Err(std::fs::TryLockError::Error(e)) => Err(anyhow!(
448            "locking {} for writing: {e} — the store is not opened without the writer lock, \
449             because an unlocked open is the P-014 race",
450            path.display()
451        )),
452    }
453}
454
455/// Open (creating) the blob file an archive's payload region lives in, and
456/// report the cursor to append at.
457///
458/// **Takes the writer lock** before reading the cursor — see
459/// [`lock_for_writing`] for why the lock is here and not around `append`. The
460/// order matters: the length this returns is only meaningful to a writer that
461/// owns the file, and it is read after the lock so it cannot be stale by the
462/// time it is used.
463/// What a journal file's head says an opening writer must do about it.
464pub(crate) struct JournalHead {
465    /// The schema message this build writes.
466    pub schema: Vec<u8>,
467    /// Write it (again). False for a file that already opens with it.
468    pub rewrite: bool,
469}
470
471/// ── AN EMPTY STORE IS NOT MADE DURABLE, IT IS MADE AGAIN ────────────────────
472///
473/// Both durable arms used to `fsync` the schema message they wrote into a fresh
474/// journal. On gunnar's create path that barrier was 8.9 ms of a 68.5 ms
475/// push-create (lane V's per-step medians, 2 vCPU rig), spent making durable a
476/// file that references nothing: a store with no journal row has acked no pack,
477/// and gunnar's create is transactional, so a create that does not finish leaves
478/// no catalog row pointing at the store at all. The first row a writer claims
479/// fsyncs the same file and takes the schema with it, so from the first ack
480/// onward nothing about durability differs.
481///
482/// The one thing the barrier bought was that a schema torn by a crash could not
483/// be inherited by the next open as if it were whole. That is bought here
484/// instead, by **checking** — a short read of a file the open is about to read
485/// anyway:
486///
487/// * an empty file gets the schema;
488/// * a file that begins with exactly this build's schema message, and is at
489///   least as long as one, is a live journal and is left alone;
490/// * anything else **no longer than the schema** was interrupted before a row
491///   could exist behind it (rows are appended after it), so it is written again
492///   and nothing is lost;
493/// * anything else **longer** than the schema did carry rows, so it is corrupt
494///   rather than interrupted, and it is said out loud instead of truncated.
495///
496/// One function for both arms on purpose: LAW 5 already claims they are
497/// indistinguishable on disk, and two copies of this reasoning is how that stops
498/// being true.
499pub(crate) fn journal_head(f: &File, path: &Path) -> Result<JournalHead> {
500    let schema = encode_journal_schema()?;
501    let already = f
502        .metadata()
503        .map_err(|e| anyhow!("stat journal {}: {e}", path.display()))?
504        .len();
505    if already == 0 {
506        return Ok(JournalHead { schema, rewrite: true });
507    }
508    let mut got = vec![0u8; schema.len().min(already as usize)];
509    f.read_exact_at(&mut got, 0)
510        .map_err(|e| anyhow!("reading the journal header of {}: {e}", path.display()))?;
511    let head_matches = got == schema[..got.len()];
512    if head_matches && already >= schema.len() as u64 {
513        return Ok(JournalHead { schema, rewrite: false });
514    }
515    if already <= schema.len() as u64 {
516        return Ok(JournalHead { schema, rewrite: true });
517    }
518    bail!(
519        "the journal {} does not begin with this build's schema message and is longer than one, \
520         so it carried acked packs: it is corrupt, and truncating it would unreference their bytes",
521        path.display()
522    )
523}
524
525/// **The one open file description on a store's blob file** — read/write,
526/// `flock`ed, and shared.
527///
528/// `Arc<File>` rather than `File` because this handle serves three callers in
529/// one process and used to be opened three times: the writer appends through
530/// it, [`crate::git_ops::Absorber`] `pread`s extents back through it, and the
531/// index workers `read_exact_at` through it. All three address the file
532/// positionally, the file is append-only and `gc` truncates nothing (§14), so
533/// there is no shared cursor and nothing to observe between them — three
534/// descriptors bought three kernel objects and three page-cache aliases per
535/// open repository and no isolation at all. See `tests/fd_per_store.rs`.
536///
537/// The `flock` therefore lives as long as the last of the three, which is the
538/// store: a reader outliving the writer can no longer drop the writer lock out
539/// from under a store that is still open.
540pub(crate) fn open_blobs(path: &Path) -> Result<(Arc<File>, u64)> {
541    let f = OpenOptions::new()
542        .read(true)
543        .write(true)
544        .create(true)
545        .truncate(false)
546        .open(path)
547        .map_err(|e| anyhow!("open blobs {}: {e}", path.display()))?;
548    lock_for_writing(&f, path)?;
549    let end = reclaim_orphan_tail(&f, path)?;
550    Ok((Arc::new(f), end))
551}
552
553/// **Give back the bytes past the last journaled extent** — the append-side gc,
554/// run once at open while this process holds the writer `flock` and before any
555/// append can race it.
556///
557/// # The leak it closes, which is older than the caller that made it obvious
558///
559/// An append is two durable acts in order — the bytes, then the journal row
560/// naming them. A crash between them leaves bytes in the blob file that no
561/// extent addresses, and the cursor is taken from the file's LENGTH, so the next
562/// append starts past them and they are never reached again.
563/// [`SafeWriter::create_inner`]'s own comment already names the shape ("a reopen
564/// that dropped them would leave their bytes unreferenced for ever") — it is
565/// talking about the journal rows, and this is the other half.
566///
567/// Nothing referenced them, so nothing is lost by taking them back: a push whose
568/// journal row never landed was never acked, and the client will send it again.
569///
570/// # It refuses every ambiguous case rather than guessing
571///
572/// Truncation is the one operation here that cannot be undone, so it happens
573/// only when the journal positively says where the data ends:
574///
575/// * **no journal file at all** — [`FastWriter`] keeps none, so an absent
576///   journal is a normal arm and not evidence of anything. Left alone.
577/// * **a journal that reads as empty** — [`read_journal`] answers `Ok(vec![])`
578///   for a torn *schema* message as well as for a genuinely empty log, and those
579///   two are not distinguishable from here. Truncating on that reading would
580///   turn one bad message into the loss of every pack in the archive. Left
581///   alone, loudly.
582/// * **tombstones** ([`JOURNAL_TOMBSTONE`]) carry `u64::MAX` as their length and
583///   are skipped: their `blob_offset` names a retired pack, and adding that
584///   length would compute a high-water mark past the end of the universe.
585fn reclaim_orphan_tail(f: &File, archive: &Path) -> Result<u64> {
586    let len = f.metadata()?.len();
587    if len == 0 {
588        return Ok(0);
589    }
590    let journal = journal_path(archive);
591    if !journal.exists() {
592        return Ok(len);
593    }
594    let rows = read_journal(&journal)?;
595    let high_water = rows
596        .iter()
597        .filter(|(_, l)| *l != JOURNAL_TOMBSTONE)
598        .map(|(o, l)| o.saturating_add(*l))
599        .max();
600    let Some(high_water) = high_water else {
601        if len > 0 {
602            tracing::warn!(
603                target: "znippy.git",
604                archive = %archive.display(),
605                blob_bytes = len,
606                "the journal names no extent but the blob file is not empty — leaving it \
607                 alone: an unreadable journal and an empty one look the same from here, and \
608                 truncating on that reading would drop every pack in the archive"
609            );
610        }
611        return Ok(len);
612    };
613    if len <= high_water {
614        return Ok(len);
615    }
616    let orphan = len - high_water;
617    f.set_len(high_water)
618        .map_err(|e| anyhow!("reclaiming {orphan} orphan byte(s) of {}: {e}", archive.display()))?;
619    tracing::warn!(
620        target: "znippy.git",
621        archive = %archive.display(),
622        reclaimed = orphan,
623        blob_bytes = high_water,
624        "gave back blob bytes past the last journaled extent — a crash between an append \
625         and its journal row. Nothing referenced them; the push they belonged to was never \
626         acked"
627    );
628    Ok(high_water)
629}
630
631// ── impl 1 — FastWriter ─────────────────────────────────────────────────────
632
633/// **The cheating arm.** Returns before the bytes are on disk.
634///
635/// # What it does NOT guarantee
636///
637/// `append` returns as soon as one `pwrite(2)` has handed the bytes to the
638/// kernel's page cache. There is **no `fsync`**, **no journal row**, and **no
639/// barrier of any kind**. Concretely:
640///
641/// * **A crash (power loss, kernel panic, `SIGKILL` of the box) after `append`
642///   returns loses the data.** Not "may lose" — the bytes exist only in volatile
643///   page cache and nothing has told the device about them.
644/// * Nothing on disk references the extent, so even bytes that *did* reach the
645///   platter are unreachable after a restart until something scans for them.
646/// * A process crash alone (not a machine crash) does keep them: page cache
647///   survives `exit`. That is the only crash it survives.
648///
649/// It is here to **bound** [`SafeWriter`] and [`UringWriter`]: it is the ceiling
650/// the price of durability is measured against — at 8 KiB it acks in 3.9 µs
651/// against `SafeWriter`'s 132 µs (`examples/push_path_bench.rs`, oden
652/// 2026-08-07).
653///
654/// # Selecting it
655///
656/// It is a first-class arm of [`crate::arms::WriterArm`] and
657/// [`GitStore`](crate::git_ops::GitStore) will be built on it if it is asked
658/// for. Nothing refuses it, because "durable before ack" is git's contract and
659/// not every store on this code is serving git pushes: a mirror that can be
660/// re-cloned, an import that is re-run on failure and a benchmark all have a
661/// weaker requirement, and for them the four bullets above are a price they are
662/// not obliged to pay. A store that *is* serving pushes and picks this one loses
663/// acked data on a machine crash, and that is the whole of what the choice
664/// means.
665///
666/// There is one consequence beyond durability, because it follows from the same
667/// absence: with no journal there is no durable record that a pack was ever
668/// acked, so a reopened store re-queues nothing and pack ordinals restart at 0
669/// (`indexer::packs_already_acked`).
670///
671/// # Zero-copy
672///
673/// The caller's `bytes` are handed to `pwrite` at their own address. There is no
674/// `to_vec`, no staging buffer and no `BufWriter` — one userspace→kernel copy,
675/// which is the syscall itself and cannot be removed without io_uring registered
676/// buffers (see [`UringWriter`]).
677pub struct FastWriter {
678    blobs: Arc<File>,
679    cursor: AtomicU64,
680}
681
682impl FastWriter {
683    /// Open `archive` for verbatim appends.
684    pub fn create(archive: &Path) -> Result<Self> {
685        let (blobs, end) = open_blobs(archive)?;
686        Ok(Self {
687            blobs,
688            cursor: AtomicU64::new(end),
689        })
690    }
691
692    /// The blob file, for a reader that wants to `pread` an extent back.
693    pub fn blobs(&self) -> &File {
694        &self.blobs
695    }
696}
697
698impl ArchiveWrite for FastWriter {
699    fn append(&self, bytes: &[u8]) -> Result<Extent> {
700        let len = bytes.len() as u64;
701        // Reserve the extent atomically, then write it positionally. Two
702        // concurrent appends touch disjoint ranges and never share a file
703        // offset, so no lock is needed and none is taken.
704        let offset = self.cursor.fetch_add(len, Ordering::SeqCst);
705        self.blobs.write_all_at(bytes, offset)?;
706        // …and return. The bytes are in the page cache. That is the whole point.
707        Ok((offset, len))
708    }
709
710    fn name(&self) -> &'static str {
711        "FastWriter"
712    }
713
714    fn durability(&self) -> &'static str {
715        "none — page cache only; a machine crash after return loses the bytes"
716    }
717
718    fn blobs_shared(&self) -> Option<Arc<File>> {
719        Some(Arc::clone(&self.blobs))
720    }
721}
722
723// ── impl 2 — SafeWriter ─────────────────────────────────────────────────────
724
725struct SafeJournal {
726    /// The userspace buffer the trap above is about. Raw Arrow IPC messages go
727    /// into it — [`encode_journal_schema`] once, [`encode_journal_row`] per
728    /// append — the same two calls
729    /// [`UringWriter`](crate::uring_write::UringWriter) builds its journal from
730    /// (LAW 5: one journal format, one encoder, two transports). An arrow
731    /// `StreamWriter` cannot be used here because it emits
732    /// its schema on construction, and a second schema message is what an
733    /// appending reopen must not write.
734    writer: BufWriter<File>,
735    path: PathBuf,
736}
737
738/// The ordering znippy's own hot path already uses, followed rather than
739/// re-invented.
740///
741/// Per `append`, in this order and no other:
742///
743/// 1. `pwrite` the blob bytes at the reserved extent;
744/// 2. `fsync` the **blobs** — the bytes are on the platter;
745/// 3. write one Arrow IPC `RecordBatch` naming the extent into the journal;
746/// 4. `flush()` the journal's `BufWriter` — userspace → kernel;
747/// 5. `sync_all()` the journal file — kernel → platter.
748///
749/// Steps 4 and 5 are not interchangeable and 5 alone is not enough: see the
750/// module docs' "BufWriter trap". Steps 2 and 3 are not interchangeable either
751/// — that is the crash-ordering contract, and
752/// `crash_between_fsyncs_leaves_orphan_bytes_not_a_dangling_reference` asserts
753/// it on real files.
754///
755/// # Durability on return
756///
757/// The bytes are on the device and a journal row on the device points at them.
758/// A crash after return keeps both.
759pub struct SafeWriter {
760    blobs: Arc<File>,
761    cursor: AtomicU64,
762    journal: Mutex<SafeJournal>,
763    faults: Faults,
764}
765
766/// Injected faults, so the crash-ordering guard exercises the **real** `append`
767/// rather than a hand-rolled twin of it (LAW 5: one writer, not two copies that
768/// a guard then watches agree).
769///
770/// Never set outside a test. [`SafeWriter::create`] leaves both clear.
771#[derive(Debug, Clone, Copy, Default)]
772pub struct Faults {
773    /// Skip step (2), the blob `fsync`.
774    pub skip_blob_fsync: bool,
775    /// Stop after step (2) and before step (3) — the exact on-disk state a
776    /// machine that dies between the two fsyncs leaves behind. `append` returns
777    /// `Err` because a Rust function has to return something; a real crash
778    /// simply would not return, and the bytes on the platter are identical
779    /// either way, which is what the guard asserts on.
780    pub die_between_fsyncs: bool,
781}
782
783impl SafeWriter {
784    /// Open `archive` and start a journal segment beside it at
785    /// `<archive>.journal`.
786    pub fn create(archive: &Path) -> Result<Self> {
787        Self::create_inner(archive, Faults::default())
788    }
789
790    /// Same, with an injected fault. **Tests only.**
791    pub fn create_with_faults(archive: &Path, faults: Faults) -> Result<Self> {
792        Self::create_inner(archive, faults)
793    }
794
795    fn create_inner(archive: &Path, faults: Faults) -> Result<Self> {
796        let (blobs, end) = open_blobs(archive)?;
797        let path = journal_path(archive);
798        // **Append, never truncate** (module docs): the rows already here are the
799        // durable record that those packs were acked, and a reopen that dropped
800        // them would leave their bytes unreferenced for ever.
801        let f = OpenOptions::new()
802            .read(true)
803            .append(true)
804            .create(true)
805            .open(&path)
806            .map_err(|e| anyhow!("open journal {}: {e}", path.display()))?;
807        let head = journal_head(&f, &path)?;
808        if head.rewrite && f.metadata()?.len() != 0 {
809            f.set_len(0)
810                .map_err(|e| anyhow!("resetting the interrupted journal {}: {e}", path.display()))?;
811        }
812        // DELIBERATELY buffered. See the module docs: the flush→sync ordering is
813        // only load-bearing when there is a userspace buffer to lose.
814        let mut j = SafeJournal {
815            writer: BufWriter::new(f),
816            path,
817        };
818        // The schema message opens the stream and is written **once per file**,
819        // not once per writer.
820        if head.rewrite {
821            j.writer
822                .write_all(&head.schema)
823                .map_err(|e| anyhow!("journal schema: {e}"))?;
824            // Out of the userspace buffer, so the next `sync_all` — the one the
825            // first acked pack pays — has something to make durable.
826            j.writer.flush().map_err(|e| anyhow!("journal flush: {e}"))?;
827        }
828        Ok(Self {
829            blobs,
830            cursor: AtomicU64::new(end),
831            journal: Mutex::new(j),
832            faults,
833        })
834    }
835
836    /// Path of the journal segment beside `archive`.
837    pub fn journal_path(archive: &Path) -> PathBuf {
838        journal_path(archive)
839    }
840
841    /// The blob file, for a reader that wants to `pread` an extent back.
842    pub fn blobs(&self) -> &File {
843        &self.blobs
844    }
845
846    /// The journal segment this writer is appending rows to.
847    pub fn journal_file(&self) -> PathBuf {
848        self.journal.lock().expect("journal mutex").path.clone()
849    }
850}
851
852pub(crate) fn journal_path(archive: &Path) -> PathBuf {
853    let mut s = archive.as_os_str().to_os_string();
854    s.push(".journal");
855    PathBuf::from(s)
856}
857
858impl ArchiveWrite for SafeWriter {
859    fn append(&self, bytes: &[u8]) -> Result<Extent> {
860        let len = bytes.len() as u64;
861        let offset = self.cursor.fetch_add(len, Ordering::SeqCst);
862
863        // (1) blob bytes, positional, no userspace copy of `bytes`.
864        self.blobs.write_all_at(bytes, offset)?;
865
866        // (2) blob bytes DURABLE — before anything references them. A crash from
867        //     here to the end of this function leaves orphan payload nobody
868        //     points at, which the seal drops. The reverse order would leave a
869        //     row pointing into a hole.
870        if !self.faults.skip_blob_fsync {
871            self.blobs.sync_all()?;
872        }
873
874        if self.faults.die_between_fsyncs {
875            // The machine is gone. On-disk state: the blob bytes, fsynced, and a
876            // journal that has never heard of them. Orphan payload, not a
877            // dangling reference.
878            bail!("injected crash between the two fsyncs");
879        }
880
881        let mut j = self
882            .journal
883            .lock()
884            .map_err(|_| anyhow!("journal mutex poisoned"))?;
885        // (3) the row that claims the extent — one IPC batch message appended
886        //     after every row this archive has ever acked.
887        let row = encode_journal_row(offset, len)?;
888        j.writer
889            .write_all(&row)
890            .map_err(|e| anyhow!("journal write: {e}"))?;
891        // (4) userspace -> kernel. WITHOUT THIS, (5) SYNCS NOTHING.
892        j.writer.flush().map_err(|e| anyhow!("journal flush: {e}"))?;
893        // (5) kernel -> platter.
894        j.writer
895            .get_ref()
896            .sync_all()
897            .map_err(|e| anyhow!("journal fsync: {e}"))?;
898
899        Ok((offset, len))
900    }
901
902    fn name(&self) -> &'static str {
903        "SafeWriter"
904    }
905
906    fn durability(&self) -> &'static str {
907        "full — blob fsynced, then a journal row fsynced; crash after return keeps both"
908    }
909
910    fn blobs_shared(&self) -> Option<Arc<File>> {
911        Some(Arc::clone(&self.blobs))
912    }
913}
914
915// ── generation 0: the seal that makes an archive exist at all ────────────────
916
917/// What one [`seal_generation_zero`] put on disk. Every count is taken off the
918/// journal and the sealed file, never echoed back from an argument.
919#[derive(Debug, Clone, PartialEq, Eq)]
920pub struct SealReport {
921    /// The archive that now exists.
922    pub archive: PathBuf,
923    /// Verbatim packs that got an index row — one row each, `chunk_seq = 0`.
924    pub packs_sealed: u64,
925    /// Acked packs the journal has since tombstoned. They keep their bytes in
926    /// the blob region and get **no** row, which is what makes them dead payload
927    /// for the compaction that follows.
928    pub packs_retired: u64,
929    /// Acked packs whose extent runs past the bytes that were copied — a pack
930    /// acked *after* the copy began. They get no row either, because a row for
931    /// one would address bytes this archive does not carry. Normally `0`; it is
932    /// reported rather than swallowed so a seal that raced a push says so.
933    pub packs_after_copy: u64,
934    /// Bytes of blob region carried into the archive — the whole blob file,
935    /// verbatim, so every journal extent still addresses the bytes it named.
936    pub blob_bytes: u64,
937    /// Size of the sealed archive, blob region and metadata tail together.
938    pub sealed_total_bytes: u64,
939}
940
941/// **Write generation 0.**
942///
943/// Both [`Gc`](crate::gc::Gc) implementations compact an archive that already
944/// exists — [`NewGeneration`](crate::gc::NewGeneration) writes `repository.g1.znippy`
945/// beside `repository.znippy`. Nothing created `repository.znippy`, so a `gc()`
946/// on a store that had never been sealed died on `stat: No such file or
947/// directory`. This is the function that had been missing.
948///
949/// # An archive is a blob region plus a metadata tail
950///
951/// The blob region here is the store's `objects.pack` — the verbatim pushed
952/// packs, at the offsets the journal named — and it is copied **whole and
953/// unchanged**. That is not laziness: a journal extent is `(offset, len)` into
954/// that file, and the same extents are what the store's `objects` table holds
955/// for every object. Rewriting the region to squeeze the dead packs out would
956/// move every offset after the first hole and invalidate every one of those
957/// rows. Reclaiming that payload is the compaction's job, and it can do it
958/// precisely because a retired pack gets no row here.
959///
960/// The tail is written past the copied blob region by
961/// [`ArrowIpcSink`](znippy_common::ArrowIpcSink) — the same writer
962/// `HotArchive::seal` uses, with the same reserved-section hook, so there is one
963/// metadata writer in this constellation and not two (LAW 5).
964///
965/// # One row per acked pack
966///
967/// | column | value |
968/// |---|---|
969/// | `relative_path` | `objects.pack.<ordinal>` — gunnar's `synthetic_path` convention, and the ordinal is the pack's position among the journal's [`JournalRow::Pack`] rows |
970/// | `blob_offset` / `blob_size` | the journal extent, unchanged |
971/// | `uncompressed_size` | the same length: a verbatim pack is stored raw |
972/// | `compressed` | `false` — the bytes on disk are the pack's own |
973/// | `chunk_seq` / `fdata_offset` | `0` — one chunk per pack |
974/// | `checksum` | blake3 **over the stored bytes**, which for `compressed: false` are also the original bytes — the domain `write_blobs` uses and the domain `extract_file_verified` checks against |
975///
976/// A tombstoned pack is skipped, and so is one whose extent runs past the bytes
977/// that were copied — that second case is a pack acked *after* the copy began,
978/// and a row for it would point into a hole. Both are **counted** on the
979/// [`SealReport`] rather than swallowed.
980///
981/// # Ordering
982///
983/// The blob region is copied first and the journal is read second, because the
984/// blob file is append-only: anything the journal names within the copied length
985/// is certainly present, while the reverse order could name an extent the copy
986/// had not reached. The tail is written into a staging sibling and the archive's
987/// own name appears only at the final `rename(2)`, so an interruption at any
988/// byte leaves no half-sealed archive under the name a reader opens.
989pub fn seal_generation_zero(
990    blobs: &Path,
991    journal: Option<&Path>,
992    archive: &Path,
993    reserved: Vec<znippy_common::ReservedSection>,
994) -> Result<SealReport> {
995    use znippy_common::index::{ChunkLoc, data_subindex_schema};
996    use znippy_common::{ArchiveMetaSink, ArrowIpcSink, GroupKey, base_batch_from_rows};
997
998    let staged = staging_sibling(archive);
999    let sealed = (|| -> Result<SealReport> {
1000        // (1) the blob region, verbatim. Every journal extent still addresses
1001        //     the bytes it named because not one of them moved.
1002        let blob_bytes = std::fs::copy(blobs, &staged).map_err(|e| {
1003            anyhow!(
1004                "copying the blob region {} into {}: {e}",
1005                blobs.display(),
1006                staged.display()
1007            )
1008        })?;
1009
1010        // (2) the journal, second — see the ordering note above.
1011        let rows = match journal {
1012            Some(p) if p.exists() => read_journal(p)?,
1013            _ => Vec::new(),
1014        };
1015        let packs = acked_packs(&rows);
1016        let retired = retired_offsets(&rows);
1017
1018        let file = OpenOptions::new()
1019            .read(true)
1020            .write(true)
1021            .open(&staged)
1022            .map_err(|e| anyhow!("reopening {} to seal into: {e}", staged.display()))?;
1023
1024        let mut paths: Vec<String> = Vec::with_capacity(packs.len());
1025        let mut locs: Vec<ChunkLoc> = Vec::with_capacity(packs.len());
1026        let mut packs_retired = 0u64;
1027        let mut packs_after_copy = 0u64;
1028        for (ordinal, &(offset, len)) in packs.iter().enumerate() {
1029            if retired.contains(&offset) {
1030                packs_retired += 1;
1031                continue;
1032            }
1033            if offset.saturating_add(len) > blob_bytes {
1034                // Acked after the copy began. A row for it would address bytes
1035                // this archive does not carry.
1036                packs_after_copy += 1;
1037                continue;
1038            }
1039            paths.push(format!("objects.pack.{ordinal}"));
1040            locs.push(ChunkLoc {
1041                chunk_seq: 0,
1042                fdata_offset: 0,
1043                blob_offset: offset,
1044                blob_size: len,
1045                uncompressed_size: len,
1046                compressed: false,
1047                checksum: blake3_extent(&file, offset, len)?,
1048            });
1049        }
1050        let packs_sealed = paths.len() as u64;
1051
1052        // (3) the metadata tail, past the blob region.
1053        let file = Arc::new(file);
1054        let mut sink = ArrowIpcSink::new(Arc::clone(&file), blob_bytes);
1055        if !reserved.is_empty() {
1056            sink = sink.with_reserved_builder(Box::new(move |_| Ok(reserved)));
1057        }
1058        if !paths.is_empty() {
1059            let batch = base_batch_from_rows(&paths, &locs)?;
1060            sink.push_subindex(
1061                data_subindex_schema().as_ref(),
1062                &[batch],
1063                GroupKey {
1064                    pkg_type: 0,
1065                    repo: String::new(),
1066                    module_name: String::new(),
1067                },
1068            )?;
1069        }
1070        // `finish` fsyncs before it returns the total.
1071        let sealed_total_bytes = Box::new(sink).finish()?;
1072
1073        Ok(SealReport {
1074            archive: archive.to_path_buf(),
1075            packs_sealed,
1076            packs_retired,
1077            packs_after_copy,
1078            blob_bytes,
1079            sealed_total_bytes,
1080        })
1081    })();
1082
1083    let sealed = match sealed {
1084        Ok(s) => s,
1085        Err(e) => {
1086            let _ = std::fs::remove_file(&staged);
1087            return Err(e);
1088        }
1089    };
1090
1091    // (4) the archive's own name, atomically, and only now.
1092    std::fs::rename(&staged, archive).map_err(|e| {
1093        let _ = std::fs::remove_file(&staged);
1094        anyhow!(
1095            "renaming {} into place as {}: {e}",
1096            staged.display(),
1097            archive.display()
1098        )
1099    })?;
1100    sync_parent_dir(archive);
1101    Ok(sealed)
1102}
1103
1104/// blake3 over `(offset, len)` of `file`, streamed rather than buffered whole:
1105/// a consolidated pack is tens of megabytes and there is no reason for the seal
1106/// to hold one in memory to hash it.
1107fn blake3_extent(file: &File, offset: u64, len: u64) -> Result<[u8; 32]> {
1108    const WINDOW: usize = 1 << 20;
1109    let mut hasher = znippy_common::blake3::Hasher::new();
1110    let mut buf = vec![0u8; WINDOW.min(len.max(1) as usize)];
1111    let mut done = 0u64;
1112    while done < len {
1113        let want = ((len - done) as usize).min(buf.len());
1114        file.read_exact_at(&mut buf[..want], offset + done)
1115            .map_err(|e| anyhow!("reading the pack at ({offset}, {len}) to checksum it: {e}"))?;
1116        hasher.update(&buf[..want]);
1117        done += want as u64;
1118    }
1119    Ok(*hasher.finalize().as_bytes())
1120}
1121
1122/// A sibling name nothing else can be holding: pid and nanos, the same shape
1123/// `gc.rs` stages its compaction under.
1124fn staging_sibling(archive: &Path) -> PathBuf {
1125    let unique = std::time::SystemTime::now()
1126        .duration_since(std::time::UNIX_EPOCH)
1127        .map(|d| d.as_nanos())
1128        .unwrap_or(0);
1129    let mut p = archive.as_os_str().to_owned();
1130    p.push(format!(".seal-{}-{unique}", std::process::id()));
1131    PathBuf::from(p)
1132}
1133
1134/// fsync the directory so the rename is durable and not merely visible.
1135/// Best-effort, like `gc::sync_dir`: a filesystem that will not open a directory
1136/// is not a reason to fail a seal that otherwise succeeded.
1137fn sync_parent_dir(path: &Path) {
1138    if let Some(parent) = path.parent()
1139        && let Ok(f) = File::open(parent)
1140    {
1141        let _ = f.sync_all();
1142    }
1143}