Skip to main content

acta/
recovery.rs

1//! Point-in-time recovery inspection and explicit incomplete-tail repair.
2
3use std::fs::{File, OpenOptions};
4use std::io;
5use std::path::Path;
6
7use crate::error::{Error, ErrorContext, Result};
8use crate::format::scan::FileScan;
9use crate::limits::Limits;
10use crate::lock::acquire_writer_lock;
11
12/// The action a recovery inspection recommends.
13#[derive(Debug, Clone, Copy, PartialEq, Eq)]
14#[non_exhaustive]
15pub enum RecoveryAction {
16    /// The file ends at a complete, structurally validated frame boundary.
17    None,
18    /// The file ends inside one final data frame and may be truncated safely.
19    TruncateIncompleteTail,
20}
21
22/// A read-only, point-in-time recovery decision for an Acta file.
23///
24/// Inspection captures the file length when it opens the file and validates
25/// only that snapshot. The plan must not be treated as an authorization to
26/// truncate later: [`repair_incomplete_tail`] independently opens, locks, and
27/// rescans the current file before changing anything.
28#[derive(Debug, Clone, Copy, PartialEq, Eq)]
29#[non_exhaustive]
30pub struct RecoveryPlan {
31    action: RecoveryAction,
32    file_size: u64,
33    last_good_offset: u64,
34    bytes_to_remove: u64,
35}
36
37impl RecoveryPlan {
38    /// The action supported by this plan.
39    pub fn action(&self) -> RecoveryAction {
40        self.action
41    }
42
43    /// The file length observed by inspection.
44    pub fn file_size(&self) -> u64 {
45        self.file_size
46    }
47
48    /// The byte after the last complete, validated frame.
49    pub fn last_good_offset(&self) -> u64 {
50        self.last_good_offset
51    }
52
53    /// The number of bytes a matching repair would remove.
54    pub fn bytes_to_remove(&self) -> u64 {
55        self.bytes_to_remove
56    }
57
58    /// Whether the snapshot contains a repairable incomplete data-frame tail.
59    pub fn requires_repair(&self) -> bool {
60        self.action == RecoveryAction::TruncateIncompleteTail
61    }
62}
63
64/// The result of successfully repairing one incomplete final data frame.
65#[derive(Debug, Clone, Copy, PartialEq, Eq)]
66#[non_exhaustive]
67pub struct RecoverySummary {
68    original_file_size: u64,
69    repaired_file_size: u64,
70    bytes_removed: u64,
71}
72
73impl RecoverySummary {
74    /// The file length observed before the repair.
75    pub fn original_file_size(&self) -> u64 {
76        self.original_file_size
77    }
78
79    /// The file length after truncation and post-repair validation.
80    pub fn repaired_file_size(&self) -> u64 {
81        self.repaired_file_size
82    }
83
84    /// The number of bytes removed from the incomplete tail.
85    pub fn bytes_removed(&self) -> u64 {
86        self.bytes_removed
87    }
88}
89
90/// Inspect recovery state without opening the file for writing.
91///
92/// This is a point-in-time snapshot. It never acquires the writer lock and
93/// never changes file bytes. A plan that requests repair is only a description
94/// of the snapshot; the repair operation independently locks and revalidates
95/// the current file.
96///
97/// The scan is the structural, whole-frame pass
98/// [`Writer::open`](crate::Writer::open) performs rather than
99/// [`ValidationLevel::Full`](crate::ValidationLevel::Full), so a plan reporting
100/// a complete file says the frames and their chains are intact, not that every
101/// stream decodes.
102pub fn inspect_recovery<P: AsRef<Path>>(path: P) -> Result<RecoveryPlan> {
103    inspect_recovery_with_limits(path, Limits::default())
104}
105
106/// Inspect recovery state under caller-supplied structural limits.
107pub fn inspect_recovery_with_limits<P: AsRef<Path>>(
108    path: P,
109    limits: Limits,
110) -> Result<RecoveryPlan> {
111    let scan = FileScan::open(path.as_ref(), limits)?;
112    let (_file, plan) = discover(scan)?;
113    Ok(plan)
114}
115
116/// Repair a verified incomplete final data frame.
117///
118/// Repair is destructive but narrowly bounded: it can remove only the bytes
119/// after the last complete frame found by a strict structural scan. It refuses
120/// complete corruption, an incomplete schema frame, and an already complete
121/// file. The current file is opened read/write, locked before discovery,
122/// rescanned on that same handle, and scanned once more after truncation. Both
123/// scans are the structural, whole-frame pass
124/// [`Writer::open`](crate::Writer::open) performs rather than
125/// [`ValidationLevel::Full`](crate::ValidationLevel::Full): no stream is
126/// decoded and no statistic is verified.
127///
128/// # Writer exclusion and its limits
129///
130/// This takes the same cooperative exclusive lock as
131/// [`Writer::create`](crate::Writer::create), with the same scope and the same
132/// limits; see that method. Those limits matter more here than they do for an
133/// append, because this operation deletes bytes. The lock is a convention among
134/// `acta` writers rather than part of the format, so it constrains neither
135/// another implementation nor a process that simply opens the path, and it is
136/// unreliable on network filesystems. The physical length is rechecked
137/// immediately before the truncation and a length that moved is refused, but no
138/// portable API makes that check and the truncation a single atomic step: a
139/// writer that does not participate in the lock can still commit a frame inside
140/// that window and lose it.
141///
142/// # Failure after truncation
143///
144/// Every failure before the truncation leaves the file byte for byte as it was,
145/// and every one of them says so. A failure can also follow the truncation —
146/// the synchronization, the rescan, or the post-repair validation — and every
147/// one of those instead says the tail was already removed, whatever its
148/// [`ErrorKind`](crate::ErrorKind). The two sets of messages never overlap, so
149/// a caller can always tell which side of the mutation it is on. After a
150/// post-truncation error the file may already be shorter while its durability
151/// or its structure is unconfirmed.
152pub fn repair_incomplete_tail<P: AsRef<Path>>(path: P) -> Result<RecoverySummary> {
153    repair_incomplete_tail_with_limits(path, Limits::default())
154}
155
156/// Repair a verified incomplete final data frame under structural `limits`.
157pub fn repair_incomplete_tail_with_limits<P: AsRef<Path>>(
158    path: P,
159    limits: Limits,
160) -> Result<RecoverySummary> {
161    let file = OpenOptions::new()
162        .read(true)
163        .write(true)
164        .open(path.as_ref())
165        .map_err(|error| Error::io(error, None).with_context(ErrorContext::File))?;
166    acquire_writer_lock(&file)?;
167    repair_locked_file(file, limits, &FileRepairOperations)
168}
169
170/// The file operations recovery performs around its one mutation.
171///
172/// Naming them lets a test report a length that moved underneath a plan, or
173/// truncate to the wrong length, or fail either call, which is the only way to
174/// reach those paths without a disk that misbehaves. Production always acts on
175/// the locked [`File`].
176trait RepairOperations {
177    fn physical_length(&self, file: &File) -> io::Result<u64>;
178    fn set_len(&self, file: &File, length: u64) -> io::Result<()>;
179    fn sync(&self, file: &File) -> io::Result<()>;
180}
181
182struct FileRepairOperations;
183
184impl RepairOperations for FileRepairOperations {
185    fn physical_length(&self, file: &File) -> io::Result<u64> {
186        file.metadata().map(|metadata| metadata.len())
187    }
188
189    fn set_len(&self, file: &File, length: u64) -> io::Result<()> {
190        file.set_len(length)
191    }
192
193    fn sync(&self, file: &File) -> io::Result<()> {
194        file.sync_all()
195    }
196}
197
198fn discover(mut scan: FileScan) -> Result<(File, RecoveryPlan)> {
199    let walk = scan.walk_data_frames(|_frame, _block| Ok(()))?;
200    let plan = RecoveryPlan::from_walk(
201        scan.file_size(),
202        walk.last_good_offset,
203        walk.incomplete_tail,
204    )?;
205    Ok((scan.into_file(), plan))
206}
207
208fn repair_locked_file(
209    file: File,
210    limits: Limits,
211    operations: &dyn RepairOperations,
212) -> Result<RecoverySummary> {
213    let scan = FileScan::from_file(file, limits)?;
214    let (file, plan) = discover(scan)?;
215    let current_size = operations
216        .physical_length(&file)
217        .map_err(|error| Error::io(error, None).with_context(ErrorContext::File))?;
218    if current_size != plan.file_size {
219        return Err(Error::io(
220            io::Error::other("the file changed while recovery was being inspected"),
221            Some(current_size),
222        )
223        .with_context(ErrorContext::File));
224    }
225    if !plan.requires_repair() {
226        return Err(Error::invalid_argument(
227            "the file is complete; there is no incomplete tail to repair",
228        )
229        .with_context(ErrorContext::File));
230    }
231
232    // The truncation is the point the file's state changes, so the failure on
233    // either side of it says which side it is on. A caller that cannot tell
234    // them apart cannot know whether the committed bytes it had a moment ago
235    // are still all there.
236    operations
237        .set_len(&file, plan.last_good_offset)
238        .map_err(|error| {
239            Error::io(error, Some(plan.last_good_offset))
240                .with_message_prefix(BEFORE_TRUNCATION)
241                .with_context(ErrorContext::File)
242        })?;
243
244    // Synchronization is not the only step left — the file is rescanned and
245    // revalidated after it — and any of those can fail on a disk that stops
246    // cooperating or against a writer that ignored the lock. They all report a
247    // file that is already shorter, so they all carry the same marker.
248    finish_repair(file, limits, &plan, operations)
249        .map_err(|error| error.with_message_prefix(AFTER_TRUNCATION))
250}
251
252/// The marker every failure that leaves the file untouched carries.
253const BEFORE_TRUNCATION: &str = "the incomplete tail was not removed and the file is unchanged";
254
255/// The marker every failure after the truncation carries, whatever its kind.
256const AFTER_TRUNCATION: &str =
257    "the incomplete tail was already removed, so the file may already be shorter than it was";
258
259/// Everything after the one mutation: synchronize, rescan, and validate.
260fn finish_repair(
261    file: File,
262    limits: Limits,
263    plan: &RecoveryPlan,
264    operations: &dyn RepairOperations,
265) -> Result<RecoverySummary> {
266    operations.sync(&file).map_err(|error| {
267        Error::io(error, Some(plan.last_good_offset))
268            .with_message_prefix("the repaired file could not be synchronized")
269            .with_context(ErrorContext::File)
270    })?;
271
272    let post_scan = FileScan::from_file(file, limits)?;
273    let (file, post_plan) = discover(post_scan)?;
274    if post_plan.action != RecoveryAction::None
275        || post_plan.file_size != plan.last_good_offset
276        || post_plan.last_good_offset != plan.last_good_offset
277    {
278        return Err(Error::corruption(
279            "post-repair validation did not produce the expected complete file",
280            Some(plan.last_good_offset),
281        )
282        .with_context(ErrorContext::File));
283    }
284    drop(file);
285
286    let bytes_removed = plan
287        .file_size
288        .checked_sub(post_plan.file_size)
289        .ok_or_else(|| {
290            Error::corruption(
291                "repaired file grew beyond its original size",
292                Some(post_plan.file_size),
293            )
294            .with_context(ErrorContext::File)
295        })?;
296    Ok(RecoverySummary {
297        original_file_size: plan.file_size,
298        repaired_file_size: post_plan.file_size,
299        bytes_removed,
300    })
301}
302
303impl RecoveryPlan {
304    fn from_walk(file_size: u64, last_good_offset: u64, incomplete_tail: bool) -> Result<Self> {
305        let bytes_to_remove = if incomplete_tail {
306            if last_good_offset >= file_size {
307                return Err(Error::corruption(
308                    "an incomplete tail does not extend beyond the last complete frame",
309                    Some(last_good_offset),
310                )
311                .with_context(ErrorContext::File));
312            }
313            file_size.checked_sub(last_good_offset).ok_or_else(|| {
314                Error::corruption(
315                    "the last complete frame is beyond the captured file extent",
316                    Some(last_good_offset),
317                )
318                .with_context(ErrorContext::File)
319            })?
320        } else {
321            if last_good_offset != file_size {
322                return Err(Error::corruption(
323                    "a complete recovery walk did not reach the file extent",
324                    Some(last_good_offset),
325                )
326                .with_context(ErrorContext::File));
327            }
328            0
329        };
330        let action = if incomplete_tail {
331            RecoveryAction::TruncateIncompleteTail
332        } else {
333            RecoveryAction::None
334        };
335        Ok(Self {
336            action,
337            file_size,
338            last_good_offset,
339            bytes_to_remove,
340        })
341    }
342}
343
344#[cfg(test)]
345mod tests {
346    use super::*;
347    use std::path::PathBuf;
348    use std::sync::atomic::{AtomicU64, Ordering};
349
350    /// Operations that behave normally except where a test asks otherwise.
351    #[derive(Default)]
352    struct FailingOperations {
353        /// Reported instead of the real length, to move the file underneath a
354        /// plan that has already been discovered.
355        length: Option<u64>,
356        fail_length: bool,
357        fail_set_len: bool,
358        fail_sync: bool,
359        /// Added to the verified target, to truncate somewhere the plan did not
360        /// authorize.
361        length_delta: i64,
362    }
363
364    impl RepairOperations for FailingOperations {
365        fn physical_length(&self, file: &File) -> io::Result<u64> {
366            if self.fail_length {
367                return Err(io::Error::other("injected metadata failure"));
368            }
369            match self.length {
370                Some(length) => Ok(length),
371                None => file.metadata().map(|metadata| metadata.len()),
372            }
373        }
374
375        fn set_len(&self, file: &File, length: u64) -> io::Result<()> {
376            if self.fail_set_len {
377                return Err(io::Error::other("injected set_len failure"));
378            }
379            file.set_len(length.wrapping_add(self.length_delta as u64))
380        }
381
382        fn sync(&self, file: &File) -> io::Result<()> {
383            if self.fail_sync {
384                return Err(io::Error::other("injected sync failure"));
385            }
386            file.sync_all()
387        }
388    }
389
390    #[test]
391    fn plan_requires_removal_only_for_an_incomplete_data_tail() {
392        let complete = RecoveryPlan::from_walk(10, 10, false).unwrap();
393        assert_eq!(complete.action(), RecoveryAction::None);
394        assert_eq!(complete.bytes_to_remove(), 0);
395
396        let incomplete = RecoveryPlan::from_walk(14, 10, true).unwrap();
397        assert_eq!(incomplete.action(), RecoveryAction::TruncateIncompleteTail);
398        assert!(incomplete.requires_repair());
399        assert_eq!(incomplete.bytes_to_remove(), 4);
400    }
401
402    #[test]
403    fn injected_set_len_failure_preserves_the_file() {
404        let fixture = Fixture::incomplete();
405        let error = fixture.repair(FailingOperations {
406            fail_set_len: true,
407            ..FailingOperations::default()
408        });
409        assert_eq!(error.kind(), crate::ErrorKind::Io);
410        assert!(error.message().starts_with(BEFORE_TRUNCATION), "{error}");
411        assert!(!error.message().contains(AFTER_TRUNCATION), "{error}");
412        assert_eq!(fixture.length(), fixture.original_size);
413        assert_eq!(fixture.bytes(), fixture.original_bytes);
414    }
415
416    #[test]
417    fn injected_sync_failure_does_not_claim_durability() {
418        let fixture = Fixture::incomplete();
419        let error = fixture.repair(FailingOperations {
420            fail_sync: true,
421            ..FailingOperations::default()
422        });
423        assert_eq!(error.kind(), crate::ErrorKind::Io);
424        // The failures on either side of the truncation leave opposite states,
425        // so a caller has to be able to tell them apart from the error alone.
426        assert!(error.message().starts_with(AFTER_TRUNCATION), "{error}");
427        assert!(!error.message().contains(BEFORE_TRUNCATION), "{error}");
428        assert!(fixture.length() < fixture.original_size);
429    }
430
431    #[test]
432    fn a_length_that_moved_under_the_plan_refuses_before_truncating() {
433        for reported in [0, 1, u64::MAX] {
434            let fixture = Fixture::incomplete();
435            let error = fixture.repair(FailingOperations {
436                length: Some(reported),
437                ..FailingOperations::default()
438            });
439            assert_eq!(error.kind(), crate::ErrorKind::Io);
440            assert!(error.message().contains("changed"), "{error}");
441            assert_eq!(fixture.bytes(), fixture.original_bytes);
442        }
443    }
444
445    #[test]
446    fn an_unreadable_length_refuses_before_truncating() {
447        let fixture = Fixture::incomplete();
448        let error = fixture.repair(FailingOperations {
449            fail_length: true,
450            ..FailingOperations::default()
451        });
452        assert_eq!(error.kind(), crate::ErrorKind::Io);
453        assert_eq!(fixture.bytes(), fixture.original_bytes);
454    }
455
456    /// The last guard before success is reported: a truncation that lands
457    /// anywhere but the verified boundary must not be called a repair.
458    ///
459    /// This failure is a `Corruption`, not the `Io` a failed synchronization
460    /// produces, and it still arrives after the file has been shortened, so it
461    /// has to carry the same marker: the distinction a caller needs is which
462    /// side of the mutation the failure is on, not which kind it is.
463    #[test]
464    fn a_wrong_truncation_target_fails_post_repair_validation() {
465        for delta in [-64_i64, -8, -1, 1, 8, 64] {
466            let fixture = Fixture::incomplete();
467            let error = fixture.repair(FailingOperations {
468                length_delta: delta,
469                ..FailingOperations::default()
470            });
471            assert_eq!(error.kind(), crate::ErrorKind::Corruption, "delta {delta}");
472            assert!(
473                error.message().starts_with(AFTER_TRUNCATION),
474                "delta {delta}: {error}"
475            );
476            assert!(!error.message().contains(BEFORE_TRUNCATION), "{error}");
477        }
478    }
479
480    /// A file that removes itself, so a failing assertion cannot leave one
481    /// behind for the next run to collide with.
482    struct Fixture {
483        path: PathBuf,
484        original_bytes: Vec<u8>,
485        original_size: u64,
486    }
487
488    impl Fixture {
489        /// The reference fixture with one byte of an unfinished frame after it.
490        fn incomplete() -> Self {
491            static NEXT_FIXTURE: AtomicU64 = AtomicU64::new(0);
492
493            let mut bytes = include_bytes!("../spec/v0.2/fixtures/minimal/minimal.acta").to_vec();
494            bytes.push(0);
495            let path = std::env::temp_dir().join(format!(
496                "acta-recovery-unit-{}-{}.acta",
497                std::process::id(),
498                NEXT_FIXTURE.fetch_add(1, Ordering::Relaxed)
499            ));
500            let _ = std::fs::remove_file(&path);
501            std::fs::write(&path, &bytes).unwrap();
502            Self {
503                path,
504                original_size: bytes.len() as u64,
505                original_bytes: bytes,
506            }
507        }
508
509        /// Run the production repair over a locked handle and expect a failure.
510        fn repair(&self, operations: FailingOperations) -> Error {
511            let file = OpenOptions::new()
512                .read(true)
513                .write(true)
514                .open(&self.path)
515                .unwrap();
516            acquire_writer_lock(&file).unwrap();
517            match repair_locked_file(file, Limits::default(), &operations) {
518                Ok(summary) => panic!("repair unexpectedly succeeded: {summary:?}"),
519                Err(error) => error,
520            }
521        }
522
523        fn length(&self) -> u64 {
524            std::fs::metadata(&self.path).unwrap().len()
525        }
526
527        fn bytes(&self) -> Vec<u8> {
528            std::fs::read(&self.path).unwrap()
529        }
530    }
531
532    impl Drop for Fixture {
533        fn drop(&mut self) {
534            let _ = std::fs::remove_file(&self.path);
535        }
536    }
537}