Skip to main content

choir_oplog/
repair.rs

1//! Inspecting and repairing a log file that a daemon is not holding.
2//!
3//! Three things a log can be, and they want three different answers:
4//!
5//! 1. **Intact.** Every record decodes, every `parent` names the record
6//!    before it, and `seq` counts from zero without a gap.
7//! 2. **Torn at the tail.** The final record was still being written
8//!    when the machine stopped. Nothing was lost — the sequencer
9//!    acknowledges only after [`crate::OpLog::sync`] returns — so this is
10//!    repairable, and [`crate::FileLog::open`] already repairs it in place.
11//! 3. **Damaged in the middle.** A record that was once written whole no
12//!    longer decodes, or the chain does not link. **Not repairable
13//!    here.** Anything that made the file consistent again would do it
14//!    by dropping ops that were acknowledged to somebody, and a log that
15//!    silently loses acknowledged ops is worse than one that refuses to
16//!    open. The answer is a restore from backup, and this module's job
17//!    is to say so precisely rather than to improvise.
18//!
19//! # Why this does not call `FileLog::open`
20//!
21//! Because opening a log *repairs* it: a torn tail is truncated as a
22//! side effect of the constructor. A verify that ran through `open`
23//! would change the thing it was asked to inspect, and would report
24//! "intact" about a file it had just altered. Everything here reads the
25//! file with its own reader and writes nothing unless asked to.
26//!
27//! # What `open` does not check, and this does
28//!
29//! [`crate::FileLog::open`] validates that each record *decodes*. It does not
30//! validate the chain: the `parent`/head comparison lives in
31//! [`crate::OpLog::append`], on the write path, and has no counterpart on
32//! replay. So a log whose hash chain is broken opens perfectly well
33//! today. That gap is the reason [`verify`] exists.
34
35use std::io::{BufRead, BufReader, Read, Seek, SeekFrom, Write};
36use std::path::{Path, PathBuf};
37
38use crate::{ContentHash, LogError, OpEntry, FORMAT_VERSION};
39
40/// The first thing found wrong with a log, if anything was.
41///
42/// One fault, not a list, and deliberately so: after the first break
43/// every later record is being judged against a chain that is already
44/// wrong, so a list would be one real finding followed by noise.
45#[derive(Debug, Clone, PartialEq, Eq)]
46pub enum Fault {
47    /// A newline-terminated record did not decode. It was written whole
48    /// once, so this is damage, not a torn write.
49    Undecodable {
50        /// Position in the file, counting records from zero.
51        position: u64,
52        /// What the decoder said.
53        detail: String,
54    },
55    /// A record names a wire format this build does not support.
56    UnsupportedFormat {
57        /// Position in the file, counting records from zero.
58        position: u64,
59        /// Version carried by the record.
60        found: u16,
61    },
62    /// A record's `parent` is not the hash of the record before it.
63    BrokenChain {
64        /// Position in the file, counting records from zero.
65        position: u64,
66        /// The hash the previous record actually has.
67        expected: Option<ContentHash>,
68        /// The hash this record claims its parent is.
69        found: Option<ContentHash>,
70    },
71    /// A record's `seq` is not its position in the file.
72    ///
73    /// Separate from [`Fault::BrokenChain`] because the two fail
74    /// independently: a log can renumber without breaking its hashes
75    /// (the hash does not cover position) and can break its hashes while
76    /// staying numbered correctly.
77    SeqMismatch {
78        /// Position in the file, counting records from zero.
79        position: u64,
80        /// The `seq` the record carries.
81        found: u64,
82    },
83}
84
85impl Fault {
86    /// Where the log first goes wrong.
87    #[must_use]
88    pub fn position(&self) -> u64 {
89        match self {
90            Fault::Undecodable { position, .. }
91            | Fault::UnsupportedFormat { position, .. }
92            | Fault::BrokenChain { position, .. }
93            | Fault::SeqMismatch { position, .. } => *position,
94        }
95    }
96}
97
98impl std::fmt::Display for Fault {
99    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
100        match self {
101            Fault::Undecodable { position, detail } => write!(
102                f,
103                "record {position} does not decode: {detail}"
104            ),
105            Fault::UnsupportedFormat { position, found } => write!(
106                f,
107                "record {position} uses unsupported format version {found}; this build supports {FORMAT_VERSION}"
108            ),
109            Fault::BrokenChain {
110                position,
111                expected,
112                found,
113            } => write!(
114                f,
115                "record {position} names parent {} but the record before it hashes to {}",
116                found.as_ref().map_or("none".to_string(), ContentHash::to_hex),
117                expected
118                    .as_ref()
119                    .map_or("none".to_string(), ContentHash::to_hex)
120            ),
121            Fault::SeqMismatch { position, found } => write!(
122                f,
123                "record at position {position} carries seq {found}"
124            ),
125        }
126    }
127}
128
129/// What a read-only walk of the log found.
130#[derive(Debug, Clone)]
131pub struct ChainReport {
132    /// Records that decoded and linked correctly, counted from the
133    /// start until the first fault (or to the end, if there was none).
134    pub intact_records: u64,
135    /// The first thing wrong, if anything was.
136    pub fault: Option<Fault>,
137    /// Bytes of unterminated final record, or 0 if the file ends on a
138    /// record boundary. Non-zero is repairable; see [`truncate_tail`].
139    pub torn_tail_bytes: u64,
140    /// Hash of the last intact record, which is what a restore has to
141    /// agree with.
142    pub head: Option<ContentHash>,
143}
144
145impl ChainReport {
146    /// Whether the log can be opened and used as it stands.
147    ///
148    /// A torn tail does not make this false: [`crate::FileLog::open`] repairs
149    /// that on its own, and the bytes it discards were never
150    /// acknowledged.
151    #[must_use]
152    pub fn is_usable(&self) -> bool {
153        self.fault.is_none()
154    }
155
156    /// Whether the only thing wrong is a tail that was still being
157    /// written, which [`truncate_tail`] can repair.
158    #[must_use]
159    pub fn is_repairable(&self) -> bool {
160        self.fault.is_none() && self.torn_tail_bytes > 0
161    }
162}
163
164/// Walks the log and reports the first fault. Changes nothing.
165///
166/// Reads the file directly rather than through [`crate::FileLog::open`],
167/// because opening repairs a torn tail as a side effect and would make
168/// this report describe a file it had already altered.
169///
170/// # Errors
171///
172/// [`LogError::Io`] if the file cannot be read. A damaged *record* is
173/// reported in the [`ChainReport`], not returned as an error: the
174/// caller asked what is wrong with the log, and answering with a
175/// failure would make the answer indistinguishable from not being able
176/// to look.
177pub fn verify(path: &Path) -> Result<ChainReport, LogError> {
178    let file = std::fs::File::open(path).map_err(LogError::Io)?;
179    let mut reader = BufReader::new(file);
180    let mut line = Vec::new();
181    let mut head: Option<ContentHash> = None;
182    let mut position = 0u64;
183    let mut torn_tail_bytes = 0u64;
184    let mut fault = None;
185
186    loop {
187        line.clear();
188        let read = reader.read_until(b'\n', &mut line).map_err(LogError::Io)?;
189        if read == 0 {
190            break;
191        }
192        let Some(body) = line.strip_suffix(b"\n") else {
193            // No terminator: the write was interrupted. Not a fault --
194            // it is the one repairable state -- so it is reported in its
195            // own field rather than as damage.
196            torn_tail_bytes = read as u64;
197            break;
198        };
199        let entry: OpEntry = match serde_json::from_slice(body) {
200            Ok(entry) => entry,
201            Err(error) => {
202                fault = Some(Fault::Undecodable {
203                    position,
204                    detail: error.to_string(),
205                });
206                break;
207            }
208        };
209        if entry.format_version != FORMAT_VERSION {
210            fault = Some(Fault::UnsupportedFormat {
211                position,
212                found: entry.format_version,
213            });
214            break;
215        }
216        if entry.parent != head {
217            fault = Some(Fault::BrokenChain {
218                position,
219                expected: head.clone(),
220                found: entry.parent,
221            });
222            break;
223        }
224        if entry.seq != position {
225            fault = Some(Fault::SeqMismatch {
226                position,
227                found: entry.seq,
228            });
229            break;
230        }
231        head = Some(entry.content_hash());
232        position += 1;
233    }
234
235    Ok(ChainReport {
236        intact_records: position,
237        fault,
238        torn_tail_bytes,
239        head,
240    })
241}
242
243/// What a tail repair did.
244#[derive(Debug, Clone)]
245pub struct Repaired {
246    /// Where the removed bytes were written before the file was cut.
247    pub quarantine: PathBuf,
248    /// How many bytes were moved there.
249    pub bytes: u64,
250    /// The log's length afterwards.
251    pub length: u64,
252}
253
254/// Moves an unterminated final record into a sidecar and truncates the
255/// log to the last complete one.
256///
257/// The bytes are **copied out before the file is cut**, in that order,
258/// and the sidecar is synced before the truncation is issued. If the
259/// machine stops midway the worst case is a sidecar with no truncation
260/// -- a spare copy of bytes that are still in the log -- rather than a
261/// truncation with no sidecar, which would be the deletion this is
262/// written to avoid.
263///
264/// # Errors
265///
266/// [`LogError::Corrupt`] if the log has a fault that is not a torn tail.
267/// Refusing is the whole point: a mid-log break cannot be repaired by
268/// removing the end of the file, and doing it anyway would silently drop
269/// acknowledged ops. [`LogError::Io`] on filesystem failure.
270pub fn truncate_tail(path: &Path) -> Result<Option<Repaired>, LogError> {
271    let report = verify(path)?;
272    if let Some(fault) = report.fault {
273        return Err(LogError::Corrupt(format!(
274            "refusing to truncate: the damage is not a torn tail ({fault}). \
275             Truncating would drop acknowledged ops. Restore from backup."
276        )));
277    }
278    if report.torn_tail_bytes == 0 {
279        return Ok(None);
280    }
281
282    let mut file = std::fs::OpenOptions::new()
283        .read(true)
284        .write(true)
285        .open(path)
286        .map_err(LogError::Io)?;
287    let length = file.metadata().map_err(LogError::Io)?.len();
288    let keep = length - report.torn_tail_bytes;
289
290    let mut torn = vec![0u8; usize::try_from(report.torn_tail_bytes).unwrap_or(usize::MAX)];
291    file.seek(SeekFrom::Start(keep)).map_err(LogError::Io)?;
292    file.read_exact(&mut torn).map_err(LogError::Io)?;
293
294    let quarantine = quarantine_path(path, keep);
295    let mut sidecar = std::fs::File::create(&quarantine).map_err(LogError::Io)?;
296    sidecar.write_all(&torn).map_err(LogError::Io)?;
297    // Durable before the log is cut, or a crash here loses the only
298    // remaining copy.
299    sidecar.sync_all().map_err(LogError::Io)?;
300    drop(sidecar);
301    crate::sync_parent_dir(path)?;
302
303    file.set_len(keep).map_err(LogError::Io)?;
304    file.sync_all().map_err(LogError::Io)?;
305
306    Ok(Some(Repaired {
307        quarantine,
308        bytes: report.torn_tail_bytes,
309        length: keep,
310    }))
311}
312
313/// Writes `bytes` to the sidecar for a tail cut at `offset`, syncing it
314/// before returning.
315///
316/// Shared with [`crate::FileLog::open`], which does the same repair
317/// automatically on startup. Automatic *truncation* is a deliberate
318/// availability choice — a node has to come back after a power cut
319/// without a human — but automatic *deletion* is not, and this is the
320/// difference. The caller must not truncate until this returns.
321///
322/// # Errors
323///
324/// [`LogError::Io`] if the sidecar cannot be written or synced. Failing
325/// here fails the open, which is correct: the alternative is truncating
326/// with nowhere to put the bytes.
327pub(crate) fn quarantine_tail(path: &Path, bytes: &[u8], offset: u64) -> Result<PathBuf, LogError> {
328    let quarantine = quarantine_path(path, offset);
329    let mut sidecar = std::fs::File::create(&quarantine).map_err(LogError::Io)?;
330    sidecar.write_all(bytes).map_err(LogError::Io)?;
331    sidecar.sync_all().map_err(LogError::Io)?;
332    drop(sidecar);
333    crate::sync_parent_dir(path)?;
334    Ok(quarantine)
335}
336
337/// Sidecar name for bytes cut from `path` at offset `offset`.
338///
339/// The offset is in the name rather than a timestamp: it makes the file
340/// say where it came from, and repeating the same repair produces the
341/// same name instead of a new file each run.
342fn quarantine_path(path: &Path, offset: u64) -> PathBuf {
343    let mut name = path.file_name().unwrap_or_default().to_os_string();
344    name.push(format!(".torn-{offset}"));
345    path.with_file_name(name)
346}
347
348#[cfg(test)]
349mod tests {
350    use super::*;
351    use crate::{OpLog, FORMAT_VERSION};
352
353    /// Builds a valid log file of `count` linked records and returns its
354    /// path. Generated inline rather than kept as a fixture, per the
355    /// house rule, and built through the real writer so the bytes are
356    /// exactly what a node would have produced.
357    fn valid_log(dir: &Path, count: u64) -> PathBuf {
358        let path = dir.join("ops.jsonl");
359        let mut log = crate::FileLog::open(&path).expect("fresh log opens");
360        for seq in 0..count {
361            let entry = OpEntry {
362                format_version: FORMAT_VERSION,
363                parent: log.head(),
364                seq,
365                channel: "ws".into(),
366                payload: format!("op-{seq}").into_bytes(),
367                witnesses: Vec::new(),
368                author_sig: None,
369            };
370            log.append(entry).expect("append");
371        }
372        log.sync().expect("sync");
373        path
374    }
375
376    fn scratch(tag: &str) -> PathBuf {
377        let dir = std::env::temp_dir().join(format!(
378            "choir-repair-{tag}-{}-{:?}",
379            std::process::id(),
380            std::thread::current().id()
381        ));
382        std::fs::create_dir_all(&dir).expect("scratch dir");
383        dir
384    }
385
386    #[test]
387    fn an_intact_log_reports_no_fault() {
388        let dir = scratch("intact");
389        let path = valid_log(&dir, 5);
390        let report = verify(&path).expect("readable");
391        assert_eq!(report.intact_records, 5);
392        assert!(report.fault.is_none(), "{:?}", report.fault);
393        assert_eq!(report.torn_tail_bytes, 0);
394        assert!(report.is_usable());
395        assert!(!report.is_repairable(), "nothing to repair");
396    }
397
398    #[test]
399    fn a_torn_tail_is_reported_as_repairable_not_as_damage() {
400        let dir = scratch("torn");
401        let path = valid_log(&dir, 3);
402        // A partial record with no terminator, exactly what an
403        // interrupted append leaves behind.
404        let mut file = std::fs::OpenOptions::new()
405            .append(true)
406            .open(&path)
407            .expect("reopen");
408        file.write_all(br#"{"format_version":1,"seq":3,"chan"#)
409            .expect("partial write");
410        file.sync_all().expect("sync");
411        drop(file);
412
413        let report = verify(&path).expect("readable");
414        assert!(report.fault.is_none(), "a torn tail is not damage");
415        assert_eq!(report.intact_records, 3);
416        assert!(report.torn_tail_bytes > 0);
417        assert!(report.is_repairable());
418    }
419
420    #[test]
421    fn a_broken_chain_is_found_and_located() {
422        let dir = scratch("chain");
423        let path = valid_log(&dir, 4);
424        // Rewrite record 2 with a parent that names nothing real. The
425        // record still decodes, so only a chain check can catch it --
426        // which is precisely what `FileLog::open` does not do.
427        let text = std::fs::read_to_string(&path).expect("read");
428        let mut lines: Vec<String> = text.lines().map(ToString::to_string).collect();
429        // Give record 2 record 1's parent: a correctly *shaped*
430        // ContentHash lifted from the file itself, pointing at the wrong
431        // record. Handing it a hex string instead produced an
432        // `Undecodable` fault -- the check fired, but on the wrong
433        // thing, which would have made this test a false witness for
434        // chain verification.
435        let earlier: serde_json::Value = serde_json::from_str(&lines[1]).expect("record 1 decodes");
436        let mut entry: serde_json::Value =
437            serde_json::from_str(&lines[2]).expect("record 2 decodes");
438        entry["parent"] = earlier["parent"].clone();
439        lines[2] = serde_json::to_string(&entry).expect("re-encode");
440        std::fs::write(&path, lines.join("\n") + "\n").expect("write back");
441
442        let report = verify(&path).expect("readable");
443        assert!(!report.is_usable());
444        assert!(!report.is_repairable(), "this is not a tail problem");
445        assert_eq!(report.intact_records, 2, "records before it are intact");
446        let fault = report.fault.expect("the break is found");
447        assert_eq!(fault.position(), 2, "and located exactly: {fault}");
448        assert!(matches!(fault, Fault::BrokenChain { .. }), "{fault:?}");
449    }
450
451    #[test]
452    fn an_undecodable_record_is_found_and_located() {
453        let dir = scratch("garbage");
454        let path = valid_log(&dir, 4);
455        let text = std::fs::read_to_string(&path).expect("read");
456        let mut lines: Vec<String> = text.lines().map(ToString::to_string).collect();
457        lines[1] = "{not json at all".to_string();
458        std::fs::write(&path, lines.join("\n") + "\n").expect("write back");
459
460        let report = verify(&path).expect("readable");
461        let fault = report.fault.expect("the damage is found");
462        assert_eq!(fault.position(), 1);
463        assert!(matches!(fault, Fault::Undecodable { .. }), "{fault:?}");
464    }
465
466    #[test]
467    fn verifying_does_not_repair() {
468        // The trap this module's doc warns about: a verify implemented
469        // over `FileLog::open` would truncate the tail and then report a
470        // clean log, having caused the change it failed to mention.
471        let dir = scratch("readonly");
472        let path = valid_log(&dir, 2);
473        let mut file = std::fs::OpenOptions::new()
474            .append(true)
475            .open(&path)
476            .expect("reopen");
477        file.write_all(b"partial").expect("partial write");
478        drop(file);
479
480        let before = std::fs::metadata(&path).expect("stat").len();
481        let report = verify(&path).expect("readable");
482        let after = std::fs::metadata(&path).expect("stat").len();
483        assert_eq!(before, after, "verify must not change the file");
484        assert_eq!(report.torn_tail_bytes, 7);
485    }
486
487    #[test]
488    fn repairing_a_tail_quarantines_the_bytes_before_cutting() {
489        let dir = scratch("quarantine");
490        let path = valid_log(&dir, 3);
491        let intact_len = std::fs::metadata(&path).expect("stat").len();
492        let torn = br#"{"format_version":1,"seq":3,"chan"#;
493        let mut file = std::fs::OpenOptions::new()
494            .append(true)
495            .open(&path)
496            .expect("reopen");
497        file.write_all(torn).expect("partial write");
498        drop(file);
499
500        let repaired = truncate_tail(&path)
501            .expect("repairable")
502            .expect("something was repaired");
503        assert_eq!(repaired.bytes, torn.len() as u64);
504        assert_eq!(repaired.length, intact_len, "cut back to the last record");
505
506        // The bytes still exist, byte for byte. This is the difference
507        // between quarantine and deletion, and the only way to tell the
508        // two apart is to read them back.
509        let saved = std::fs::read(&repaired.quarantine).expect("sidecar exists");
510        assert_eq!(saved, torn, "the removed bytes are recoverable");
511
512        // And the log is now usable.
513        let after = verify(&path).expect("readable");
514        assert!(after.is_usable(), "{:?}", after.fault);
515        assert_eq!(after.intact_records, 3);
516        assert_eq!(after.torn_tail_bytes, 0);
517    }
518
519    /// After a tail repair the log is not merely readable — it takes new
520    /// appends that chain onto the surviving head.
521    ///
522    /// Verifying the repaired file only proves it parses. What a node
523    /// does next is *write*, and a truncation that left `write_pos` or
524    /// the head wrong would pass a verify and corrupt the log on the
525    /// first append after boot.
526    #[test]
527    fn a_node_can_boot_and_keep_writing_after_a_tail_repair() {
528        let dir = scratch("boots");
529        let path = valid_log(&dir, 3);
530        let mut file = std::fs::OpenOptions::new()
531            .append(true)
532            .open(&path)
533            .expect("reopen");
534        file.write_all(br#"{"format_version":1,"seq":3,"wor"#)
535            .expect("partial write");
536        drop(file);
537
538        truncate_tail(&path).expect("repairable").expect("repaired");
539
540        let mut log = crate::FileLog::open(&path).expect("the node opens the repaired log");
541        assert_eq!(log.len(), 3, "the surviving records are all there");
542        assert_eq!(log.torn_tail_bytes(), 0, "and nothing was left torn");
543
544        let head_before = log.head().expect("three records means a head");
545        log.append(OpEntry {
546            format_version: FORMAT_VERSION,
547            parent: Some(head_before),
548            seq: 3,
549            channel: "ws".into(),
550            payload: b"after the repair".to_vec(),
551            witnesses: Vec::new(),
552            author_sig: None,
553        })
554        .expect("the append chains onto the surviving head");
555        log.sync().expect("sync");
556        drop(log);
557
558        let report = verify(&path).expect("readable");
559        assert!(report.is_usable(), "{:?}", report.fault);
560        assert_eq!(report.intact_records, 4, "the new record joined the chain");
561    }
562
563    #[test]
564    fn repairing_refuses_mid_log_damage_and_says_why() {
565        let dir = scratch("refuse");
566        let path = valid_log(&dir, 4);
567        let text = std::fs::read_to_string(&path).expect("read");
568        let mut lines: Vec<String> = text.lines().map(ToString::to_string).collect();
569        lines[1] = "{not json at all".to_string();
570        std::fs::write(&path, lines.join("\n") + "\n").expect("write back");
571        let before = std::fs::read(&path).expect("read");
572
573        let error = truncate_tail(&path).expect_err("must refuse");
574        let message = format!("{error:?}");
575        assert!(
576            message.contains("restore") || message.contains("Restore"),
577            "the refusal must point at the way out: {message}"
578        );
579
580        // Refusing means changing nothing at all, including not
581        // quarantining anything.
582        assert_eq!(
583            std::fs::read(&path).expect("read"),
584            before,
585            "a refusal must leave the file untouched"
586        );
587        let strays: Vec<_> = std::fs::read_dir(&dir)
588            .expect("listing")
589            .filter_map(Result::ok)
590            .filter(|e| e.file_name().to_string_lossy().contains(".torn-"))
591            .collect();
592        assert!(strays.is_empty(), "nothing was quarantined either");
593    }
594
595    #[test]
596    fn repairing_an_intact_log_does_nothing_rather_than_something() {
597        let dir = scratch("noop");
598        let path = valid_log(&dir, 3);
599        let before = std::fs::read(&path).expect("read");
600        assert!(
601            truncate_tail(&path).expect("succeeds").is_none(),
602            "an intact log has no tail to repair"
603        );
604        assert_eq!(std::fs::read(&path).expect("read"), before);
605    }
606
607    #[test]
608    fn a_renumbered_record_is_caught_even_though_it_decodes() {
609        // `seq` is not covered by the parent chain, so a log can link
610        // correctly and still be misnumbered. Checked separately for
611        // exactly that reason.
612        let dir = scratch("renumber");
613        let path = valid_log(&dir, 3);
614        let text = std::fs::read_to_string(&path).expect("read");
615        let mut lines: Vec<String> = text.lines().map(ToString::to_string).collect();
616        let mut entry: serde_json::Value = serde_json::from_str(&lines[1]).expect("decodes");
617        entry["seq"] = serde_json::json!(99);
618        lines[1] = serde_json::to_string(&entry).expect("re-encode");
619        std::fs::write(&path, lines.join("\n") + "\n").expect("write back");
620
621        let report = verify(&path).expect("readable");
622        let fault = report.fault.expect("caught");
623        assert!(
624            matches!(fault, Fault::SeqMismatch { found: 99, .. }),
625            "{fault:?}"
626        );
627    }
628}