znippy_plugin_git/pushlog.rs
1//! The append-only **push log**: the one mechanism `__gunnar_refs__` and
2//! `__gunnar_secrets__` are both built from.
3//!
4//! ## Why there is no database here
5//!
6//! D18 removed redb: it was a database wedged between two archive formats, and
7//! it bought nothing that the formats did not already provide. What a ref update
8//! actually needs is *atomicity* — a push is visible whole or not at all — and
9//! Arrow IPC already frames its messages. So the transaction boundary is the
10//! **IPC frame boundary**, and the rule is one `RecordBatch` per push.
11//!
12//! ## The on-disk shape
13//!
14//! The live log is a concatenation of **self-contained** Arrow IPC streams, one
15//! per push:
16//!
17//! ```text
18//! [ schema | RecordBatch | EOS ] ← push 1, fsynced
19//! [ schema | RecordBatch | EOS ] ← push 2, fsynced
20//! [ schema | RecordBatch ← push 3, TORN by a crash
21//! ```
22//!
23//! Repeating the schema per frame is what buys the property: every frame is
24//! independently parseable, so recovery needs no side-car, no length table and
25//! no journal.
26//!
27//! **What that costs, measured** (refs schema, 4 refs per push, 2026-08-03):
28//! a frame is 2056 bytes — 448 of schema (21.8%) and 1600 of batch (77.8%,
29//! i.e. 400 bytes per ref). So the schema repetition is the *smaller* half of
30//! the overhead; the larger half is Arrow's 64-byte buffer alignment paid six
31//! times over on a four-row batch. Merging frames into one stream would
32//! therefore recover only ~21.8%.
33//!
34//! The real win is compaction — folding many small batches into one large one,
35//! which amortises the padding as well as the schema. `gunnar-store`'s
36//! `arrow_log.rs` already does exactly this (`CompactionPolicy`, a staged
37//! rewrite plus rename, and a reflog archive for the history it retires), and
38//! its single-stream live log carries the schema once rather than per push.
39//! That is the shape to adopt here rather than reinvent; see the note in the
40//! commit that added this measurement. [`scan_frames`] reads forward while
41//! frames parse and stops at the first one that does not — push 3 above is
42//! discarded whole, pushes 1 and 2 survive intact. That is the crash story, and
43//! it is a property of the framing rather than of any code that has to run.
44//!
45//! A partially-written frame can never be mistaken for a complete one: Arrow
46//! prefixes every message with a continuation marker and a length, so a truncated
47//! frame ends in the middle of a message body and the reader errors instead of
48//! yielding a half-populated batch.
49//!
50//! ## Live log vs sealed archive
51//!
52//! znippy seals later. Until then the log is the file above. At seal, the
53//! recovered batches are folded into **one** reserved Arrow section that keeps
54//! one RecordBatch per push — so the push boundaries survive into the sealed
55//! archive, and DuckDB / Polars / DataFusion read the section straight out of its
56//! manifest byte range with no gunnar code.
57
58use std::fs::{File, OpenOptions};
59use std::io::{Cursor, Write};
60use std::path::{Path, PathBuf};
61use std::sync::Arc;
62
63use anyhow::{Result, anyhow};
64use znippy_common::arrow::datatypes::Schema;
65use znippy_common::arrow::ipc::reader::StreamReader;
66use znippy_common::arrow::ipc::writer::StreamWriter;
67use znippy_common::arrow::record_batch::RecordBatch;
68use znippy_common::{ReservedSection, read_reserved_section_bytes};
69
70/// What a scan of a log recovered, and what it had to throw away.
71#[derive(Debug, Default)]
72pub struct PushLogScan {
73 /// The complete frames, in push order. One entry per push.
74 pub pushes: Vec<RecordBatch>,
75 /// Bytes at the tail that did not form a complete frame — a push that was
76 /// interrupted. Zero for a cleanly closed log.
77 ///
78 /// Deliberately reported rather than silently dropped: "the last push did
79 /// not land" is information the caller may need, and a scan that quietly
80 /// swallowed it would be indistinguishable from a clean log.
81 pub torn_tail_bytes: u64,
82}
83
84impl PushLogScan {
85 pub fn is_clean(&self) -> bool {
86 self.torn_tail_bytes == 0
87 }
88}
89
90/// Serialize one push as a self-contained Arrow IPC stream frame.
91pub fn encode_frame(schema: &Arc<Schema>, batch: &RecordBatch) -> Result<Vec<u8>> {
92 let mut buf = Vec::new();
93 {
94 let mut w = StreamWriter::try_new(&mut buf, schema)
95 .map_err(|e| anyhow!("push log: opening a frame: {e}"))?;
96 w.write(batch).map_err(|e| anyhow!("push log: writing a frame: {e}"))?;
97 w.finish().map_err(|e| anyhow!("push log: closing a frame: {e}"))?;
98 }
99 Ok(buf)
100}
101
102/// Arrow's end-of-stream marker: the continuation marker `0xFFFFFFFF` followed
103/// by a zero metadata length. Its presence at the end of a frame is what proves
104/// the writer got all the way through `finish()`.
105const IPC_EOS: [u8; 8] = [0xFF, 0xFF, 0xFF, 0xFF, 0x00, 0x00, 0x00, 0x00];
106
107/// Read forward over concatenated frames, stopping at the first that does not
108/// parse. Never errors on a torn tail — that is the expected state after a crash
109/// and the whole reason the framing was chosen.
110///
111/// ## Why "did the reader error?" is not enough
112///
113/// Arrow's `StreamReader` treats an unexpected EOF as a clean end of stream, so
114/// a frame truncated *inside* its record-batch message parses without error and
115/// simply yields no batch. Trusting the reader's verdict alone therefore
116/// accepted a torn push as a complete-but-empty frame and advanced past it —
117/// the torn bytes were silently swallowed, `torn_tail_bytes` reported 0, and a
118/// crashed push was indistinguishable from a clean log.
119///
120/// Two positive checks close that, and both must hold for a frame to count:
121///
122/// 1. it yielded **exactly one** `RecordBatch` — the format's invariant is one
123/// push, one batch, so zero batches is a truncation and two is not our frame;
124/// 2. its last eight bytes are [`IPC_EOS`] — proof the writer reached `finish()`
125/// rather than the reader reaching the end of the file.
126pub fn scan_frames(bytes: &[u8]) -> PushLogScan {
127 let mut out = PushLogScan::default();
128 let mut off = 0usize;
129
130 while off < bytes.len() {
131 let rest = &bytes[off..];
132 let Ok(mut reader) = StreamReader::try_new(Cursor::new(rest), None) else {
133 break;
134 };
135 let mut batches = Vec::new();
136 let mut errored = false;
137 loop {
138 match reader.next() {
139 Some(Ok(b)) => batches.push(b),
140 Some(Err(_)) => {
141 errored = true;
142 break;
143 }
144 None => break,
145 }
146 }
147 if errored {
148 break;
149 }
150 // The reader is unbuffered over a `Cursor`, so its position is exactly
151 // the bytes this frame consumed.
152 let consumed = reader.get_ref().position() as usize;
153
154 // A frame that consumed nothing would spin forever; one that did not end
155 // in EOS, or did not carry exactly one batch, was torn mid-write.
156 if consumed < IPC_EOS.len()
157 || batches.len() != 1
158 || rest[consumed - IPC_EOS.len()..consumed] != IPC_EOS
159 {
160 break;
161 }
162
163 out.pushes.append(&mut batches);
164 off += consumed;
165 }
166
167 out.torn_tail_bytes = (bytes.len() - off) as u64;
168 out
169}
170
171/// When a log should be compacted.
172///
173/// Compaction folds many small frames into one large one. The point is NOT the
174/// schema — measured, that is only 21.8% of a frame — but Arrow's 64-byte
175/// buffer alignment, which a four-row batch pays six times over and a
176/// twenty-thousand-row batch pays once.
177#[derive(Debug, Clone, Copy, PartialEq, Eq)]
178pub struct CompactionPolicy {
179 /// Compact once the log holds at least this many frames. Zero disables it.
180 pub after_frames: usize,
181}
182
183impl CompactionPolicy {
184 pub const fn never() -> Self {
185 Self { after_frames: 0 }
186 }
187
188 pub fn should_compact(&self, frames: usize) -> bool {
189 self.after_frames != 0 && frames >= self.after_frames
190 }
191}
192
193impl Default for CompactionPolicy {
194 fn default() -> Self {
195 Self { after_frames: 1024 }
196 }
197}
198
199/// What one compaction did.
200#[derive(Debug, Clone, PartialEq, Eq)]
201pub struct CompactionReport {
202 pub frames_before: usize,
203 pub frames_after: usize,
204 pub rows: usize,
205 pub bytes_before: u64,
206 pub bytes_after: u64,
207}
208
209/// The scratch name a compaction stages under, beside the live log so the
210/// rename cannot cross a filesystem boundary and stop being atomic.
211fn compacting_path(path: &Path) -> PathBuf {
212 let name = path
213 .file_name()
214 .map(|n| n.to_string_lossy().into_owned())
215 .unwrap_or_else(|| "log".to_owned());
216 path.with_file_name(format!("{name}.compacting"))
217}
218
219/// A rename is only durable once the *directory entry* is on the platter.
220fn sync_parent_dir(path: &Path) -> Result<()> {
221 let parent = match path.parent() {
222 Some(p) if !p.as_os_str().is_empty() => p.to_path_buf(),
223 _ => PathBuf::from("."),
224 };
225 File::open(parent)?.sync_all()?;
226 Ok(())
227}
228
229/// Where a compaction is allowed to stop. Only the crash test constructs
230/// anything but [`Finish::Swap`].
231#[derive(Debug, Clone, Copy, PartialEq, Eq)]
232pub enum Finish {
233 /// Stage the replacement, fsync it, and rename it into place.
234 Swap,
235 /// Stage and fsync, then stop. What a crash mid-compaction leaves on disk:
236 /// a complete replacement that nothing points at yet.
237 StopBeforeSwap,
238}
239
240/// An append-only log file. One [`append`](Self::append) call is one push.
241pub struct PushLog {
242 path: PathBuf,
243 schema: Arc<Schema>,
244}
245
246impl PushLog {
247 pub fn new(path: impl Into<PathBuf>, schema: Arc<Schema>) -> Self {
248 Self { path: path.into(), schema }
249 }
250
251 pub fn path(&self) -> &Path {
252 &self.path
253 }
254
255 pub fn schema(&self) -> &Arc<Schema> {
256 &self.schema
257 }
258
259 /// Append one push and fsync it. Returns the byte offset the frame starts
260 /// at.
261 ///
262 /// The frame is built **fully in memory first**, so the single `write_all`
263 /// is the only thing the crash window covers; and the `sync_all` is what
264 /// makes "the frame is complete on disk" — the durability claim this format
265 /// rests on — actually true rather than merely likely.
266 pub fn append(&self, batch: &RecordBatch) -> Result<u64> {
267 if batch.schema() != self.schema {
268 return Err(anyhow!(
269 "push log {}: batch schema does not match the log schema",
270 self.path.display()
271 ));
272 }
273 let frame = encode_frame(&self.schema, batch)?;
274 let mut f = OpenOptions::new()
275 .create(true)
276 .append(true)
277 .open(&self.path)
278 .map_err(|e| anyhow!("push log {}: {e}", self.path.display()))?;
279 let offset = f.metadata()?.len();
280 f.write_all(&frame)?;
281 f.sync_all()?;
282 Ok(offset)
283 }
284
285 /// Recover the log. A missing file is an empty, clean log — a repository
286 /// that has never been pushed to is not an error.
287 pub fn scan(&self) -> Result<PushLogScan> {
288 match std::fs::read(&self.path) {
289 Ok(b) => Ok(scan_frames(&b)),
290 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(PushLogScan::default()),
291 Err(e) => Err(anyhow!("push log {}: {e}", self.path.display())),
292 }
293 }
294
295 /// How many complete frames the log holds.
296 pub fn frame_count(&self) -> Result<usize> {
297 Ok(self.scan()?.pushes.len())
298 }
299
300 /// Fold every frame into one, atomically.
301 ///
302 /// ## What it costs and what it buys
303 ///
304 /// Rows are concatenated, never dropped: `push_seq` and `updated_ms` ride
305 /// in the columns, so the full history survives and [`crate::refs::fold`]
306 /// gives the identical answer before and after. What is given up is the
307 /// *frame* boundary as a record of where one push ended — after compaction
308 /// a frame is a run of pushes, and `push_seq` is the only thing that says
309 /// where the seams were. That is why the ordering key was never the frame
310 /// index.
311 ///
312 /// ## Compatibility — no migration, no version bump
313 ///
314 /// A compacted log is still exactly what an uncompacted one is: a sequence
315 /// of self-contained frames, each carrying one batch and ending in EOS.
316 /// There are simply fewer and larger ones. [`scan_frames`] already reads any
317 /// number of them and its one-batch-per-frame invariant still holds, so a
318 /// log written before this existed reads unchanged and a compacted log
319 /// reads on any reader that could read the old one. This is deliberately
320 /// **not** a versioned format change.
321 ///
322 /// ## Crash safety
323 ///
324 /// The same shape as `gunnar-store`'s `arrow_log.rs` — which owns the
325 /// mature version of this problem — and deliberately so, since the code
326 /// cannot be shared across the repository boundary: stage the whole
327 /// replacement under [`compacting_path`], fsync it, rename it over the live
328 /// log, then fsync the parent directory so the rename itself is durable.
329 /// The live log is never written in place, so an interruption at *any* byte
330 /// leaves the old log serving untouched; the worst outcome is a stray
331 /// `.compacting` file that the next compaction overwrites.
332 pub fn compact(&self) -> Result<CompactionReport> {
333 self.compact_with(Finish::Swap)
334 }
335
336 /// [`compact`](Self::compact), stopping where `finish` says. The crash test
337 /// is the only caller that passes anything but [`Finish::Swap`].
338 pub fn compact_with(&self, finish: Finish) -> Result<CompactionReport> {
339 let scan = self.scan()?;
340 let frames_before = scan.pushes.len();
341 let bytes_before = std::fs::metadata(&self.path).map(|m| m.len()).unwrap_or(0);
342
343 if frames_before == 0 {
344 return Ok(CompactionReport {
345 frames_before,
346 frames_after: frames_before,
347 rows: 0,
348 bytes_before,
349 bytes_after: bytes_before,
350 });
351 }
352
353 let merged = znippy_common::arrow::compute::concat_batches(&self.schema, scan.pushes.iter())
354 .map_err(|e| anyhow!("compacting {}: {e}", self.path.display()))?;
355 let rows = merged.num_rows();
356 let bytes = encode_frame(&self.schema, &merged)?;
357
358 // Stage the whole replacement first. Nothing points at it yet, so a
359 // crash anywhere in here is invisible to a reader.
360 let staged = compacting_path(&self.path);
361 {
362 let mut f = File::create(&staged)?;
363 f.write_all(&bytes)?;
364 f.sync_all()?;
365 }
366 if finish == Finish::StopBeforeSwap {
367 return Ok(CompactionReport {
368 frames_before,
369 frames_after: 1,
370 rows,
371 bytes_before,
372 bytes_after: bytes.len() as u64,
373 });
374 }
375
376 std::fs::rename(&staged, &self.path)?;
377 sync_parent_dir(&self.path)?;
378
379 Ok(CompactionReport {
380 frames_before,
381 frames_after: 1,
382 rows,
383 bytes_before,
384 bytes_after: bytes.len() as u64,
385 })
386 }
387
388 /// Compact if `policy` says the log has grown enough. Returns `None` when
389 /// it did not run.
390 pub fn maybe_compact(&self, policy: CompactionPolicy) -> Result<Option<CompactionReport>> {
391 if policy == CompactionPolicy::never() {
392 return Ok(None);
393 }
394 let frames = self.frame_count()?;
395 if !policy.should_compact(frames) {
396 return Ok(None);
397 }
398 self.compact().map(Some)
399 }
400
401 /// Fold the recovered pushes into the reserved section to seal into an
402 /// archive, preserving one RecordBatch per push.
403 pub fn seal_section(&self, module_name: &str) -> Result<ReservedSection> {
404 let scan = self.scan()?;
405 Ok(ReservedSection::arrow(module_name, self.schema.clone(), scan.pushes))
406 }
407}
408
409/// Read a sealed push-log section back out of an archive. `Ok(None)` when the
410/// archive carries no such section — distinct from a section with no pushes.
411pub fn read_sealed(archive: &Path, module_name: &str) -> Result<Option<Vec<RecordBatch>>> {
412 let Some(bytes) = read_reserved_section_bytes(archive, module_name)? else {
413 return Ok(None);
414 };
415 let reader = StreamReader::try_new(Cursor::new(&bytes[..]), None)
416 .map_err(|e| anyhow!("{module_name}: {e}"))?;
417 let mut out = Vec::new();
418 for b in reader {
419 out.push(b.map_err(|e| anyhow!("{module_name}: {e}"))?);
420 }
421 Ok(Some(out))
422}
423
424/// Truncate a log to `len` bytes — the crash simulator the tests need, and the
425/// only supported way to produce a torn tail deliberately.
426#[doc(hidden)]
427pub fn truncate_for_test(path: &Path, len: u64) -> Result<()> {
428 let f = File::options().write(true).open(path)?;
429 f.set_len(len)?;
430 Ok(())
431}
432
433#[cfg(test)]
434mod tests {
435 use super::*;
436 use znippy_common::arrow::array::{StringArray, UInt64Array};
437 use znippy_common::arrow::datatypes::{DataType, Field};
438
439 fn schema() -> Arc<Schema> {
440 Arc::new(Schema::new(vec![
441 Field::new("name", DataType::Utf8, false),
442 Field::new("seq", DataType::UInt64, false),
443 ]))
444 }
445
446 fn batch(names: &[&str], seq: u64) -> RecordBatch {
447 RecordBatch::try_new(
448 schema(),
449 vec![
450 Arc::new(StringArray::from(names.to_vec())),
451 Arc::new(UInt64Array::from(vec![seq; names.len()])),
452 ],
453 )
454 .unwrap()
455 }
456
457 fn tmpdir(tag: &str) -> PathBuf {
458 let ns = std::time::SystemTime::now()
459 .duration_since(std::time::UNIX_EPOCH)
460 .unwrap()
461 .as_nanos();
462 let d = std::env::temp_dir().join(format!("znippy_pushlog_{tag}_{ns}"));
463 std::fs::create_dir_all(&d).unwrap();
464 d
465 }
466
467 /// Each push is its own frame, and the boundaries survive the round trip —
468 /// three pushes come back as three batches, not one merged batch. If the
469 /// framing collapsed, `pushes.len()` would be 1 and the per-push transaction
470 /// boundary this format rests on would not exist.
471 #[test]
472 fn one_push_is_one_frame_is_one_batch() {
473 let dir = tmpdir("frames");
474 let log = PushLog::new(dir.join("refs.log"), schema());
475 log.append(&batch(&["a", "b"], 1)).unwrap();
476 log.append(&batch(&["c"], 2)).unwrap();
477 log.append(&batch(&["d", "e", "f"], 3)).unwrap();
478
479 let scan = log.scan().unwrap();
480 assert!(scan.is_clean(), "no crash happened, tail must be clean");
481 assert_eq!(scan.pushes.len(), 3, "one RecordBatch per push");
482 assert_eq!(scan.pushes[0].num_rows(), 2);
483 assert_eq!(scan.pushes[1].num_rows(), 1);
484 assert_eq!(scan.pushes[2].num_rows(), 3);
485
486 std::fs::remove_dir_all(&dir).ok();
487 }
488
489 /// The crash test. A push interrupted part-way must lose **that push and
490 /// only that push**; every earlier push stays readable.
491 ///
492 /// The truncation is swept across the whole final frame rather than taken at
493 /// one convenient offset, because a recovery that works at one cut point and
494 /// not another is not recovery. At every cut the two surviving pushes must
495 /// come back byte-identical, and the third must never appear in any partial
496 /// form.
497 #[test]
498 fn a_torn_final_frame_loses_only_that_push() {
499 let dir = tmpdir("torn");
500 let path = dir.join("refs.log");
501 let log = PushLog::new(&path, schema());
502 log.append(&batch(&["a", "b"], 1)).unwrap();
503 log.append(&batch(&["c"], 2)).unwrap();
504 let third_start = log.append(&batch(&["doomed"], 3)).unwrap();
505 let full_len = std::fs::metadata(&path).unwrap().len();
506 assert!(full_len > third_start, "third frame must have real bytes");
507
508 let intact = std::fs::read(&path).unwrap();
509
510 // Cut anywhere inside the third frame: 1 byte in, through 1 byte short.
511 for cut in (third_start + 1)..full_len {
512 std::fs::write(&path, &intact).unwrap();
513 truncate_for_test(&path, cut).unwrap();
514
515 let scan = log.scan().unwrap();
516 assert_eq!(
517 scan.pushes.len(),
518 2,
519 "cut at {cut}: the torn push must vanish whole, leaving exactly the two \
520 that completed (got {} batches)",
521 scan.pushes.len()
522 );
523 assert_eq!(scan.pushes[0].num_rows(), 2, "cut at {cut}: push 1 damaged");
524 assert_eq!(scan.pushes[1].num_rows(), 1, "cut at {cut}: push 2 damaged");
525 assert_eq!(
526 scan.torn_tail_bytes,
527 cut - third_start,
528 "cut at {cut}: the torn tail must be reported, not silently swallowed"
529 );
530 let names = scan.pushes[0]
531 .column(0)
532 .as_any()
533 .downcast_ref::<StringArray>()
534 .unwrap();
535 assert_eq!(names.value(0), "a", "cut at {cut}: push 1 content damaged");
536 assert_eq!(names.value(1), "b");
537 }
538
539 // Restoring the full bytes brings the third push back — proving the loss
540 // was the truncation and not the scanner dropping a trailing frame.
541 std::fs::write(&path, &intact).unwrap();
542 let scan = log.scan().unwrap();
543 assert_eq!(scan.pushes.len(), 3);
544 assert!(scan.is_clean());
545
546 std::fs::remove_dir_all(&dir).ok();
547 }
548
549 /// Garbage appended after a clean log is a torn tail, not a parse failure
550 /// and not a panic: recovery must be total over arbitrary trailing bytes.
551 #[test]
552 fn trailing_garbage_is_a_torn_tail_not_an_error() {
553 let dir = tmpdir("garbage");
554 let path = dir.join("refs.log");
555 let log = PushLog::new(&path, schema());
556 log.append(&batch(&["a"], 1)).unwrap();
557
558 for junk in [
559 &b"\x00"[..],
560 &b"\xff\xff\xff\xff"[..],
561 &b"\xff\xff\xff\xff\x10\x00\x00\x00partial"[..],
562 &[0xAB; 4096][..],
563 ] {
564 let mut bytes = std::fs::read(&path).unwrap();
565 let clean_len = bytes.len();
566 bytes.extend_from_slice(junk);
567 let scan = scan_frames(&bytes);
568 assert_eq!(scan.pushes.len(), 1, "the complete push must survive {junk:?}");
569 assert_eq!(scan.torn_tail_bytes, (bytes.len() - clean_len) as u64);
570 }
571
572 std::fs::remove_dir_all(&dir).ok();
573 }
574
575 /// An empty / never-written log is clean and empty, not an error. A
576 /// repository that has never been pushed to is a normal state.
577 #[test]
578 fn a_missing_log_is_empty_and_clean() {
579 let dir = tmpdir("missing");
580 let log = PushLog::new(dir.join("nope.log"), schema());
581 let scan = log.scan().unwrap();
582 assert!(scan.pushes.is_empty());
583 assert!(scan.is_clean());
584 std::fs::remove_dir_all(&dir).ok();
585 }
586
587 /// Compaction preserves every row and every push_seq, so the fold is
588 /// identical before and after — and it must actually shrink the log, which
589 /// is the only reason to run it.
590 #[test]
591 fn compaction_preserves_every_row_and_its_order() {
592 let dir = tmpdir("compact");
593 let path = dir.join("refs.log");
594 let log = PushLog::new(&path, schema());
595 for i in 0..200u64 {
596 log.append(&batch(&["a", "b", "c", "d"], i)).unwrap();
597 }
598
599 let before = log.scan().unwrap();
600 let before_rows: usize = before.pushes.iter().map(|b| b.num_rows()).sum();
601 assert_eq!(before.pushes.len(), 200);
602
603 let report = log.compact().unwrap();
604 assert_eq!(report.frames_before, 200);
605 assert_eq!(report.frames_after, 1, "everything must fold into one frame");
606 assert_eq!(report.rows, before_rows, "compaction must not drop a row");
607
608 let after = log.scan().unwrap();
609 assert!(after.is_clean(), "a compacted log must scan clean");
610 assert_eq!(after.pushes.len(), 1);
611 assert_eq!(
612 after.pushes[0].num_rows(),
613 before_rows,
614 "the merged batch must carry every row the frames did"
615 );
616
617 std::fs::remove_dir_all(&dir).ok();
618 }
619
620 /// The reason compaction exists, as its own claim so that a mutation which
621 /// stops it saving space fails *here* rather than tripping a row-count
622 /// assertion first. Alignment padding paid 200 times collapses to once.
623 #[test]
624 fn compaction_actually_shrinks_the_log() {
625 let dir = tmpdir("compact_size");
626 let log = PushLog::new(dir.join("refs.log"), schema());
627 for i in 0..200u64 {
628 log.append(&batch(&["a", "b", "c", "d"], i)).unwrap();
629 }
630 let report = log.compact().unwrap();
631 assert!(
632 report.bytes_after * 2 < report.bytes_before,
633 "compaction saved almost nothing: {} -> {} bytes. It costs a rewrite and the \
634 frame boundaries; if it does not pay for them it should not run.",
635 report.bytes_before,
636 report.bytes_after
637 );
638 std::fs::remove_dir_all(&dir).ok();
639 }
640
641 /// **The crash bar.** A compaction interrupted at ANY byte must leave the
642 /// OLD log serving, whole.
643 ///
644 /// Mirrors `gunnar-store`'s `an_interrupted_compaction_leaves_the_old_log_
645 /// serving`, and swept across every byte rather than stopped at one point,
646 /// for the same reason the torn-push sweep is: a recovery that holds at one
647 /// offset and not another is not a recovery. A compaction that can lose a
648 /// ref is worse than the padding it saves.
649 ///
650 /// Seen red by making `compact_with` write the merged frame straight over
651 /// the live log instead of staging and renaming — the sweep then finds cuts
652 /// where the log holds a partial frame and the pushes are gone.
653 #[test]
654 fn an_interrupted_compaction_leaves_the_old_log_serving_at_every_cut() {
655 let dir = tmpdir("compact_crash");
656 let path = dir.join("refs.log");
657 let log = PushLog::new(&path, schema());
658 for i in 0..40u64 {
659 log.append(&batch(&["x", "y"], i)).unwrap();
660 }
661 let intact = std::fs::read(&path).unwrap();
662 let expected_rows: usize = log.scan().unwrap().pushes.iter().map(|b| b.num_rows()).sum();
663
664 // Stage a compaction and stop before the swap — what a crash leaves.
665 let report = log.compact_with(Finish::StopBeforeSwap).unwrap();
666 let staged = compacting_path(&path);
667 assert!(staged.exists(), "the staged replacement must exist to cut into");
668 let staged_bytes = std::fs::read(&staged).unwrap();
669 assert!(staged_bytes.len() > 32);
670
671 for cut in 0..staged_bytes.len() {
672 // A crash part-way through writing the replacement.
673 std::fs::write(&staged, &staged_bytes[..cut]).unwrap();
674
675 // The live log is untouched and still serves every push.
676 assert_eq!(
677 std::fs::read(&path).unwrap(),
678 intact,
679 "cut at {cut}: the LIVE log was modified by a compaction that never \
680 completed — it must never be written in place"
681 );
682 let scan = log.scan().unwrap();
683 assert!(scan.is_clean(), "cut at {cut}: the live log stopped scanning clean");
684 assert_eq!(
685 scan.pushes.len(),
686 40,
687 "cut at {cut}: the live log lost pushes to an interrupted compaction"
688 );
689 let rows: usize = scan.pushes.iter().map(|b| b.num_rows()).sum();
690 assert_eq!(rows, expected_rows, "cut at {cut}: rows went missing");
691 }
692
693 // And finishing the compaction for real still works afterwards.
694 std::fs::remove_file(&staged).ok();
695 let done = log.compact().unwrap();
696 assert_eq!(done.frames_after, 1);
697 assert_eq!(done.rows, expected_rows);
698 assert_eq!(report.rows, expected_rows);
699
700 std::fs::remove_dir_all(&dir).ok();
701 }
702
703 /// A log written before compaction existed still reads, and a compacted log
704 /// reads on the same scanner. No migration, no version bump.
705 #[test]
706 fn compacted_and_uncompacted_logs_are_the_same_format() {
707 let dir = tmpdir("compat");
708 let path = dir.join("refs.log");
709 let log = PushLog::new(&path, schema());
710 for i in 0..8u64 {
711 log.append(&batch(&["a"], i)).unwrap();
712 }
713 let uncompacted = log.scan().unwrap();
714 log.compact().unwrap();
715 let compacted = log.scan().unwrap();
716
717 // Same rows, same order, read by the same scanner with no flag.
718 let flat = |s: &PushLogScan| -> Vec<u64> {
719 let mut v = Vec::new();
720 for b in &s.pushes {
721 let c = b.column(1).as_any().downcast_ref::<UInt64Array>().unwrap();
722 v.extend((0..c.len()).map(|i| c.value(i)));
723 }
724 v
725 };
726 assert_eq!(flat(&uncompacted), flat(&compacted), "compaction changed the row sequence");
727
728 // Appending after a compaction still works and stays readable.
729 log.append(&batch(&["z"], 99)).unwrap();
730 let after = log.scan().unwrap();
731 assert!(after.is_clean());
732 assert_eq!(after.pushes.len(), 2, "a compacted log must still accept appends");
733 assert_eq!(*flat(&after).last().unwrap(), 99);
734
735 std::fs::remove_dir_all(&dir).ok();
736 }
737
738 /// A batch whose schema is not the log's is refused at append. Writing it
739 /// would produce a frame the scanner parses happily but whose columns no
740 /// reader of this log expects.
741 #[test]
742 fn a_foreign_schema_is_refused_at_append() {
743 let dir = tmpdir("schema");
744 let log = PushLog::new(dir.join("refs.log"), schema());
745 let other = Arc::new(Schema::new(vec![Field::new("x", DataType::Utf8, false)]));
746 let foreign =
747 RecordBatch::try_new(other, vec![Arc::new(StringArray::from(vec!["v"]))]).unwrap();
748 let err = log.append(&foreign).unwrap_err().to_string();
749 assert!(err.contains("schema"), "expected a schema error, got: {err}");
750 std::fs::remove_dir_all(&dir).ok();
751 }
752}