Skip to main content

gunnar_core/
history.rs

1//! The reference history, **read**.
2//!
3//! [`crate::arrow_log`] has always written this. Every compaction moves the
4//! reflog it takes out of the live log into `refs-archive/` as one complete,
5//! self-contained Arrow IPC stream, named so that a lexical sort is
6//! chronological. What it never had was a reader: `REFS_ARCHIVE_DIR`'s own doc
7//! comment said *"Never read at open"*, and until this module the only code in
8//! the whole workspace that opened a segment was `arrow_log`'s own
9//! `#[cfg(test)]` block. gunnar wrote a complete history of every reference
10//! transaction it had ever performed and nothing but its own tests could see
11//! it.
12//!
13//! That is what this module closes, and it closes it with **no new
14//! dependency**: the bytes are already Arrow, written by the arrow crates
15//! `gunnar-store` already links.
16//!
17//! # Two things, not one
18//!
19//! **Reading.** [`History::read`] returns every transaction, oldest first,
20//! across every archive segment and the live log. It takes **no lock**: an
21//! archive segment is immutable once renamed into place, and the live log is
22//! read through the same [`valid_prefix_len`] scan an open uses, so a torn tail
23//! from a writer that is still running is truncated rather than replayed. A
24//! history read is therefore safe against a *running* server, which is the only
25//! way it is ever useful.
26//!
27//! **Sealing.** [`History::seal`] writes the whole history as **one** Arrow IPC
28//! stream holding **one** batch, with the push boundary carried as a
29//! [`COL_PUSH_SEQ`] column instead of as a frame boundary.
30//!
31//! # Why the seal is a column and not a frame
32//!
33//! The live log's invariant is one `RecordBatch` per transaction, and on a
34//! live, fsynced, crash-recoverable log that is exactly right: repeating the
35//! schema per frame is what buys independent parseability when a writer can
36//! still be interrupted mid-frame.
37//!
38//! A sealed artifact is by definition past that point — it is fsynced, renamed
39//! into place, and never appended to — so the per-frame schema is paid for a
40//! property that can no longer be exercised. Carrying the push boundary as a
41//! column instead recovers it. This is what Iceberg does and gunnar did not: a
42//! snapshot boundary is a sequence-number column inside a manifest, never a
43//! file boundary.
44//!
45//! The saving is measured on gunnar's own bytes rather than modelled; see
46//! `sealing_costs_far_less_per_push_than_the_live_log_it_seals`: **99.7 B/push
47//! against the live log's 666.2, 6.68x**, 201 transactions, 2026-08-04.
48//!
49//! # The artifact is readable by things that are not gunnar
50//!
51//! The ref-log design claims the log is "queryable — DuckDB over the stream", and a claim like
52//! that cannot be settled by the code that wrote the bytes. Checked with a
53//! foreign implementation, **pyarrow 24.0.0**, against a seal taken from a live
54//! server after three real `git push` operations (2026-08-04):
55//!
56//! ```text
57//! >>> with pa.ipc.open_stream("hist.arrow") as r: t = r.read_all()
58//! schema: ['push_seq','refname','old_oid','new_oid','old_symref','new_symref','actor','at_unix_ms']
59//! batches: 1   rows: 3
60//!   push_seq=0 refs/heads/main  -       -> c415176  actor=gunnar
61//!   push_seq=1 refs/heads/main  c415176 -> b5d8140  actor=gunnar
62//!   push_seq=2 refs/heads/topic -       -> 9933705  actor=gunnar
63//! ```
64//!
65//! One batch, and the oids are the commits `git log` reports. That is the
66//! column-not-a-frame property and the round trip, checked by something with no
67//! stake in either.
68//!
69//! # What a snapshot row is, and why it is kept
70//!
71//! A compaction writes one snapshot batch at the head of the rewritten live
72//! log: every reference at its current value, actor [`COMPACTION_ACTOR`], with
73//! a null `old_oid` *and* a null `old_symref` for references that certainly had
74//! a previous value. That is derived state, not a transaction, and reading it
75//! as one would invent history that never happened.
76//!
77//! It is **kept** and **marked** ([`RefTransaction::is_snapshot`]) rather than
78//! dropped, for the reason `COMPACTION_ACTOR`'s own documentation gives: a
79//! reflog reader must be able to tell the two apart. Dropping it here would
80//! silently empty the history of a repository whose `refs-archive/` was lost or
81//! never copied, which is precisely the case where the snapshot is the only
82//! surviving evidence.
83
84use std::path::{Path, PathBuf};
85use std::sync::Arc;
86
87use arrow_array::builder::{FixedSizeBinaryBuilder, Int64Builder, StringBuilder, UInt64Builder};
88use arrow_array::{Array, FixedSizeBinaryArray, Int64Array, RecordBatch, StringArray, UInt64Array};
89use arrow_ipc::reader::StreamReader;
90use arrow_schema::{DataType, Field, Schema, SchemaRef};
91
92use crate::arrow_log::{
93    arrow_err, check_schema, column, encode_stream, ipc_guard, ref_schema, sync_parent_dir,
94    valid_prefix_len, write_options, COL_ACTOR, COL_AT, COL_NEW_OID, COL_NEW_SYMREF, COL_OLD_OID,
95    COL_OLD_SYMREF, COL_REFNAME, COMPACTION_ACTOR, REFS_ARCHIVE_DIR, REFS_LOG,
96};
97use gunnar_store::{Error, Result};
98
99/// The column a sealed history carries the push boundary in.
100///
101/// Dense and monotonic across the whole artifact, starting at 0 for the oldest
102/// transaction. A compaction's snapshot batch consumes one value like any other
103/// frame did, so `push_seq` is *the frame index of the log this was sealed
104/// from* and not a count of pushes a client made.
105pub const COL_PUSH_SEQ: &str = "push_seq";
106
107/// The conventional file name for a sealed history, beside the log it seals.
108pub const SEALED_HISTORY: &str = "refs-history.arrow";
109
110/// One row of the reference history: a single reference moving, once.
111#[derive(Clone, Debug, PartialEq, Eq)]
112pub struct RefTransaction {
113    /// Which frame of the source log this row came from. See [`COL_PUSH_SEQ`].
114    pub push_seq: u64,
115    /// The full reference name, `refs/heads/main` and not `main`.
116    pub refname: String,
117    /// The value before, absent for a creation **and** on a snapshot row.
118    pub old_oid: Option<gix_hash::ObjectId>,
119    /// The value after, absent for a deletion and for a symbolic target.
120    pub new_oid: Option<gix_hash::ObjectId>,
121    /// The symbolic value before, for a `HEAD`-shaped reference.
122    pub old_symref: Option<String>,
123    /// The symbolic value after.
124    pub new_symref: Option<String>,
125    /// Who did it. [`COMPACTION_ACTOR`] means this is a snapshot row.
126    pub actor: String,
127    /// When, in milliseconds since the epoch.
128    pub at_unix_ms: i64,
129}
130
131impl RefTransaction {
132    /// Whether this row is a compaction's snapshot of state rather than a
133    /// reference actually moving.
134    ///
135    /// The test is the one `COMPACTION_ACTOR` documents: the actor **and** a
136    /// null previous value, both, since a genuine creation by a client also has
137    /// a null previous value and a client could in principle be named
138    /// `gunnar:compaction`.
139    pub fn is_snapshot(&self) -> bool {
140        self.actor == COMPACTION_ACTOR && self.old_oid.is_none() && self.old_symref.is_none()
141    }
142
143    /// Whether this row deletes the reference: no value after, either kind.
144    pub fn is_deletion(&self) -> bool {
145        self.new_oid.is_none() && self.new_symref.is_none()
146    }
147}
148
149/// What one [`History::seal`] produced.
150#[derive(Clone, Copy, Debug, PartialEq, Eq)]
151pub struct SealReport {
152    /// Frames in the source log, i.e. the number of distinct `push_seq` values.
153    pub pushes: usize,
154    /// Rows written. One reference moving once is one row.
155    pub rows: usize,
156    /// Bytes of the sealed artifact.
157    pub bytes: u64,
158    /// Bytes of live log and archive segments the seal read.
159    pub bytes_in: u64,
160    /// Archive segments read.
161    pub segments_read: usize,
162}
163
164impl SealReport {
165    /// Sealed bytes per source frame — the number the column-not-a-frame
166    /// argument is about. `0.0` when there was nothing to seal.
167    pub fn bytes_per_push(&self) -> f64 {
168        if self.pushes == 0 {
169            return 0.0;
170        }
171        self.bytes as f64 / self.pushes as f64
172    }
173
174    /// How much smaller the seal is than the bytes it read. `0.0` when there
175    /// was nothing to seal, so a caller cannot mistake an empty run for a win.
176    pub fn shrink_x(&self) -> f64 {
177        if self.bytes == 0 {
178            return 0.0;
179        }
180        self.bytes_in as f64 / self.bytes as f64
181    }
182}
183
184/// Every reference transaction a repository's log still holds, oldest first.
185#[derive(Clone, Debug)]
186pub struct History {
187    hash_kind: gix_hash::Kind,
188    /// One entry per frame of the source log, in order.
189    batches: Vec<RecordBatch>,
190    segments_read: usize,
191    live_batches_read: usize,
192    bytes_in: u64,
193}
194
195impl History {
196    /// Read `store_dir`'s whole reference history: every archive segment,
197    /// oldest first, then the live log.
198    ///
199    /// `store_dir` is the `gunnar-store/` directory inside a repository, the
200    /// one holding `refs.arrow`. **No lock is taken**; see the module
201    /// documentation for why that is safe and why it is the point.
202    ///
203    /// Fails with [`Error::HashKindMismatch`] if the log belongs to a
204    /// repository of the other hash kind, through the same schema check an open
205    /// uses — reading a sha256 history as sha1 is a mistake, not a truncation.
206    pub fn read(store_dir: impl AsRef<Path>, hash_kind: gix_hash::Kind) -> Result<Self> {
207        Self::read_upto(store_dir, hash_kind, u64::MAX)
208    }
209
210    /// [`History::read`], but stopping the **live** log at `live_len` bytes.
211    ///
212    /// The archive segments are immutable and are always read whole; only the
213    /// live log is cut, and only at a byte offset a caller obtained from
214    /// [`live_mark`], which is always a complete-message boundary. So this
215    /// reconstructs the history exactly as it stood when that mark was taken,
216    /// which is what makes a watermark verifiable rather than merely recorded:
217    /// the state this replays to can be compared against what a seal wrote.
218    ///
219    /// `u64::MAX` is the whole log, and is how [`History::read`] is expressed
220    /// so that there is one reader and not two (one implementation).
221    pub fn read_upto(
222        store_dir: impl AsRef<Path>,
223        hash_kind: gix_hash::Kind,
224        live_len: u64,
225    ) -> Result<Self> {
226        let store_dir = store_dir.as_ref();
227        let expected = ref_schema(hash_kind);
228        let mut batches = Vec::new();
229        let mut bytes_in = 0u64;
230
231        let segments = archive_segments(&store_dir.join(REFS_ARCHIVE_DIR))?;
232        let segments_read = segments.len();
233        for segment in &segments {
234            bytes_in += read_stream(segment, Some(&expected), &mut batches)?;
235        }
236
237        let before_live = batches.len();
238        let live = store_dir.join(REFS_LOG);
239        if live.exists() {
240            let bytes = std::fs::read(&live)?;
241            // The bytes this read cost are the bytes it read off the platter,
242            // which is the whole file however far into it the replay goes.
243            bytes_in += bytes.len() as u64;
244            let valid = valid_prefix_len(&bytes).min(clamp_len(live_len));
245            decode_stream(&bytes[..valid], Some(&expected), &mut batches)?;
246        }
247
248        Ok(History {
249            hash_kind,
250            live_batches_read: batches.len() - before_live,
251            batches,
252            segments_read,
253            bytes_in,
254        })
255    }
256
257    /// The hash kind this history's oids are.
258    pub fn hash_kind(&self) -> gix_hash::Kind {
259        self.hash_kind
260    }
261
262    /// Frames read, across archive and live log. One push is one frame.
263    pub fn pushes(&self) -> usize {
264        self.batches.len()
265    }
266
267    /// Archive segments read. `0` means the archive is absent or empty, and
268    /// then [`History::pushes`] only covers the bounded live window.
269    pub fn segments_read(&self) -> usize {
270        self.segments_read
271    }
272
273    /// Frames that came from the live log rather than the archive.
274    pub fn live_pushes(&self) -> usize {
275        self.live_batches_read
276    }
277
278    /// Frames that came from the archive. This is the number that was
279    /// unreachable before this module existed.
280    pub fn archived_pushes(&self) -> usize {
281        self.batches.len() - self.live_batches_read
282    }
283
284    /// Bytes read to produce this history.
285    pub fn bytes_in(&self) -> u64 {
286        self.bytes_in
287    }
288
289    /// Every row, oldest first, with its `push_seq`.
290    pub fn transactions(&self) -> Result<Vec<RefTransaction>> {
291        let mut out = Vec::new();
292        for (push_seq, batch) in self.batches.iter().enumerate() {
293            decode_batch(batch, push_seq as u64, &mut out)?;
294        }
295        Ok(out)
296    }
297
298    /// The sealed artifact's bytes: one Arrow IPC stream, one batch,
299    /// `push_seq` as a column.
300    pub fn seal_to_vec(&self) -> Result<Vec<u8>> {
301        let schema = sealed_schema(self.hash_kind);
302        let batch = self.sealed_batch(&schema)?;
303        encode_stream(&schema, std::slice::from_ref(&batch), &write_options())
304    }
305
306    /// Seal to `path`, atomically: write a `.writing` scratch file beside it,
307    /// fsync, rename, fsync the directory.
308    ///
309    /// A crash anywhere before the rename leaves a `.writing` file and no
310    /// artifact, never a truncated one that a reader would silently stop at —
311    /// the same discipline [`crate::arrow_log`] uses for an archive segment.
312    pub fn seal(&self, path: impl AsRef<Path>) -> Result<SealReport> {
313        use std::io::Write as _;
314
315        let path = path.as_ref();
316        let bytes = self.seal_to_vec()?;
317        if let Some(parent) = path.parent() {
318            if !parent.as_os_str().is_empty() {
319                std::fs::create_dir_all(parent)?;
320            }
321        }
322        let name = path
323            .file_name()
324            .map(|n| n.to_string_lossy().into_owned())
325            .unwrap_or_else(|| SEALED_HISTORY.to_owned());
326        let temporary = path.with_file_name(format!("{name}.writing"));
327        {
328            let mut file = std::fs::OpenOptions::new()
329                .write(true)
330                .create(true)
331                .truncate(true)
332                .open(&temporary)?;
333            file.write_all(&bytes)?;
334            file.flush()?;
335            file.sync_all()?;
336        }
337        std::fs::rename(&temporary, path)?;
338        sync_parent_dir(path)?;
339
340        let rows = self.batches.iter().map(|b| b.num_rows()).sum();
341        Ok(SealReport {
342            pushes: self.batches.len(),
343            rows,
344            bytes: bytes.len() as u64,
345            bytes_in: self.bytes_in,
346            segments_read: self.segments_read,
347        })
348    }
349
350    fn sealed_batch(&self, schema: &SchemaRef) -> Result<RecordBatch> {
351        let width = self.hash_kind.len_in_bytes() as i32;
352        let mut push_seqs = UInt64Builder::new();
353        let mut names = StringBuilder::new();
354        let mut old_oids = FixedSizeBinaryBuilder::new(width);
355        let mut new_oids = FixedSizeBinaryBuilder::new(width);
356        let mut old_symrefs = StringBuilder::new();
357        let mut new_symrefs = StringBuilder::new();
358        let mut actors = StringBuilder::new();
359        let mut ats = Int64Builder::new();
360
361        for (push_seq, batch) in self.batches.iter().enumerate() {
362            let refnames = column::<StringArray>(batch, COL_REFNAME)?;
363            let olds = column::<FixedSizeBinaryArray>(batch, COL_OLD_OID)?;
364            let news = column::<FixedSizeBinaryArray>(batch, COL_NEW_OID)?;
365            let old_syms = column::<StringArray>(batch, COL_OLD_SYMREF)?;
366            let new_syms = column::<StringArray>(batch, COL_NEW_SYMREF)?;
367            let batch_actors = column::<StringArray>(batch, COL_ACTOR)?;
368            let batch_ats = column::<Int64Array>(batch, COL_AT)?;
369
370            for row in 0..batch.num_rows() {
371                push_seqs.append_value(push_seq as u64);
372                names.append_value(refnames.value(row));
373                append_optional_binary(&mut old_oids, olds, row)?;
374                append_optional_binary(&mut new_oids, news, row)?;
375                append_optional_str(&mut old_symrefs, old_syms, row);
376                append_optional_str(&mut new_symrefs, new_syms, row);
377                actors.append_value(batch_actors.value(row));
378                ats.append_value(batch_ats.value(row));
379            }
380        }
381
382        RecordBatch::try_new(
383            schema.clone(),
384            vec![
385                Arc::new(push_seqs.finish()),
386                Arc::new(names.finish()),
387                Arc::new(old_oids.finish()),
388                Arc::new(new_oids.finish()),
389                Arc::new(old_symrefs.finish()),
390                Arc::new(new_symrefs.finish()),
391                Arc::new(actors.finish()),
392                Arc::new(ats.finish()),
393            ],
394        )
395        .map_err(arrow_err)
396    }
397}
398
399/// Read a sealed history back: the artifact's own reader.
400///
401/// Present so that a seal is verifiable rather than merely written — a sealed
402/// file nothing can open is the bug this module exists to fix, one directory
403/// along (measured, not asserted).
404pub fn read_sealed(path: impl AsRef<Path>) -> Result<Vec<RefTransaction>> {
405    read_sealed_bytes(&std::fs::read(path.as_ref())?)
406}
407
408/// [`read_sealed`] over bytes already in hand.
409pub fn read_sealed_bytes(bytes: &[u8]) -> Result<Vec<RefTransaction>> {
410    let valid = valid_prefix_len(bytes);
411    if valid == 0 {
412        return Ok(Vec::new());
413    }
414    ipc_guard(&bytes[..valid])?;
415    let reader =
416        StreamReader::try_new(std::io::Cursor::new(&bytes[..valid]), None).map_err(arrow_err)?;
417    let batches: Vec<RecordBatch> = reader
418        .collect::<std::result::Result<Vec<_>, _>>()
419        .map_err(arrow_err)?;
420    let mut out = Vec::new();
421    for batch in &batches {
422        let push_seqs = column::<UInt64Array>(batch, COL_PUSH_SEQ)?;
423        let mut rows = Vec::new();
424        // The push_seq passed here is a placeholder: the sealed batch carries
425        // the real one per row, and it is read back below. Passing the
426        // placeholder through `decode_batch` rather than duplicating the row
427        // decode is deliberate (one implementation).
428        decode_batch(batch, 0, &mut rows)?;
429        for (row, mut transaction) in rows.into_iter().enumerate() {
430            transaction.push_seq = push_seqs.value(row);
431            out.push(transaction);
432        }
433    }
434    Ok(out)
435}
436
437/// The sealed artifact's schema: the live log's, plus [`COL_PUSH_SEQ`] in
438/// front.
439///
440/// `push_seq` leads because it is the sort key, and a reader that wants only
441/// the boundary column reads the narrowest possible projection.
442pub fn sealed_schema(hash_kind: gix_hash::Kind) -> SchemaRef {
443    let width = hash_kind.len_in_bytes() as i32;
444    Arc::new(Schema::new(vec![
445        Field::new(COL_PUSH_SEQ, DataType::UInt64, false),
446        Field::new(COL_REFNAME, DataType::Utf8, false),
447        Field::new(COL_OLD_OID, DataType::FixedSizeBinary(width), true),
448        Field::new(COL_NEW_OID, DataType::FixedSizeBinary(width), true),
449        Field::new(COL_OLD_SYMREF, DataType::Utf8, true),
450        Field::new(COL_NEW_SYMREF, DataType::Utf8, true),
451        Field::new(COL_ACTOR, DataType::Utf8, false),
452        Field::new(COL_AT, DataType::Int64, false),
453    ]))
454}
455
456/// Archive segments in `dir`, oldest first.
457///
458/// `.writing` files are **not** globbed: they are what a crash mid-archive
459/// leaves, and a reader that picked one up would be reading a stream whose
460/// writer never finished. The name format puts the millisecond first and the
461/// compaction sequence second, so a plain lexical sort is chronological.
462///
463/// Reversing this sort breaks multi-segment archive reads; a single compaction
464/// and a one-segment archive cannot catch that ordering seam.
465pub(crate) fn archive_segments(dir: &Path) -> Result<Vec<PathBuf>> {
466    let entries = match std::fs::read_dir(dir) {
467        Ok(entries) => entries,
468        // A repository that has never compacted has no archive directory, and
469        // that is not an error: its whole history is still live.
470        Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()),
471        Err(err) => return Err(err.into()),
472    };
473    let mut segments: Vec<PathBuf> = entries
474        .filter_map(|entry| entry.ok())
475        .map(|entry| entry.path())
476        .filter(|path| path.extension().map(|e| e == "arrow").unwrap_or(false))
477        .collect();
478    segments.sort();
479    Ok(segments)
480}
481
482/// Read one complete Arrow IPC stream, appending its batches.
483///
484/// The [`valid_prefix_len`] scan is the same one an open uses: a torn tail from
485/// a writer that is still running is truncated, not replayed, so this is safe
486/// against a live log. Returns the bytes read.
487pub(crate) fn read_stream(
488    path: &Path,
489    expected: Option<&SchemaRef>,
490    into: &mut Vec<RecordBatch>,
491) -> Result<u64> {
492    let bytes = std::fs::read(path)?;
493    let valid = valid_prefix_len(&bytes);
494    decode_stream(&bytes[..valid], expected, into)?;
495    Ok(bytes.len() as u64)
496}
497
498/// Decode an already-validated stream prefix. The one place a stream's batches
499/// are pulled out, so [`read_stream`] and [`History::read_upto`] cannot come to
500/// disagree about what a stream holds.
501fn decode_stream(
502    valid: &[u8],
503    expected: Option<&SchemaRef>,
504    into: &mut Vec<RecordBatch>,
505) -> Result<()> {
506    if valid.is_empty() {
507        return Ok(());
508    }
509    ipc_guard(valid)?;
510    let reader = StreamReader::try_new(std::io::Cursor::new(valid), None).map_err(arrow_err)?;
511    if let Some(expected) = expected {
512        check_schema(reader.schema().as_ref(), expected.as_ref())?;
513    }
514    for batch in reader {
515        into.push(batch.map_err(arrow_err)?);
516    }
517    Ok(())
518}
519
520fn clamp_len(len: u64) -> usize {
521    usize::try_from(len).unwrap_or(usize::MAX)
522}
523
524// ─────────────────────────── the live watermark ──────────────────────────────
525
526/// A position in the **live** reference log, plus enough identity to decide
527/// later whether that position still means the same bytes.
528///
529/// # Why a position needs identity at all
530///
531/// The live log is append-only *within one inode*, and that is what makes a
532/// byte offset a stable name for a prefix: everything below [`LiveMark::len`]
533/// is written, fsynced and never touched again, and [`valid_prefix_len`] only
534/// ever moves forward. A compaction breaks that — it stages a replacement and
535/// **renames it over** the live log, so the file at the same path is a
536/// different inode holding a snapshot plus a window, and byte 4096 of it is a
537/// different message entirely.
538///
539/// So the mark carries `(dev, ino)`. A compaction changes the inode and the
540/// mark stops applying, which is detected with one `stat` rather than by
541/// hashing the prefix on every read. `len` and `tail` close the one gap that
542/// leaves: an inode number freed by the compaction's unlink and immediately
543/// reissued to the replacement. Such a log would have to have grown back past
544/// `len` **and** carry the identical [`TAIL_WINDOW`] bytes ending there, which
545/// is not something a differently-framed stream does.
546///
547/// It deliberately does not hash the whole prefix. That would be O(history) on
548/// every open, which is the cost the archive exists to avoid; the bounded
549/// window is O(1). What it therefore does **not** prove is that a prefix was
550/// not rewritten in place below `len` by something that is not gunnar — nothing
551/// short of a full digest does, and nothing writes this file but the process
552/// holding its `O_EXCL` lock.
553#[derive(Clone, Copy, Debug, PartialEq, Eq)]
554pub struct LiveMark {
555    dev: u64,
556    ino: u64,
557    /// Length of the stream's leading schema message, which is byte-identical
558    /// for the life of one inode and is what lets a reader parse a *tail*.
559    schema_len: u64,
560    /// Complete-message prefix length when the mark was taken.
561    len: u64,
562    /// Digest of the last [`TAIL_WINDOW`] bytes at or before `len`.
563    tail: u64,
564}
565
566/// How much of the marked prefix the identity digest covers.
567const TAIL_WINDOW: usize = 8192;
568
569impl LiveMark {
570    /// The marked prefix length in bytes. Everything at or after this is what a
571    /// reader treats as "since the mark".
572    pub fn len(&self) -> u64 {
573        self.len
574    }
575
576    /// Whether the mark names an empty log — a repository whose references have
577    /// never been journalled.
578    pub fn is_empty(&self) -> bool {
579        self.len == 0
580    }
581
582    /// Bytes of one encoded mark.
583    pub const ENCODED_LEN: usize = 40;
584
585    /// The mark as bytes, so a caller can persist it without knowing what is in
586    /// it.
587    ///
588    /// The encoding lives here rather than at the call site because the fields
589    /// are this module's and the rule that they are all load-bearing is this
590    /// module's; a caller that spelled the layout itself would be a second
591    /// definition of the mark, free to drift by one field.
592    pub fn encode(&self) -> [u8; Self::ENCODED_LEN] {
593        let mut out = [0u8; Self::ENCODED_LEN];
594        out[0..8].copy_from_slice(&self.dev.to_le_bytes());
595        out[8..16].copy_from_slice(&self.ino.to_le_bytes());
596        out[16..24].copy_from_slice(&self.schema_len.to_le_bytes());
597        out[24..32].copy_from_slice(&self.len.to_le_bytes());
598        out[32..40].copy_from_slice(&self.tail.to_le_bytes());
599        out
600    }
601
602    /// The inverse of [`LiveMark::encode`]. `None` on any length but exactly
603    /// [`LiveMark::ENCODED_LEN`] — a short read is a truncated file, and a
604    /// truncated watermark that decoded to a *shorter prefix* would silently
605    /// widen the delta.
606    pub fn decode(bytes: &[u8]) -> Option<Self> {
607        if bytes.len() != Self::ENCODED_LEN {
608            return None;
609        }
610        let u = |at: usize| u64::from_le_bytes(bytes[at..at + 8].try_into().expect("8 bytes"));
611        Some(Self {
612            dev: u(0),
613            ino: u(8),
614            schema_len: u(16),
615            len: u(24),
616            tail: u(32),
617        })
618    }
619}
620
621/// Take a [`LiveMark`] on `store_dir`'s live reference log.
622///
623/// **No lock is taken**, the same as [`History::read`] and for the same reason:
624/// the prefix this names is written and fsynced, and a torn tail from a writer
625/// that is still running is excluded by [`valid_prefix_len`] rather than
626/// replayed.
627///
628/// A missing log marks as empty rather than failing: a repository that has
629/// never had a reference transaction is a normal state, and a mark on it is the
630/// honest "nothing has happened yet".
631pub fn live_mark(store_dir: impl AsRef<Path>) -> Result<LiveMark> {
632    let path = store_dir.as_ref().join(REFS_LOG);
633    let bytes = match std::fs::read(&path) {
634        Ok(bytes) => bytes,
635        Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
636            // The digest of an empty prefix, not a zero: a log that appears
637            // later must still verify against this mark by the same rule every
638            // other mark is verified by, and `0` is a value `window_digest`
639            // does not produce.
640            return Ok(LiveMark {
641                dev: 0,
642                ino: 0,
643                schema_len: 0,
644                len: 0,
645                tail: window_digest(&[]),
646            });
647        }
648        Err(err) => return Err(err.into()),
649    };
650    // The `stat` comes AFTER the read on purpose. An append between the two
651    // would only extend the file, and the mark names a prefix, so a later
652    // identity is still an identity for these bytes; a `stat` taken *first*
653    // could name an inode a compaction then replaced under the read.
654    let (dev, ino) = ids(&path)?;
655    let len = valid_prefix_len(&bytes);
656    let schema_len = crate::arrow_log::message_len(&bytes).unwrap_or(0);
657    Ok(LiveMark {
658        dev,
659        ino,
660        schema_len: schema_len as u64,
661        len: len as u64,
662        tail: window_digest(&bytes[..len]),
663    })
664}
665
666/// Every reference transaction the live log has recorded **since** `mark`.
667///
668/// `Ok(None)` — deliberately not an error and deliberately not an empty vector
669/// — when the mark no longer describes the log: the inode changed (a
670/// compaction), the log is shorter than the mark, or the marked prefix's tail
671/// digest disagrees. A caller that overlaid an empty delta in that case would
672/// serve the sealed namespace as though nothing had happened since, which is
673/// exactly the staleness this exists to remove. `None` means *fall back*, and
674/// there is no third answer that is a wrong one.
675///
676/// The read is O(the tail), and that is the *I/O* as well as the parse. Three
677/// positional reads — the leading schema message, the digest window ending at
678/// the mark, and the bytes after it — never the whole file. Reading the file and
679/// then slicing would have been simpler and would have made every advertisement
680/// pay for the whole history in page cache and memcpy, which is precisely the
681/// cost the archive exists to avoid: at 400 pushes `refs.arrow` is already
682/// 273 kB (`fs.rs`, measured 2026-08-04) against a tail of a few hundred bytes.
683///
684/// A tail alone is not a stream — every batch message is decoded against the
685/// schema message that opened the file — so the schema is spliced in front of
686/// it. `mark.len` is a complete-message boundary by construction, which is what
687/// makes both the splice and the tail's own [`valid_prefix_len`] scan legal from
688/// a standing start.
689pub fn read_live_since(
690    store_dir: impl AsRef<Path>,
691    hash_kind: gix_hash::Kind,
692    mark: &LiveMark,
693) -> Result<Option<Vec<RefTransaction>>> {
694    use std::os::unix::fs::FileExt as _;
695
696    let path = store_dir.as_ref().join(REFS_LOG);
697    let file = match std::fs::File::open(&path) {
698        Ok(file) => file,
699        Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
700            // A mark on a log that has since vanished applies to nothing. An
701            // empty mark on an absent log is the normal never-journalled state.
702            return Ok(mark.is_empty().then(Vec::new));
703        }
704        Err(err) => return Err(err.into()),
705    };
706    let (dev, ino, file_len) = {
707        use std::os::unix::fs::MetadataExt as _;
708        let meta = file.metadata()?;
709        (meta.dev(), meta.ino(), meta.size())
710    };
711
712    // Identity first, and with one `stat` rather than a digest: the log is
713    // append-only within an inode, so an unchanged inode means an unchanged
714    // prefix. A compaction renames a replacement over it and lands a new one.
715    if !mark.is_empty() && ((dev, ino) != (mark.dev, mark.ino) || file_len < mark.len) {
716        return Ok(None);
717    }
718    let from = clamp_len(mark.len);
719
720    // The bounded digest, which is what catches an inode number reissued to a
721    // replacement and a prefix rewritten in place. See [`LiveMark`] for what it
722    // does and does not prove.
723    let window_at = mark.len.saturating_sub(TAIL_WINDOW as u64);
724    let mut window = vec![0u8; (mark.len - window_at) as usize];
725    file.read_exact_at(&mut window, window_at)?;
726    if digest_of(&window, from) != mark.tail {
727        return Ok(None);
728    }
729
730    let mut tail = vec![0u8; (file_len - mark.len) as usize];
731    file.read_exact_at(&mut tail, mark.len)?;
732    // A torn tail from a writer that is still running is excluded rather than
733    // replayed, exactly as an open would exclude it.
734    let tail_valid = valid_prefix_len(&tail);
735    if tail_valid == 0 {
736        return Ok(Some(Vec::new()));
737    }
738
739    let schema_len = clamp_len(mark.schema_len);
740    let mut stream = Vec::with_capacity(schema_len + tail_valid);
741    if schema_len > 0 {
742        let mut schema = vec![0u8; schema_len];
743        file.read_exact_at(&mut schema, 0)?;
744        stream.extend_from_slice(&schema);
745    }
746    stream.extend_from_slice(&tail[..tail_valid]);
747
748    let mut batches = Vec::new();
749    decode_stream(&stream, Some(&ref_schema(hash_kind)), &mut batches)?;
750    let mut out = Vec::new();
751    for (push_seq, batch) in batches.iter().enumerate() {
752        decode_batch(batch, push_seq as u64, &mut out)?;
753    }
754    Ok(Some(out))
755}
756
757/// `(device, inode)` of `path`.
758fn ids(path: &Path) -> Result<(u64, u64)> {
759    use std::os::unix::fs::MetadataExt as _;
760    let meta = std::fs::metadata(path)?;
761    Ok((meta.dev(), meta.ino()))
762}
763
764/// A 64-bit digest of the last [`TAIL_WINDOW`] bytes of `prefix`, mixed with
765/// its length so that two different prefixes sharing a tail do not share a
766/// digest.
767///
768/// FNV-1a with a `splitmix64` finalizer, written out rather than pulled in: a
769/// hashing crate would be a third-party package on a graph the dependency guard
770/// counts in packages, for eleven lines of arithmetic.
771fn window_digest(prefix: &[u8]) -> u64 {
772    digest_of(
773        &prefix[prefix.len().saturating_sub(TAIL_WINDOW)..],
774        prefix.len(),
775    )
776}
777
778/// [`window_digest`] over a window a caller already read positionally, so the
779/// reader never has to hold the whole prefix to check it. One definition of the
780/// digest, two ways in (one implementation).
781fn digest_of(window: &[u8], prefix_len: usize) -> u64 {
782    let mut h: u64 = 0xcbf2_9ce4_8422_2325;
783    for &b in window {
784        h ^= b as u64;
785        h = h.wrapping_mul(0x0000_0100_0000_01b3);
786    }
787    h ^= prefix_len as u64;
788    h ^= h >> 30;
789    h = h.wrapping_mul(0xbf58_476d_1ce4_e5b9);
790    h ^= h >> 27;
791    h = h.wrapping_mul(0x94d0_49bb_1331_11eb);
792    h ^ (h >> 31)
793}
794
795pub(crate) fn decode_batch(
796    batch: &RecordBatch,
797    push_seq: u64,
798    into: &mut Vec<RefTransaction>,
799) -> Result<()> {
800    let refnames = column::<StringArray>(batch, COL_REFNAME)?;
801    let olds = column::<FixedSizeBinaryArray>(batch, COL_OLD_OID)?;
802    let news = column::<FixedSizeBinaryArray>(batch, COL_NEW_OID)?;
803    let old_syms = column::<StringArray>(batch, COL_OLD_SYMREF)?;
804    let new_syms = column::<StringArray>(batch, COL_NEW_SYMREF)?;
805    let actors = column::<StringArray>(batch, COL_ACTOR)?;
806    let ats = column::<Int64Array>(batch, COL_AT)?;
807
808    for row in 0..batch.num_rows() {
809        into.push(RefTransaction {
810            push_seq,
811            refname: refnames.value(row).to_owned(),
812            old_oid: optional_oid(olds, row)?,
813            new_oid: optional_oid(news, row)?,
814            old_symref: optional_string(old_syms, row),
815            new_symref: optional_string(new_syms, row),
816            actor: actors.value(row).to_owned(),
817            at_unix_ms: ats.value(row),
818        });
819    }
820    Ok(())
821}
822
823fn optional_oid(array: &FixedSizeBinaryArray, row: usize) -> Result<Option<gix_hash::ObjectId>> {
824    if array.is_null(row) {
825        return Ok(None);
826    }
827    gix_hash::ObjectId::try_from(array.value(row))
828        .map(Some)
829        .map_err(|err| {
830            Error::backend(format!(
831                "the reference history holds a malformed oid: {err}"
832            ))
833        })
834}
835
836fn optional_string(array: &StringArray, row: usize) -> Option<String> {
837    if array.is_null(row) {
838        None
839    } else {
840        Some(array.value(row).to_owned())
841    }
842}
843
844fn append_optional_binary(
845    builder: &mut FixedSizeBinaryBuilder,
846    array: &FixedSizeBinaryArray,
847    row: usize,
848) -> Result<()> {
849    if array.is_null(row) {
850        builder.append_null();
851    } else {
852        builder.append_value(array.value(row)).map_err(arrow_err)?;
853    }
854    Ok(())
855}
856
857fn append_optional_str(builder: &mut StringBuilder, array: &StringArray, row: usize) {
858    if array.is_null(row) {
859        builder.append_null();
860    } else {
861        builder.append_value(array.value(row));
862    }
863}