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
125// ── the journal, shared by every durable arm ────────────────────────────────
126
127/// Alignment of an IPC message in the journal. 8 is what
128/// `znippy_common::hot`'s journal segments use.
129pub const JOURNAL_ALIGNMENT: u8 = 8;
130
131/// The journal's schema: the reference from a row to the blob bytes.
132///
133/// Deliberately two `u64`s and nothing else. The journal's job is to say *these
134/// bytes exist at this extent*; everything derived from the bytes (object count,
135/// pack version, checksum) is the indexer's job and is built later, off this
136/// path.
137pub fn journal_schema() -> SchemaRef {
138 Arc::new(Schema::new(vec![
139 Field::new("blob_offset", DataType::UInt64, false),
140 Field::new("blob_size", DataType::UInt64, false),
141 ]))
142}
143
144/// The IPC write options every arm's journal uses, so the three arms' journals
145/// are byte-comparable.
146pub fn journal_options() -> Result<IpcWriteOptions> {
147 IpcWriteOptions::try_new(JOURNAL_ALIGNMENT as usize, false, MetadataVersion::V5)
148 .map_err(|e| anyhow!("journal write options: {e}"))
149}
150
151/// One journal row for one extent.
152pub fn journal_batch(offset: u64, len: u64) -> Result<RecordBatch> {
153 let off: ArrayRef = Arc::new(UInt64Array::from(vec![offset]));
154 let size: ArrayRef = Arc::new(UInt64Array::from(vec![len]));
155 RecordBatch::try_new(journal_schema(), vec![off, size])
156 .map_err(|e| anyhow!("journal batch: {e}"))
157}
158
159/// Serialize the journal's *schema* message — the header an Arrow IPC stream
160/// opens with, emitted once per segment.
161///
162/// Both durable arms call it, and each writes it **once per journal file**:
163/// [`SafeWriter`] when it finds the file empty,
164/// [`UringWriter`](crate::uring_write::UringWriter) at offset 0 of the one it
165/// creates. One encoder, one format, two transports (LAW 5) — and a
166/// second schema message inside one file is what would make [`read_journal()`]
167/// stop early, which is why neither arm can emit it per writer.
168pub fn encode_journal_schema() -> Result<Vec<u8>> {
169 let opts = journal_options()?;
170 let dg = IpcDataGenerator {};
171 let mut tracker = DictionaryTracker::new(false);
172 let encoded =
173 dg.schema_to_bytes_with_dictionary_tracker(journal_schema().as_ref(), &mut tracker, &opts);
174 let mut out = Vec::with_capacity(512);
175 write_message(&mut out, encoded, &opts).map_err(|e| anyhow!("journal schema encode: {e}"))?;
176 Ok(out)
177}
178
179/// Serialize one journal *batch* message for `(offset, len)`.
180pub fn encode_journal_row(offset: u64, len: u64) -> Result<Vec<u8>> {
181 let opts = journal_options()?;
182 let dg = IpcDataGenerator {};
183 let mut tracker = DictionaryTracker::new(false);
184 let batch = journal_batch(offset, len)?;
185 let (dicts, msg) = dg
186 .encode(&batch, &mut tracker, &opts, &mut Default::default())
187 .map_err(|e| anyhow!("journal batch encode: {e}"))?;
188 let mut out = Vec::with_capacity(512);
189 for d in dicts {
190 write_message(&mut out, d, &opts).map_err(|e| anyhow!("journal dict encode: {e}"))?;
191 }
192 write_message(&mut out, msg, &opts).map_err(|e| anyhow!("journal row encode: {e}"))?;
193 Ok(out)
194}
195
196/// Read every complete journal row back, tolerating a torn tail.
197///
198/// A journal is a **log**, not a document: a process killed mid-write leaves a
199/// partial final message, and every complete message before it is real. Same
200/// rule `znippy_common::hot`'s segment reader follows. Used by the guards to
201/// assert what a crash actually left behind rather than what the writer claimed.
202pub fn read_journal(path: &Path) -> Result<Vec<Extent>> {
203 let f = File::open(path).map_err(|e| anyhow!("journal open {}: {e}", path.display()))?;
204 if f.metadata()?.len() == 0 {
205 return Ok(Vec::new());
206 }
207 let reader = match arrow::ipc::reader::StreamReader::try_new(std::io::BufReader::new(f), None) {
208 Ok(r) => r,
209 // A journal with a torn *schema* message carries no rows at all.
210 Err(_) => return Ok(Vec::new()),
211 };
212 let mut out = Vec::new();
213 for batch in reader {
214 let Ok(batch) = batch else { break }; // torn tail: stop, keep what is whole
215 let offs = batch
216 .column(0)
217 .as_any()
218 .downcast_ref::<UInt64Array>()
219 .ok_or_else(|| anyhow!("journal column 0 is not u64"))?;
220 let sizes = batch
221 .column(1)
222 .as_any()
223 .downcast_ref::<UInt64Array>()
224 .ok_or_else(|| anyhow!("journal column 1 is not u64"))?;
225 for i in 0..batch.num_rows() {
226 out.push((offs.value(i), sizes.value(i)));
227 }
228 }
229 Ok(out)
230}
231
232// ── retirement: the one row a `gc` appends ──────────────────────────────────
233
234/// The `blob_size` of a journal row that is **not** a pack: a **tombstone**,
235/// whose `blob_offset` names a pack this archive has retired.
236///
237/// `u64::MAX` rather than `0`, and that is the whole of the encoding decision.
238/// The journal's schema is two `u64`s and a third column cannot be added without
239/// making every existing journal file unreadable by the writer that appends to
240/// it, so the marker has to live inside a value that no real pack can take. A
241/// zero-length append is at least *conceivable* — a caller handing `append` an
242/// empty slice gets `(cursor, 0)` — while an extent of 2^64-1 bytes cannot
243/// exist on any filesystem this will ever run on. The unreachable value is the
244/// safe one.
245pub const JOURNAL_TOMBSTONE: u64 = u64::MAX;
246
247/// One journal row, read back and interpreted.
248///
249/// **The ordinal of a pack is its position among the [`Pack`](JournalRow::Pack)
250/// rows, not its row index.** Tombstones are appended to the same log — that is
251/// what keeps the journal append-only — so counting raw rows would renumber
252/// every pack acked before a `gc` and make the ordinals a live process holds
253/// disagree with the ones the next open derives. Counting pack rows cannot: a
254/// pack row is never removed and never reordered, so a pack's ordinal is fixed
255/// for the life of the archive.
256#[derive(Debug, Clone, Copy, PartialEq, Eq)]
257pub enum JournalRow {
258 /// A pack was acked at this extent.
259 Pack(Extent),
260 /// The pack that starts at this offset was retired: a `gc` found **every**
261 /// one of its objects dead and dropped every row it had. Its bytes are still
262 /// in the blob file (the blob file is append-only and nothing truncates it),
263 /// but no open may ever re-queue it for indexing again.
264 Retired(u64),
265}
266
267/// Interpret what [`read_journal`] returned.
268pub fn journal_rows(rows: &[Extent]) -> Vec<JournalRow> {
269 rows.iter()
270 .map(|&(offset, len)| {
271 if len == JOURNAL_TOMBSTONE {
272 JournalRow::Retired(offset)
273 } else {
274 JournalRow::Pack((offset, len))
275 }
276 })
277 .collect()
278}
279
280/// The acked pack extents, in **ordinal order** — index `i` is pack `i`.
281pub fn acked_packs(rows: &[Extent]) -> Vec<Extent> {
282 journal_rows(rows)
283 .into_iter()
284 .filter_map(|r| match r {
285 JournalRow::Pack(e) => Some(e),
286 JournalRow::Retired(_) => None,
287 })
288 .collect()
289}
290
291/// The offsets of the packs a `gc` has retired.
292pub fn retired_offsets(rows: &[Extent]) -> std::collections::HashSet<u64> {
293 journal_rows(rows)
294 .into_iter()
295 .filter_map(|r| match r {
296 JournalRow::Retired(o) => Some(o),
297 JournalRow::Pack(_) => None,
298 })
299 .collect()
300}
301
302/// **Retire these packs: one tombstone row each, durable before this returns.**
303///
304/// Called by `GitOps::gc` *before* it drops a single index row, and that order
305/// is the crash contract. The two possible interruptions are not symmetric:
306///
307/// * killed **before** the tombstones are durable — the rows are still in the
308/// index, so the next open sees packs that have rows, calls them absorbed, and
309/// nothing is lost or resurrected. The GC simply did not happen.
310/// * killed **after** — the rows may or may not have gone, and either way the
311/// next open reads a tombstone and refuses to re-queue the pack. The dead
312/// objects cannot come back.
313///
314/// The reverse order has a window in which the rows are gone and the journal
315/// still claims an unabsorbed pack, which is exactly the resurrection this
316/// function exists to close.
317///
318/// A torn write leaves a partial final message, which [`read_journal`] discards
319/// with the rest of a torn tail — the same tolerance a torn *pack* row gets, and
320/// safe for the same reason: a tombstone that did not land is a `gc` that did not
321/// happen.
322///
323/// All the rows go out in **one** `write(2)` on an `O_APPEND` fd, so a push
324/// appending through [`SafeWriter`]'s own fd at the same instant cannot land
325/// inside one of them.
326pub fn retire_packs(journal: &Path, offsets: &[u64]) -> Result<()> {
327 if offsets.is_empty() {
328 return Ok(());
329 }
330 let mut buf = Vec::with_capacity(offsets.len() * 256);
331 for &offset in offsets {
332 buf.extend_from_slice(&encode_journal_row(offset, JOURNAL_TOMBSTONE)?);
333 }
334 let f = OpenOptions::new()
335 .append(true)
336 .open(journal)
337 .map_err(|e| anyhow!("open journal {} to retire a pack: {e}", journal.display()))?;
338 if f.metadata()?.len() == 0 {
339 // No schema message, therefore no pack row, therefore nothing that could
340 // have been retired. Writing a tombstone into an empty stream would
341 // produce a journal whose first message is a batch, which reads back as
342 // no rows at all.
343 bail!(
344 "{} is empty — nothing was ever acked here, so there is no pack to retire",
345 journal.display()
346 );
347 }
348 (&f).write_all(&buf)
349 .map_err(|e| anyhow!("journal tombstone write: {e}"))?;
350 f.sync_all()
351 .map_err(|e| anyhow!("journal tombstone fsync: {e}"))?;
352 Ok(())
353}
354
355/// **The writer lock** — `P-014`. One writer per blob file, enforced by the
356/// kernel, held for as long as the writer's handle lives.
357///
358/// # What it is protecting against, measured
359///
360/// Every arm reserves its extent from an `AtomicU64` seeded here from the file's
361/// length **at open time**. Two processes that both open before either appends
362/// therefore hold two cursors with the same value and hand out *the same
363/// offsets*. Driven on t14s 2026-08-11, two processes × 16 one-blob packs on
364/// `SafeWriter`, released together: 32 packs acked, `objects.pack` never grew
365/// past one writer's worth, and **16 of 32 acked, fsynced, journalled objects
366/// were destroyed with two zero exit codes**. Neither writer errored, which is
367/// the whole problem.
368///
369/// # Why `flock` and not an `O_EXCL` lock file
370///
371/// `S-019`: *"the `O_EXCL` store lock does not self-clear; a killed appliance
372/// will not restart."* A lock that survives a crash is worse than the race it
373/// prevents. `flock(2)` is released by the kernel when the last descriptor for
374/// the open file description closes — including on `SIGKILL`, including on a
375/// panic — so a killed server comes back up. Verified rather than recited: a
376/// store held by one process, `kill -9`, and the next open succeeds.
377///
378/// [`std::fs::File::try_lock`] *is* `flock(LOCK_EX | LOCK_NB)` on unix, so this
379/// costs no dependency, and it is **the same mechanism redb already uses** on
380/// `objects.tail` (`redb-2.6.3/src/tree_store/page_store/file_backend/unix.rs:37`).
381/// One locking discipline in this store, not two.
382///
383/// # Why at open, and NOT around `append`
384///
385/// The ack path is the latency path: `append` returns to the client and the
386/// indexer runs after it. A lock taken per append would put a syscall on that
387/// path and would still not help, because the *cursor* is what is stale, not the
388/// write. Taken once here, the ack path takes **no lock at all** and nothing
389/// about `append`'s cost changes.
390///
391/// It is here, in `open_blobs`, because that is the one function
392/// [`FastWriter`], [`SafeWriter`] and
393/// [`UringWriter`](crate::uring_write::UringWriter) all call — one lock for
394/// three arms, rather than one arm fixed and two left. It also runs *before*
395/// `UringWriter::create`'s `File::create(journal)`, so a second process can no
396/// longer truncate the journal on its way to being refused.
397///
398/// # What it deliberately does NOT cover
399///
400/// **Two threads sharing one open handle.** An advisory lock is per open file
401/// *description*; a second `try_lock` on the same handle returns `Ok`. That case
402/// is `tests/concurrent_push.rs`'s second test, and it needs no lock: the
403/// `fetch_add` cursor every arm shares makes two concurrent appends touch
404/// provably disjoint ranges. A mutex there would serialise the ack path to fix a
405/// race that cannot happen.
406///
407/// # Refuse, do not wait
408///
409/// `LOCK_NB`, matching redb. A server that blocked here would hang on startup
410/// behind a process it cannot see, with no error and no timeout; a refusal names
411/// the file and the caller already handles one, because redb's has been throwing
412/// it all along.
413fn lock_for_writing(f: &File, path: &Path) -> Result<()> {
414 match f.try_lock() {
415 Ok(()) => Ok(()),
416 Err(std::fs::TryLockError::WouldBlock) => bail!(
417 "another writer already holds {} — one process at a time appends to a store's \
418 blob file. Two would share a stale append cursor and overwrite each other's \
419 acked packs without either one erroring. The lock is an advisory flock and the \
420 kernel drops it when that process dies, so nothing has to be cleaned up by hand.",
421 path.display()
422 ),
423 Err(std::fs::TryLockError::Error(e)) => Err(anyhow!(
424 "locking {} for writing: {e} — the store is not opened without the writer lock, \
425 because an unlocked open is the P-014 race",
426 path.display()
427 )),
428 }
429}
430
431/// Open (creating) the blob file an archive's payload region lives in, and
432/// report the cursor to append at.
433///
434/// **Takes the writer lock** before reading the cursor — see
435/// [`lock_for_writing`] for why the lock is here and not around `append`. The
436/// order matters: the length this returns is only meaningful to a writer that
437/// owns the file, and it is read after the lock so it cannot be stale by the
438/// time it is used.
439pub(crate) fn open_blobs(path: &Path) -> Result<(File, u64)> {
440 let f = OpenOptions::new()
441 .read(true)
442 .write(true)
443 .create(true)
444 .truncate(false)
445 .open(path)
446 .map_err(|e| anyhow!("open blobs {}: {e}", path.display()))?;
447 lock_for_writing(&f, path)?;
448 let end = f.metadata()?.len();
449 Ok((f, end))
450}
451
452// ── impl 1 — FastWriter ─────────────────────────────────────────────────────
453
454/// **The cheating arm.** Returns before the bytes are on disk.
455///
456/// # What it does NOT guarantee
457///
458/// `append` returns as soon as one `pwrite(2)` has handed the bytes to the
459/// kernel's page cache. There is **no `fsync`**, **no journal row**, and **no
460/// barrier of any kind**. Concretely:
461///
462/// * **A crash (power loss, kernel panic, `SIGKILL` of the box) after `append`
463/// returns loses the data.** Not "may lose" — the bytes exist only in volatile
464/// page cache and nothing has told the device about them.
465/// * Nothing on disk references the extent, so even bytes that *did* reach the
466/// platter are unreachable after a restart until something scans for them.
467/// * A process crash alone (not a machine crash) does keep them: page cache
468/// survives `exit`. That is the only crash it survives.
469///
470/// It is here to **bound** [`SafeWriter`] and [`UringWriter`]: it is the ceiling
471/// the price of durability is measured against — at 8 KiB it acks in 3.9 µs
472/// against `SafeWriter`'s 132 µs (`examples/push_path_bench.rs`, oden
473/// 2026-08-07).
474///
475/// # Selecting it
476///
477/// It is a first-class arm of [`crate::arms::WriterArm`] and
478/// [`GitStore`](crate::git_ops::GitStore) will be built on it if it is asked
479/// for. Nothing refuses it, because "durable before ack" is git's contract and
480/// not every store on this code is serving git pushes: a mirror that can be
481/// re-cloned, an import that is re-run on failure and a benchmark all have a
482/// weaker requirement, and for them the four bullets above are a price they are
483/// not obliged to pay. A store that *is* serving pushes and picks this one loses
484/// acked data on a machine crash, and that is the whole of what the choice
485/// means.
486///
487/// There is one consequence beyond durability, because it follows from the same
488/// absence: with no journal there is no durable record that a pack was ever
489/// acked, so a reopened store re-queues nothing and pack ordinals restart at 0
490/// (`indexer::packs_already_acked`).
491///
492/// # Zero-copy
493///
494/// The caller's `bytes` are handed to `pwrite` at their own address. There is no
495/// `to_vec`, no staging buffer and no `BufWriter` — one userspace→kernel copy,
496/// which is the syscall itself and cannot be removed without io_uring registered
497/// buffers (see [`UringWriter`]).
498pub struct FastWriter {
499 blobs: File,
500 cursor: AtomicU64,
501}
502
503impl FastWriter {
504 /// Open `archive` for verbatim appends.
505 pub fn create(archive: &Path) -> Result<Self> {
506 let (blobs, end) = open_blobs(archive)?;
507 Ok(Self {
508 blobs,
509 cursor: AtomicU64::new(end),
510 })
511 }
512
513 /// The blob file, for a reader that wants to `pread` an extent back.
514 pub fn blobs(&self) -> &File {
515 &self.blobs
516 }
517}
518
519impl ArchiveWrite for FastWriter {
520 fn append(&self, bytes: &[u8]) -> Result<Extent> {
521 let len = bytes.len() as u64;
522 // Reserve the extent atomically, then write it positionally. Two
523 // concurrent appends touch disjoint ranges and never share a file
524 // offset, so no lock is needed and none is taken.
525 let offset = self.cursor.fetch_add(len, Ordering::SeqCst);
526 self.blobs.write_all_at(bytes, offset)?;
527 // …and return. The bytes are in the page cache. That is the whole point.
528 Ok((offset, len))
529 }
530
531 fn name(&self) -> &'static str {
532 "FastWriter"
533 }
534
535 fn durability(&self) -> &'static str {
536 "none — page cache only; a machine crash after return loses the bytes"
537 }
538}
539
540// ── impl 2 — SafeWriter ─────────────────────────────────────────────────────
541
542struct SafeJournal {
543 /// The userspace buffer the trap above is about. Raw Arrow IPC messages go
544 /// into it — [`encode_journal_schema`] once, [`encode_journal_row`] per
545 /// append — the same two calls
546 /// [`UringWriter`](crate::uring_write::UringWriter) builds its journal from
547 /// (LAW 5: one journal format, one encoder, two transports). An arrow
548 /// `StreamWriter` cannot be used here because it emits
549 /// its schema on construction, and a second schema message is what an
550 /// appending reopen must not write.
551 writer: BufWriter<File>,
552 path: PathBuf,
553}
554
555/// The ordering znippy's own hot path already uses, followed rather than
556/// re-invented.
557///
558/// Per `append`, in this order and no other:
559///
560/// 1. `pwrite` the blob bytes at the reserved extent;
561/// 2. `fsync` the **blobs** — the bytes are on the platter;
562/// 3. write one Arrow IPC `RecordBatch` naming the extent into the journal;
563/// 4. `flush()` the journal's `BufWriter` — userspace → kernel;
564/// 5. `sync_all()` the journal file — kernel → platter.
565///
566/// Steps 4 and 5 are not interchangeable and 5 alone is not enough: see the
567/// module docs' "BufWriter trap". Steps 2 and 3 are not interchangeable either
568/// — that is the crash-ordering contract, and
569/// `crash_between_fsyncs_leaves_orphan_bytes_not_a_dangling_reference` asserts
570/// it on real files.
571///
572/// # Durability on return
573///
574/// The bytes are on the device and a journal row on the device points at them.
575/// A crash after return keeps both.
576pub struct SafeWriter {
577 blobs: File,
578 cursor: AtomicU64,
579 journal: Mutex<SafeJournal>,
580 faults: Faults,
581}
582
583/// Injected faults, so the crash-ordering guard exercises the **real** `append`
584/// rather than a hand-rolled twin of it (LAW 5: one writer, not two copies that
585/// a guard then watches agree).
586///
587/// Never set outside a test. [`SafeWriter::create`] leaves both clear.
588#[derive(Debug, Clone, Copy, Default)]
589pub struct Faults {
590 /// Skip step (2), the blob `fsync`.
591 pub skip_blob_fsync: bool,
592 /// Stop after step (2) and before step (3) — the exact on-disk state a
593 /// machine that dies between the two fsyncs leaves behind. `append` returns
594 /// `Err` because a Rust function has to return something; a real crash
595 /// simply would not return, and the bytes on the platter are identical
596 /// either way, which is what the guard asserts on.
597 pub die_between_fsyncs: bool,
598}
599
600impl SafeWriter {
601 /// Open `archive` and start a journal segment beside it at
602 /// `<archive>.journal`.
603 pub fn create(archive: &Path) -> Result<Self> {
604 Self::create_inner(archive, Faults::default())
605 }
606
607 /// Same, with an injected fault. **Tests only.**
608 pub fn create_with_faults(archive: &Path, faults: Faults) -> Result<Self> {
609 Self::create_inner(archive, faults)
610 }
611
612 fn create_inner(archive: &Path, faults: Faults) -> Result<Self> {
613 let (blobs, end) = open_blobs(archive)?;
614 let path = journal_path(archive);
615 // **Append, never truncate** (module docs): the rows already here are the
616 // durable record that those packs were acked, and a reopen that dropped
617 // them would leave their bytes unreferenced for ever.
618 let f = OpenOptions::new()
619 .read(true)
620 .append(true)
621 .create(true)
622 .open(&path)
623 .map_err(|e| anyhow!("open journal {}: {e}", path.display()))?;
624 let already = f.metadata()?.len();
625 // DELIBERATELY buffered. See the module docs: the flush→sync ordering is
626 // only load-bearing when there is a userspace buffer to lose.
627 let mut j = SafeJournal {
628 writer: BufWriter::new(f),
629 path,
630 };
631 // The schema message opens the stream and is written **once per file**,
632 // not once per writer.
633 if already == 0 {
634 j.writer
635 .write_all(&encode_journal_schema()?)
636 .map_err(|e| anyhow!("journal schema: {e}"))?;
637 // The schema message itself is durable before any row is claimed.
638 j.writer.flush().map_err(|e| anyhow!("journal flush: {e}"))?;
639 j.writer
640 .get_ref()
641 .sync_all()
642 .map_err(|e| anyhow!("journal fsync: {e}"))?;
643 }
644 Ok(Self {
645 blobs,
646 cursor: AtomicU64::new(end),
647 journal: Mutex::new(j),
648 faults,
649 })
650 }
651
652 /// Path of the journal segment beside `archive`.
653 pub fn journal_path(archive: &Path) -> PathBuf {
654 journal_path(archive)
655 }
656
657 /// The blob file, for a reader that wants to `pread` an extent back.
658 pub fn blobs(&self) -> &File {
659 &self.blobs
660 }
661
662 /// The journal segment this writer is appending rows to.
663 pub fn journal_file(&self) -> PathBuf {
664 self.journal.lock().expect("journal mutex").path.clone()
665 }
666}
667
668pub(crate) fn journal_path(archive: &Path) -> PathBuf {
669 let mut s = archive.as_os_str().to_os_string();
670 s.push(".journal");
671 PathBuf::from(s)
672}
673
674impl ArchiveWrite for SafeWriter {
675 fn append(&self, bytes: &[u8]) -> Result<Extent> {
676 let len = bytes.len() as u64;
677 let offset = self.cursor.fetch_add(len, Ordering::SeqCst);
678
679 // (1) blob bytes, positional, no userspace copy of `bytes`.
680 self.blobs.write_all_at(bytes, offset)?;
681
682 // (2) blob bytes DURABLE — before anything references them. A crash from
683 // here to the end of this function leaves orphan payload nobody
684 // points at, which the seal drops. The reverse order would leave a
685 // row pointing into a hole.
686 if !self.faults.skip_blob_fsync {
687 self.blobs.sync_all()?;
688 }
689
690 if self.faults.die_between_fsyncs {
691 // The machine is gone. On-disk state: the blob bytes, fsynced, and a
692 // journal that has never heard of them. Orphan payload, not a
693 // dangling reference.
694 bail!("injected crash between the two fsyncs");
695 }
696
697 let mut j = self
698 .journal
699 .lock()
700 .map_err(|_| anyhow!("journal mutex poisoned"))?;
701 // (3) the row that claims the extent — one IPC batch message appended
702 // after every row this archive has ever acked.
703 let row = encode_journal_row(offset, len)?;
704 j.writer
705 .write_all(&row)
706 .map_err(|e| anyhow!("journal write: {e}"))?;
707 // (4) userspace -> kernel. WITHOUT THIS, (5) SYNCS NOTHING.
708 j.writer.flush().map_err(|e| anyhow!("journal flush: {e}"))?;
709 // (5) kernel -> platter.
710 j.writer
711 .get_ref()
712 .sync_all()
713 .map_err(|e| anyhow!("journal fsync: {e}"))?;
714
715 Ok((offset, len))
716 }
717
718 fn name(&self) -> &'static str {
719 "SafeWriter"
720 }
721
722 fn durability(&self) -> &'static str {
723 "full — blob fsynced, then a journal row fsynced; crash after return keeps both"
724 }
725}
726
727// ── generation 0: the seal that makes an archive exist at all ────────────────
728
729/// What one [`seal_generation_zero`] put on disk. Every count is taken off the
730/// journal and the sealed file, never echoed back from an argument.
731#[derive(Debug, Clone, PartialEq, Eq)]
732pub struct SealReport {
733 /// The archive that now exists.
734 pub archive: PathBuf,
735 /// Verbatim packs that got an index row — one row each, `chunk_seq = 0`.
736 pub packs_sealed: u64,
737 /// Acked packs the journal has since tombstoned. They keep their bytes in
738 /// the blob region and get **no** row, which is what makes them dead payload
739 /// for the compaction that follows.
740 pub packs_retired: u64,
741 /// Acked packs whose extent runs past the bytes that were copied — a pack
742 /// acked *after* the copy began. They get no row either, because a row for
743 /// one would address bytes this archive does not carry. Normally `0`; it is
744 /// reported rather than swallowed so a seal that raced a push says so.
745 pub packs_after_copy: u64,
746 /// Bytes of blob region carried into the archive — the whole blob file,
747 /// verbatim, so every journal extent still addresses the bytes it named.
748 pub blob_bytes: u64,
749 /// Size of the sealed archive, blob region and metadata tail together.
750 pub sealed_total_bytes: u64,
751}
752
753/// **Write generation 0.**
754///
755/// Both [`Gc`](crate::gc::Gc) implementations compact an archive that already
756/// exists — [`NewGeneration`](crate::gc::NewGeneration) writes `repository.g1.znippy`
757/// beside `repository.znippy`. Nothing created `repository.znippy`, so a `gc()`
758/// on a store that had never been sealed died on `stat: No such file or
759/// directory`. This is the function that had been missing.
760///
761/// # An archive is a blob region plus a metadata tail
762///
763/// The blob region here is the store's `objects.pack` — the verbatim pushed
764/// packs, at the offsets the journal named — and it is copied **whole and
765/// unchanged**. That is not laziness: a journal extent is `(offset, len)` into
766/// that file, and the same extents are what the store's `objects` table holds
767/// for every object. Rewriting the region to squeeze the dead packs out would
768/// move every offset after the first hole and invalidate every one of those
769/// rows. Reclaiming that payload is the compaction's job, and it can do it
770/// precisely because a retired pack gets no row here.
771///
772/// The tail is written past the copied blob region by
773/// [`ArrowIpcSink`](znippy_common::ArrowIpcSink) — the same writer
774/// `HotArchive::seal` uses, with the same reserved-section hook, so there is one
775/// metadata writer in this constellation and not two (LAW 5).
776///
777/// # One row per acked pack
778///
779/// | column | value |
780/// |---|---|
781/// | `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 |
782/// | `blob_offset` / `blob_size` | the journal extent, unchanged |
783/// | `uncompressed_size` | the same length: a verbatim pack is stored raw |
784/// | `compressed` | `false` — the bytes on disk are the pack's own |
785/// | `chunk_seq` / `fdata_offset` | `0` — one chunk per pack |
786/// | `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 |
787///
788/// A tombstoned pack is skipped, and so is one whose extent runs past the bytes
789/// that were copied — that second case is a pack acked *after* the copy began,
790/// and a row for it would point into a hole. Both are **counted** on the
791/// [`SealReport`] rather than swallowed.
792///
793/// # Ordering
794///
795/// The blob region is copied first and the journal is read second, because the
796/// blob file is append-only: anything the journal names within the copied length
797/// is certainly present, while the reverse order could name an extent the copy
798/// had not reached. The tail is written into a staging sibling and the archive's
799/// own name appears only at the final `rename(2)`, so an interruption at any
800/// byte leaves no half-sealed archive under the name a reader opens.
801pub fn seal_generation_zero(
802 blobs: &Path,
803 journal: Option<&Path>,
804 archive: &Path,
805 reserved: Vec<znippy_common::ReservedSection>,
806) -> Result<SealReport> {
807 use znippy_common::index::{ChunkLoc, data_subindex_schema};
808 use znippy_common::{ArchiveMetaSink, ArrowIpcSink, GroupKey, base_batch_from_rows};
809
810 let staged = staging_sibling(archive);
811 let sealed = (|| -> Result<SealReport> {
812 // (1) the blob region, verbatim. Every journal extent still addresses
813 // the bytes it named because not one of them moved.
814 let blob_bytes = std::fs::copy(blobs, &staged).map_err(|e| {
815 anyhow!(
816 "copying the blob region {} into {}: {e}",
817 blobs.display(),
818 staged.display()
819 )
820 })?;
821
822 // (2) the journal, second — see the ordering note above.
823 let rows = match journal {
824 Some(p) if p.exists() => read_journal(p)?,
825 _ => Vec::new(),
826 };
827 let packs = acked_packs(&rows);
828 let retired = retired_offsets(&rows);
829
830 let file = OpenOptions::new()
831 .read(true)
832 .write(true)
833 .open(&staged)
834 .map_err(|e| anyhow!("reopening {} to seal into: {e}", staged.display()))?;
835
836 let mut paths: Vec<String> = Vec::with_capacity(packs.len());
837 let mut locs: Vec<ChunkLoc> = Vec::with_capacity(packs.len());
838 let mut packs_retired = 0u64;
839 let mut packs_after_copy = 0u64;
840 for (ordinal, &(offset, len)) in packs.iter().enumerate() {
841 if retired.contains(&offset) {
842 packs_retired += 1;
843 continue;
844 }
845 if offset.saturating_add(len) > blob_bytes {
846 // Acked after the copy began. A row for it would address bytes
847 // this archive does not carry.
848 packs_after_copy += 1;
849 continue;
850 }
851 paths.push(format!("objects.pack.{ordinal}"));
852 locs.push(ChunkLoc {
853 chunk_seq: 0,
854 fdata_offset: 0,
855 blob_offset: offset,
856 blob_size: len,
857 uncompressed_size: len,
858 compressed: false,
859 checksum: blake3_extent(&file, offset, len)?,
860 });
861 }
862 let packs_sealed = paths.len() as u64;
863
864 // (3) the metadata tail, past the blob region.
865 let file = Arc::new(file);
866 let mut sink = ArrowIpcSink::new(Arc::clone(&file), blob_bytes);
867 if !reserved.is_empty() {
868 sink = sink.with_reserved_builder(Box::new(move |_| Ok(reserved)));
869 }
870 if !paths.is_empty() {
871 let batch = base_batch_from_rows(&paths, &locs)?;
872 sink.push_subindex(
873 data_subindex_schema().as_ref(),
874 &[batch],
875 GroupKey {
876 pkg_type: 0,
877 repo: String::new(),
878 module_name: String::new(),
879 },
880 )?;
881 }
882 // `finish` fsyncs before it returns the total.
883 let sealed_total_bytes = Box::new(sink).finish()?;
884
885 Ok(SealReport {
886 archive: archive.to_path_buf(),
887 packs_sealed,
888 packs_retired,
889 packs_after_copy,
890 blob_bytes,
891 sealed_total_bytes,
892 })
893 })();
894
895 let sealed = match sealed {
896 Ok(s) => s,
897 Err(e) => {
898 let _ = std::fs::remove_file(&staged);
899 return Err(e);
900 }
901 };
902
903 // (4) the archive's own name, atomically, and only now.
904 std::fs::rename(&staged, archive).map_err(|e| {
905 let _ = std::fs::remove_file(&staged);
906 anyhow!(
907 "renaming {} into place as {}: {e}",
908 staged.display(),
909 archive.display()
910 )
911 })?;
912 sync_parent_dir(archive);
913 Ok(sealed)
914}
915
916/// blake3 over `(offset, len)` of `file`, streamed rather than buffered whole:
917/// a consolidated pack is tens of megabytes and there is no reason for the seal
918/// to hold one in memory to hash it.
919fn blake3_extent(file: &File, offset: u64, len: u64) -> Result<[u8; 32]> {
920 const WINDOW: usize = 1 << 20;
921 let mut hasher = znippy_common::blake3::Hasher::new();
922 let mut buf = vec![0u8; WINDOW.min(len.max(1) as usize)];
923 let mut done = 0u64;
924 while done < len {
925 let want = ((len - done) as usize).min(buf.len());
926 file.read_exact_at(&mut buf[..want], offset + done)
927 .map_err(|e| anyhow!("reading the pack at ({offset}, {len}) to checksum it: {e}"))?;
928 hasher.update(&buf[..want]);
929 done += want as u64;
930 }
931 Ok(*hasher.finalize().as_bytes())
932}
933
934/// A sibling name nothing else can be holding: pid and nanos, the same shape
935/// `gc.rs` stages its compaction under.
936fn staging_sibling(archive: &Path) -> PathBuf {
937 let unique = std::time::SystemTime::now()
938 .duration_since(std::time::UNIX_EPOCH)
939 .map(|d| d.as_nanos())
940 .unwrap_or(0);
941 let mut p = archive.as_os_str().to_owned();
942 p.push(format!(".seal-{}-{unique}", std::process::id()));
943 PathBuf::from(p)
944}
945
946/// fsync the directory so the rename is durable and not merely visible.
947/// Best-effort, like `gc::sync_dir`: a filesystem that will not open a directory
948/// is not a reason to fail a seal that otherwise succeeded.
949fn sync_parent_dir(path: &Path) {
950 if let Some(parent) = path.parent()
951 && let Ok(f) = File::open(parent)
952 {
953 let _ = f.sync_all();
954 }
955}