znippy-plugin-git 0.1.1

Git object-store metadata plugin for znippy (native builtin — no WASM). Carries the reserved oid / commit-graph / reachability sub-indexes.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
//! The append-only **push log**: the one mechanism `__gunnar_refs__` and
//! `__gunnar_secrets__` are both built from.
//!
//! ## Why there is no database here
//!
//! D18 removed redb: it was a database wedged between two archive formats, and
//! it bought nothing that the formats did not already provide. What a ref update
//! actually needs is *atomicity* — a push is visible whole or not at all — and
//! Arrow IPC already frames its messages. So the transaction boundary is the
//! **IPC frame boundary**, and the rule is one `RecordBatch` per push.
//!
//! ## The on-disk shape
//!
//! The live log is a concatenation of **self-contained** Arrow IPC streams, one
//! per push:
//!
//! ```text
//! [ schema | RecordBatch | EOS ]  ← push 1, fsynced
//! [ schema | RecordBatch | EOS ]  ← push 2, fsynced
//! [ schema | RecordBatch          ← push 3, TORN by a crash
//! ```
//!
//! Repeating the schema per frame is what buys the property: every frame is
//! independently parseable, so recovery needs no side-car, no length table and
//! no journal.
//!
//! **What that costs, measured** (refs schema, 4 refs per push, 2026-08-03):
//! a frame is 2056 bytes — 448 of schema (21.8%) and 1600 of batch (77.8%,
//! i.e. 400 bytes per ref). So the schema repetition is the *smaller* half of
//! the overhead; the larger half is Arrow's 64-byte buffer alignment paid six
//! times over on a four-row batch. Merging frames into one stream would
//! therefore recover only ~21.8%.
//!
//! The real win is compaction — folding many small batches into one large one,
//! which amortises the padding as well as the schema. `gunnar-store`'s
//! `arrow_log.rs` already does exactly this (`CompactionPolicy`, a staged
//! rewrite plus rename, and a reflog archive for the history it retires), and
//! its single-stream live log carries the schema once rather than per push.
//! That is the shape to adopt here rather than reinvent; see the note in the
//! commit that added this measurement. [`scan_frames`] reads forward while
//! frames parse and stops at the first one that does not — push 3 above is
//! discarded whole, pushes 1 and 2 survive intact. That is the crash story, and
//! it is a property of the framing rather than of any code that has to run.
//!
//! A partially-written frame can never be mistaken for a complete one: Arrow
//! prefixes every message with a continuation marker and a length, so a truncated
//! frame ends in the middle of a message body and the reader errors instead of
//! yielding a half-populated batch.
//!
//! ## Live log vs sealed archive
//!
//! znippy seals later. Until then the log is the file above. At seal, the
//! recovered batches are folded into **one** reserved Arrow section that keeps
//! one RecordBatch per push — so the push boundaries survive into the sealed
//! archive, and DuckDB / Polars / DataFusion read the section straight out of its
//! manifest byte range with no gunnar code.

use std::fs::{File, OpenOptions};
use std::io::{Cursor, Write};
use std::path::{Path, PathBuf};
use std::sync::Arc;

use anyhow::{Result, anyhow};
use znippy_common::arrow::datatypes::Schema;
use znippy_common::arrow::ipc::reader::StreamReader;
use znippy_common::arrow::ipc::writer::StreamWriter;
use znippy_common::arrow::record_batch::RecordBatch;
use znippy_common::{ReservedSection, read_reserved_section_bytes};

/// What a scan of a log recovered, and what it had to throw away.
#[derive(Debug, Default)]
pub struct PushLogScan {
    /// The complete frames, in push order. One entry per push.
    pub pushes: Vec<RecordBatch>,
    /// Bytes at the tail that did not form a complete frame — a push that was
    /// interrupted. Zero for a cleanly closed log.
    ///
    /// Deliberately reported rather than silently dropped: "the last push did
    /// not land" is information the caller may need, and a scan that quietly
    /// swallowed it would be indistinguishable from a clean log.
    pub torn_tail_bytes: u64,
}

impl PushLogScan {
    pub fn is_clean(&self) -> bool {
        self.torn_tail_bytes == 0
    }
}

/// Serialize one push as a self-contained Arrow IPC stream frame.
pub fn encode_frame(schema: &Arc<Schema>, batch: &RecordBatch) -> Result<Vec<u8>> {
    let mut buf = Vec::new();
    {
        let mut w = StreamWriter::try_new(&mut buf, schema)
            .map_err(|e| anyhow!("push log: opening a frame: {e}"))?;
        w.write(batch).map_err(|e| anyhow!("push log: writing a frame: {e}"))?;
        w.finish().map_err(|e| anyhow!("push log: closing a frame: {e}"))?;
    }
    Ok(buf)
}

/// Arrow's end-of-stream marker: the continuation marker `0xFFFFFFFF` followed
/// by a zero metadata length. Its presence at the end of a frame is what proves
/// the writer got all the way through `finish()`.
const IPC_EOS: [u8; 8] = [0xFF, 0xFF, 0xFF, 0xFF, 0x00, 0x00, 0x00, 0x00];

/// Read forward over concatenated frames, stopping at the first that does not
/// parse. Never errors on a torn tail — that is the expected state after a crash
/// and the whole reason the framing was chosen.
///
/// ## Why "did the reader error?" is not enough
///
/// Arrow's `StreamReader` treats an unexpected EOF as a clean end of stream, so
/// a frame truncated *inside* its record-batch message parses without error and
/// simply yields no batch. Trusting the reader's verdict alone therefore
/// accepted a torn push as a complete-but-empty frame and advanced past it —
/// the torn bytes were silently swallowed, `torn_tail_bytes` reported 0, and a
/// crashed push was indistinguishable from a clean log.
///
/// Two positive checks close that, and both must hold for a frame to count:
///
/// 1. it yielded **exactly one** `RecordBatch` — the format's invariant is one
///    push, one batch, so zero batches is a truncation and two is not our frame;
/// 2. its last eight bytes are [`IPC_EOS`] — proof the writer reached `finish()`
///    rather than the reader reaching the end of the file.
pub fn scan_frames(bytes: &[u8]) -> PushLogScan {
    let mut out = PushLogScan::default();
    let mut off = 0usize;

    while off < bytes.len() {
        let rest = &bytes[off..];
        let Ok(mut reader) = StreamReader::try_new(Cursor::new(rest), None) else {
            break;
        };
        let mut batches = Vec::new();
        let mut errored = false;
        loop {
            match reader.next() {
                Some(Ok(b)) => batches.push(b),
                Some(Err(_)) => {
                    errored = true;
                    break;
                }
                None => break,
            }
        }
        if errored {
            break;
        }
        // The reader is unbuffered over a `Cursor`, so its position is exactly
        // the bytes this frame consumed.
        let consumed = reader.get_ref().position() as usize;

        // A frame that consumed nothing would spin forever; one that did not end
        // in EOS, or did not carry exactly one batch, was torn mid-write.
        if consumed < IPC_EOS.len()
            || batches.len() != 1
            || rest[consumed - IPC_EOS.len()..consumed] != IPC_EOS
        {
            break;
        }

        out.pushes.append(&mut batches);
        off += consumed;
    }

    out.torn_tail_bytes = (bytes.len() - off) as u64;
    out
}

/// When a log should be compacted.
///
/// Compaction folds many small frames into one large one. The point is NOT the
/// schema — measured, that is only 21.8% of a frame — but Arrow's 64-byte
/// buffer alignment, which a four-row batch pays six times over and a
/// twenty-thousand-row batch pays once.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct CompactionPolicy {
    /// Compact once the log holds at least this many frames. Zero disables it.
    pub after_frames: usize,
}

impl CompactionPolicy {
    pub const fn never() -> Self {
        Self { after_frames: 0 }
    }

    pub fn should_compact(&self, frames: usize) -> bool {
        self.after_frames != 0 && frames >= self.after_frames
    }
}

impl Default for CompactionPolicy {
    fn default() -> Self {
        Self { after_frames: 1024 }
    }
}

/// What one compaction did.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CompactionReport {
    pub frames_before: usize,
    pub frames_after: usize,
    pub rows: usize,
    pub bytes_before: u64,
    pub bytes_after: u64,
}

/// The scratch name a compaction stages under, beside the live log so the
/// rename cannot cross a filesystem boundary and stop being atomic.
fn compacting_path(path: &Path) -> PathBuf {
    let name = path
        .file_name()
        .map(|n| n.to_string_lossy().into_owned())
        .unwrap_or_else(|| "log".to_owned());
    path.with_file_name(format!("{name}.compacting"))
}

/// A rename is only durable once the *directory entry* is on the platter.
fn sync_parent_dir(path: &Path) -> Result<()> {
    let parent = match path.parent() {
        Some(p) if !p.as_os_str().is_empty() => p.to_path_buf(),
        _ => PathBuf::from("."),
    };
    File::open(parent)?.sync_all()?;
    Ok(())
}

/// Where a compaction is allowed to stop. Only the crash test constructs
/// anything but [`Finish::Swap`].
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Finish {
    /// Stage the replacement, fsync it, and rename it into place.
    Swap,
    /// Stage and fsync, then stop. What a crash mid-compaction leaves on disk:
    /// a complete replacement that nothing points at yet.
    StopBeforeSwap,
}

/// An append-only log file. One [`append`](Self::append) call is one push.
pub struct PushLog {
    path: PathBuf,
    schema: Arc<Schema>,
}

impl PushLog {
    pub fn new(path: impl Into<PathBuf>, schema: Arc<Schema>) -> Self {
        Self { path: path.into(), schema }
    }

    pub fn path(&self) -> &Path {
        &self.path
    }

    pub fn schema(&self) -> &Arc<Schema> {
        &self.schema
    }

    /// Append one push and fsync it. Returns the byte offset the frame starts
    /// at.
    ///
    /// The frame is built **fully in memory first**, so the single `write_all`
    /// is the only thing the crash window covers; and the `sync_all` is what
    /// makes "the frame is complete on disk" — the durability claim this format
    /// rests on — actually true rather than merely likely.
    pub fn append(&self, batch: &RecordBatch) -> Result<u64> {
        if batch.schema() != self.schema {
            return Err(anyhow!(
                "push log {}: batch schema does not match the log schema",
                self.path.display()
            ));
        }
        let frame = encode_frame(&self.schema, batch)?;
        let mut f = OpenOptions::new()
            .create(true)
            .append(true)
            .open(&self.path)
            .map_err(|e| anyhow!("push log {}: {e}", self.path.display()))?;
        let offset = f.metadata()?.len();
        f.write_all(&frame)?;
        f.sync_all()?;
        Ok(offset)
    }

    /// Recover the log. A missing file is an empty, clean log — a repository
    /// that has never been pushed to is not an error.
    pub fn scan(&self) -> Result<PushLogScan> {
        match std::fs::read(&self.path) {
            Ok(b) => Ok(scan_frames(&b)),
            Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(PushLogScan::default()),
            Err(e) => Err(anyhow!("push log {}: {e}", self.path.display())),
        }
    }

    /// How many complete frames the log holds.
    pub fn frame_count(&self) -> Result<usize> {
        Ok(self.scan()?.pushes.len())
    }

    /// Fold every frame into one, atomically.
    ///
    /// ## What it costs and what it buys
    ///
    /// Rows are concatenated, never dropped: `push_seq` and `updated_ms` ride
    /// in the columns, so the full history survives and [`crate::refs::fold`]
    /// gives the identical answer before and after. What is given up is the
    /// *frame* boundary as a record of where one push ended — after compaction
    /// a frame is a run of pushes, and `push_seq` is the only thing that says
    /// where the seams were. That is why the ordering key was never the frame
    /// index.
    ///
    /// ## Compatibility — no migration, no version bump
    ///
    /// A compacted log is still exactly what an uncompacted one is: a sequence
    /// of self-contained frames, each carrying one batch and ending in EOS.
    /// There are simply fewer and larger ones. [`scan_frames`] already reads any
    /// number of them and its one-batch-per-frame invariant still holds, so a
    /// log written before this existed reads unchanged and a compacted log
    /// reads on any reader that could read the old one. This is deliberately
    /// **not** a versioned format change.
    ///
    /// ## Crash safety
    ///
    /// The same shape as `gunnar-store`'s `arrow_log.rs` — which owns the
    /// mature version of this problem — and deliberately so, since the code
    /// cannot be shared across the repository boundary: stage the whole
    /// replacement under [`compacting_path`], fsync it, rename it over the live
    /// log, then fsync the parent directory so the rename itself is durable.
    /// The live log is never written in place, so an interruption at *any* byte
    /// leaves the old log serving untouched; the worst outcome is a stray
    /// `.compacting` file that the next compaction overwrites.
    pub fn compact(&self) -> Result<CompactionReport> {
        self.compact_with(Finish::Swap)
    }

    /// [`compact`](Self::compact), stopping where `finish` says. The crash test
    /// is the only caller that passes anything but [`Finish::Swap`].
    pub fn compact_with(&self, finish: Finish) -> Result<CompactionReport> {
        let scan = self.scan()?;
        let frames_before = scan.pushes.len();
        let bytes_before = std::fs::metadata(&self.path).map(|m| m.len()).unwrap_or(0);

        if frames_before == 0 {
            return Ok(CompactionReport {
                frames_before,
                frames_after: frames_before,
                rows: 0,
                bytes_before,
                bytes_after: bytes_before,
            });
        }

        let merged = znippy_common::arrow::compute::concat_batches(&self.schema, scan.pushes.iter())
            .map_err(|e| anyhow!("compacting {}: {e}", self.path.display()))?;
        let rows = merged.num_rows();
        let bytes = encode_frame(&self.schema, &merged)?;

        // Stage the whole replacement first. Nothing points at it yet, so a
        // crash anywhere in here is invisible to a reader.
        let staged = compacting_path(&self.path);
        {
            let mut f = File::create(&staged)?;
            f.write_all(&bytes)?;
            f.sync_all()?;
        }
        if finish == Finish::StopBeforeSwap {
            return Ok(CompactionReport {
                frames_before,
                frames_after: 1,
                rows,
                bytes_before,
                bytes_after: bytes.len() as u64,
            });
        }

        std::fs::rename(&staged, &self.path)?;
        sync_parent_dir(&self.path)?;

        Ok(CompactionReport {
            frames_before,
            frames_after: 1,
            rows,
            bytes_before,
            bytes_after: bytes.len() as u64,
        })
    }

    /// Compact if `policy` says the log has grown enough. Returns `None` when
    /// it did not run.
    pub fn maybe_compact(&self, policy: CompactionPolicy) -> Result<Option<CompactionReport>> {
        if policy == CompactionPolicy::never() {
            return Ok(None);
        }
        let frames = self.frame_count()?;
        if !policy.should_compact(frames) {
            return Ok(None);
        }
        self.compact().map(Some)
    }

    /// Fold the recovered pushes into the reserved section to seal into an
    /// archive, preserving one RecordBatch per push.
    pub fn seal_section(&self, module_name: &str) -> Result<ReservedSection> {
        let scan = self.scan()?;
        Ok(ReservedSection::arrow(module_name, self.schema.clone(), scan.pushes))
    }
}

/// Read a sealed push-log section back out of an archive. `Ok(None)` when the
/// archive carries no such section — distinct from a section with no pushes.
pub fn read_sealed(archive: &Path, module_name: &str) -> Result<Option<Vec<RecordBatch>>> {
    let Some(bytes) = read_reserved_section_bytes(archive, module_name)? else {
        return Ok(None);
    };
    let reader = StreamReader::try_new(Cursor::new(&bytes[..]), None)
        .map_err(|e| anyhow!("{module_name}: {e}"))?;
    let mut out = Vec::new();
    for b in reader {
        out.push(b.map_err(|e| anyhow!("{module_name}: {e}"))?);
    }
    Ok(Some(out))
}

/// Truncate a log to `len` bytes — the crash simulator the tests need, and the
/// only supported way to produce a torn tail deliberately.
#[doc(hidden)]
pub fn truncate_for_test(path: &Path, len: u64) -> Result<()> {
    let f = File::options().write(true).open(path)?;
    f.set_len(len)?;
    Ok(())
}

#[cfg(test)]
mod tests {
    use super::*;
    use znippy_common::arrow::array::{StringArray, UInt64Array};
    use znippy_common::arrow::datatypes::{DataType, Field};

    fn schema() -> Arc<Schema> {
        Arc::new(Schema::new(vec![
            Field::new("name", DataType::Utf8, false),
            Field::new("seq", DataType::UInt64, false),
        ]))
    }

    fn batch(names: &[&str], seq: u64) -> RecordBatch {
        RecordBatch::try_new(
            schema(),
            vec![
                Arc::new(StringArray::from(names.to_vec())),
                Arc::new(UInt64Array::from(vec![seq; names.len()])),
            ],
        )
        .unwrap()
    }

    fn tmpdir(tag: &str) -> PathBuf {
        let ns = std::time::SystemTime::now()
            .duration_since(std::time::UNIX_EPOCH)
            .unwrap()
            .as_nanos();
        let d = std::env::temp_dir().join(format!("znippy_pushlog_{tag}_{ns}"));
        std::fs::create_dir_all(&d).unwrap();
        d
    }

    /// Each push is its own frame, and the boundaries survive the round trip —
    /// three pushes come back as three batches, not one merged batch. If the
    /// framing collapsed, `pushes.len()` would be 1 and the per-push transaction
    /// boundary this format rests on would not exist.
    #[test]
    fn one_push_is_one_frame_is_one_batch() {
        let dir = tmpdir("frames");
        let log = PushLog::new(dir.join("refs.log"), schema());
        log.append(&batch(&["a", "b"], 1)).unwrap();
        log.append(&batch(&["c"], 2)).unwrap();
        log.append(&batch(&["d", "e", "f"], 3)).unwrap();

        let scan = log.scan().unwrap();
        assert!(scan.is_clean(), "no crash happened, tail must be clean");
        assert_eq!(scan.pushes.len(), 3, "one RecordBatch per push");
        assert_eq!(scan.pushes[0].num_rows(), 2);
        assert_eq!(scan.pushes[1].num_rows(), 1);
        assert_eq!(scan.pushes[2].num_rows(), 3);

        std::fs::remove_dir_all(&dir).ok();
    }

    /// The crash test. A push interrupted part-way must lose **that push and
    /// only that push**; every earlier push stays readable.
    ///
    /// The truncation is swept across the whole final frame rather than taken at
    /// one convenient offset, because a recovery that works at one cut point and
    /// not another is not recovery. At every cut the two surviving pushes must
    /// come back byte-identical, and the third must never appear in any partial
    /// form.
    #[test]
    fn a_torn_final_frame_loses_only_that_push() {
        let dir = tmpdir("torn");
        let path = dir.join("refs.log");
        let log = PushLog::new(&path, schema());
        log.append(&batch(&["a", "b"], 1)).unwrap();
        log.append(&batch(&["c"], 2)).unwrap();
        let third_start = log.append(&batch(&["doomed"], 3)).unwrap();
        let full_len = std::fs::metadata(&path).unwrap().len();
        assert!(full_len > third_start, "third frame must have real bytes");

        let intact = std::fs::read(&path).unwrap();

        // Cut anywhere inside the third frame: 1 byte in, through 1 byte short.
        for cut in (third_start + 1)..full_len {
            std::fs::write(&path, &intact).unwrap();
            truncate_for_test(&path, cut).unwrap();

            let scan = log.scan().unwrap();
            assert_eq!(
                scan.pushes.len(),
                2,
                "cut at {cut}: the torn push must vanish whole, leaving exactly the two \
                 that completed (got {} batches)",
                scan.pushes.len()
            );
            assert_eq!(scan.pushes[0].num_rows(), 2, "cut at {cut}: push 1 damaged");
            assert_eq!(scan.pushes[1].num_rows(), 1, "cut at {cut}: push 2 damaged");
            assert_eq!(
                scan.torn_tail_bytes,
                cut - third_start,
                "cut at {cut}: the torn tail must be reported, not silently swallowed"
            );
            let names = scan.pushes[0]
                .column(0)
                .as_any()
                .downcast_ref::<StringArray>()
                .unwrap();
            assert_eq!(names.value(0), "a", "cut at {cut}: push 1 content damaged");
            assert_eq!(names.value(1), "b");
        }

        // Restoring the full bytes brings the third push back — proving the loss
        // was the truncation and not the scanner dropping a trailing frame.
        std::fs::write(&path, &intact).unwrap();
        let scan = log.scan().unwrap();
        assert_eq!(scan.pushes.len(), 3);
        assert!(scan.is_clean());

        std::fs::remove_dir_all(&dir).ok();
    }

    /// Garbage appended after a clean log is a torn tail, not a parse failure
    /// and not a panic: recovery must be total over arbitrary trailing bytes.
    #[test]
    fn trailing_garbage_is_a_torn_tail_not_an_error() {
        let dir = tmpdir("garbage");
        let path = dir.join("refs.log");
        let log = PushLog::new(&path, schema());
        log.append(&batch(&["a"], 1)).unwrap();

        for junk in [
            &b"\x00"[..],
            &b"\xff\xff\xff\xff"[..],
            &b"\xff\xff\xff\xff\x10\x00\x00\x00partial"[..],
            &[0xAB; 4096][..],
        ] {
            let mut bytes = std::fs::read(&path).unwrap();
            let clean_len = bytes.len();
            bytes.extend_from_slice(junk);
            let scan = scan_frames(&bytes);
            assert_eq!(scan.pushes.len(), 1, "the complete push must survive {junk:?}");
            assert_eq!(scan.torn_tail_bytes, (bytes.len() - clean_len) as u64);
        }

        std::fs::remove_dir_all(&dir).ok();
    }

    /// An empty / never-written log is clean and empty, not an error. A
    /// repository that has never been pushed to is a normal state.
    #[test]
    fn a_missing_log_is_empty_and_clean() {
        let dir = tmpdir("missing");
        let log = PushLog::new(dir.join("nope.log"), schema());
        let scan = log.scan().unwrap();
        assert!(scan.pushes.is_empty());
        assert!(scan.is_clean());
        std::fs::remove_dir_all(&dir).ok();
    }

    /// Compaction preserves every row and every push_seq, so the fold is
    /// identical before and after — and it must actually shrink the log, which
    /// is the only reason to run it.
    #[test]
    fn compaction_preserves_every_row_and_its_order() {
        let dir = tmpdir("compact");
        let path = dir.join("refs.log");
        let log = PushLog::new(&path, schema());
        for i in 0..200u64 {
            log.append(&batch(&["a", "b", "c", "d"], i)).unwrap();
        }

        let before = log.scan().unwrap();
        let before_rows: usize = before.pushes.iter().map(|b| b.num_rows()).sum();
        assert_eq!(before.pushes.len(), 200);

        let report = log.compact().unwrap();
        assert_eq!(report.frames_before, 200);
        assert_eq!(report.frames_after, 1, "everything must fold into one frame");
        assert_eq!(report.rows, before_rows, "compaction must not drop a row");

        let after = log.scan().unwrap();
        assert!(after.is_clean(), "a compacted log must scan clean");
        assert_eq!(after.pushes.len(), 1);
        assert_eq!(
            after.pushes[0].num_rows(),
            before_rows,
            "the merged batch must carry every row the frames did"
        );

        std::fs::remove_dir_all(&dir).ok();
    }

    /// The reason compaction exists, as its own claim so that a mutation which
    /// stops it saving space fails *here* rather than tripping a row-count
    /// assertion first. Alignment padding paid 200 times collapses to once.
    #[test]
    fn compaction_actually_shrinks_the_log() {
        let dir = tmpdir("compact_size");
        let log = PushLog::new(dir.join("refs.log"), schema());
        for i in 0..200u64 {
            log.append(&batch(&["a", "b", "c", "d"], i)).unwrap();
        }
        let report = log.compact().unwrap();
        assert!(
            report.bytes_after * 2 < report.bytes_before,
            "compaction saved almost nothing: {} -> {} bytes. It costs a rewrite and the \
             frame boundaries; if it does not pay for them it should not run.",
            report.bytes_before,
            report.bytes_after
        );
        std::fs::remove_dir_all(&dir).ok();
    }

    /// **The crash bar.** A compaction interrupted at ANY byte must leave the
    /// OLD log serving, whole.
    ///
    /// Mirrors `gunnar-store`'s `an_interrupted_compaction_leaves_the_old_log_
    /// serving`, and swept across every byte rather than stopped at one point,
    /// for the same reason the torn-push sweep is: a recovery that holds at one
    /// offset and not another is not a recovery. A compaction that can lose a
    /// ref is worse than the padding it saves.
    ///
    /// Seen red by making `compact_with` write the merged frame straight over
    /// the live log instead of staging and renaming — the sweep then finds cuts
    /// where the log holds a partial frame and the pushes are gone.
    #[test]
    fn an_interrupted_compaction_leaves_the_old_log_serving_at_every_cut() {
        let dir = tmpdir("compact_crash");
        let path = dir.join("refs.log");
        let log = PushLog::new(&path, schema());
        for i in 0..40u64 {
            log.append(&batch(&["x", "y"], i)).unwrap();
        }
        let intact = std::fs::read(&path).unwrap();
        let expected_rows: usize = log.scan().unwrap().pushes.iter().map(|b| b.num_rows()).sum();

        // Stage a compaction and stop before the swap — what a crash leaves.
        let report = log.compact_with(Finish::StopBeforeSwap).unwrap();
        let staged = compacting_path(&path);
        assert!(staged.exists(), "the staged replacement must exist to cut into");
        let staged_bytes = std::fs::read(&staged).unwrap();
        assert!(staged_bytes.len() > 32);

        for cut in 0..staged_bytes.len() {
            // A crash part-way through writing the replacement.
            std::fs::write(&staged, &staged_bytes[..cut]).unwrap();

            // The live log is untouched and still serves every push.
            assert_eq!(
                std::fs::read(&path).unwrap(),
                intact,
                "cut at {cut}: the LIVE log was modified by a compaction that never \
                 completed — it must never be written in place"
            );
            let scan = log.scan().unwrap();
            assert!(scan.is_clean(), "cut at {cut}: the live log stopped scanning clean");
            assert_eq!(
                scan.pushes.len(),
                40,
                "cut at {cut}: the live log lost pushes to an interrupted compaction"
            );
            let rows: usize = scan.pushes.iter().map(|b| b.num_rows()).sum();
            assert_eq!(rows, expected_rows, "cut at {cut}: rows went missing");
        }

        // And finishing the compaction for real still works afterwards.
        std::fs::remove_file(&staged).ok();
        let done = log.compact().unwrap();
        assert_eq!(done.frames_after, 1);
        assert_eq!(done.rows, expected_rows);
        assert_eq!(report.rows, expected_rows);

        std::fs::remove_dir_all(&dir).ok();
    }

    /// A log written before compaction existed still reads, and a compacted log
    /// reads on the same scanner. No migration, no version bump.
    #[test]
    fn compacted_and_uncompacted_logs_are_the_same_format() {
        let dir = tmpdir("compat");
        let path = dir.join("refs.log");
        let log = PushLog::new(&path, schema());
        for i in 0..8u64 {
            log.append(&batch(&["a"], i)).unwrap();
        }
        let uncompacted = log.scan().unwrap();
        log.compact().unwrap();
        let compacted = log.scan().unwrap();

        // Same rows, same order, read by the same scanner with no flag.
        let flat = |s: &PushLogScan| -> Vec<u64> {
            let mut v = Vec::new();
            for b in &s.pushes {
                let c = b.column(1).as_any().downcast_ref::<UInt64Array>().unwrap();
                v.extend((0..c.len()).map(|i| c.value(i)));
            }
            v
        };
        assert_eq!(flat(&uncompacted), flat(&compacted), "compaction changed the row sequence");

        // Appending after a compaction still works and stays readable.
        log.append(&batch(&["z"], 99)).unwrap();
        let after = log.scan().unwrap();
        assert!(after.is_clean());
        assert_eq!(after.pushes.len(), 2, "a compacted log must still accept appends");
        assert_eq!(*flat(&after).last().unwrap(), 99);

        std::fs::remove_dir_all(&dir).ok();
    }

    /// A batch whose schema is not the log's is refused at append. Writing it
    /// would produce a frame the scanner parses happily but whose columns no
    /// reader of this log expects.
    #[test]
    fn a_foreign_schema_is_refused_at_append() {
        let dir = tmpdir("schema");
        let log = PushLog::new(dir.join("refs.log"), schema());
        let other = Arc::new(Schema::new(vec![Field::new("x", DataType::Utf8, false)]));
        let foreign =
            RecordBatch::try_new(other, vec![Arc::new(StringArray::from(vec!["v"]))]).unwrap();
        let err = log.append(&foreign).unwrap_err().to_string();
        assert!(err.contains("schema"), "expected a schema error, got: {err}");
        std::fs::remove_dir_all(&dir).ok();
    }
}