Skip to main content

froe/writer/maintenance/
plan.rs

1//! What a planned run reports: the actions it would take, why an
2//! archive or journal line is stale, and what the applied run did.
3
4use super::options::MaintenanceTask;
5use super::planning::{
6    CheckpointPlan, DirectoryFingerprint, JournalPlan, PlannedFileRemoval, StaleArchive,
7};
8use crate::segment::identifier::SegmentIdentifier;
9use crate::segment::record::RecordIdentifier;
10use crate::writer::compaction::CompactionKind;
11use crate::writer::segment_builder::GarbageCollectionGeneration;
12use crate::writer::store_writer::StandaloneSegmentCompactionPlan;
13use std::collections::HashSet;
14use std::path::{Path, PathBuf};
15
16/// Why an archive file can be removed without losing an active segment.
17#[derive(Clone, Copy, Debug, PartialEq, Eq)]
18#[non_exhaustive]
19pub enum StaleArchiveReason {
20    /// A different letter of the same archive number has the newest valid
21    /// index and is the active reader winner.
22    Superseded,
23    /// The file is empty and therefore contains no recoverable segment.
24    EmptyIncomplete,
25}
26
27impl std::fmt::Display for StaleArchiveReason {
28    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
29        formatter.write_str(match self {
30            Self::Superseded => "superseded by the active archive generation",
31            Self::EmptyIncomplete => "empty incomplete archive",
32        })
33    }
34}
35
36/// Why one physical journal line is removable.
37#[derive(Clone, Copy, Debug, PartialEq, Eq)]
38#[non_exhaustive]
39pub enum JournalRemovalReason {
40    /// The tolerant journal reader skips a line that contains no ASCII space.
41    ParserSkippedNoSpace,
42    /// The first space-delimited field is not a valid record identifier.
43    InvalidRecordIdentifier,
44    /// The record identifier names a segment that is not present.
45    MissingSegment,
46    /// The non-current historical node revision does not fully traverse.
47    UnreadableRevision,
48    /// The revision resolves, but an explicit retention bound keeps only
49    /// newer revisions. Removing the line is what releases its closure from
50    /// the history keep-veto; without it the line stays a tracing root and
51    /// the segments behind it stay protected.
52    BeyondRetention,
53}
54
55impl std::fmt::Display for JournalRemovalReason {
56    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
57        formatter.write_str(match self {
58            Self::ParserSkippedNoSpace => "parser-skipped (no ASCII space)",
59            Self::InvalidRecordIdentifier => "invalid record identifier",
60            Self::MissingSegment => "missing segment",
61            Self::UnreadableRevision => "unreadable historical revision",
62            Self::BeyondRetention => "beyond the journal retention bound",
63        })
64    }
65}
66
67/// One physical journal line selected for removal.
68///
69/// The preview is an exact, bounded prefix of the line excluding its line
70/// terminator. It is bytes rather than text so invalid UTF-8 remains auditable;
71/// terminal applications must escape it before display.
72#[derive(Clone, Debug, PartialEq, Eq)]
73#[non_exhaustive]
74pub struct JournalLineRemoval {
75    pub(super) line_number: usize,
76    pub(super) record_identifier: Option<RecordIdentifier>,
77    pub(super) reason: JournalRemovalReason,
78    pub(super) preview: Vec<u8>,
79    pub(super) preview_truncated: bool,
80}
81
82impl JournalLineRemoval {
83    /// One-based physical line number in the journal snapshot.
84    #[must_use]
85    pub fn line_number(&self) -> usize {
86        self.line_number
87    }
88
89    /// Parsed record identifier, when the line contained one.
90    #[must_use]
91    pub fn record_identifier(&self) -> Option<RecordIdentifier> {
92        self.record_identifier
93    }
94
95    /// Structured reason for removing this line.
96    #[must_use]
97    pub fn reason(&self) -> JournalRemovalReason {
98        self.reason
99    }
100
101    /// Exact bounded prefix of the line, excluding its terminator.
102    #[must_use]
103    pub fn preview_bytes(&self) -> &[u8] {
104        &self.preview
105    }
106
107    /// Whether bytes after [`Self::preview_bytes`] were omitted.
108    #[must_use]
109    pub fn preview_truncated(&self) -> bool {
110        self.preview_truncated
111    }
112}
113
114pub(super) const ALREADY_ABSENT_DELETION_DETAIL: &str =
115    "file was already absent when deletion was attempted";
116
117#[derive(Clone, Copy, Debug, PartialEq, Eq)]
118pub(super) enum FileDeletionFailureKind {
119    Retained,
120    AlreadyAbsent,
121}
122
123/// A planned deletion that this cleanup could not perform or confirm itself.
124///
125/// The target usually remains for a later retry. It can instead have already
126/// been absent when the guarded unlink was reached; use
127/// [`Self::target_was_already_absent`] to distinguish that auditable race.
128#[derive(Clone, Debug, PartialEq, Eq)]
129#[non_exhaustive]
130pub struct FileDeletionFailure {
131    pub(super) file_name: String,
132    pub(super) error: String,
133    pub(super) kind: FileDeletionFailureKind,
134}
135
136impl FileDeletionFailure {
137    pub(super) fn retained(file_name: String, error: impl Into<String>) -> Self {
138        Self {
139            file_name,
140            error: error.into(),
141            kind: FileDeletionFailureKind::Retained,
142        }
143    }
144
145    pub(super) fn already_absent(file_name: String, error: impl Into<String>) -> Self {
146        Self {
147            file_name,
148            error: error.into(),
149            kind: FileDeletionFailureKind::AlreadyAbsent,
150        }
151    }
152
153    /// Exact managed file name involved in the partial deletion result.
154    ///
155    /// The path need not remain when [`Self::target_was_already_absent`]
156    /// returns `true`.
157    #[must_use]
158    pub fn file_name(&self) -> &str {
159        &self.file_name
160    }
161
162    /// Operating-system or consistency detail from the incomplete or
163    /// externally satisfied deletion.
164    #[must_use]
165    pub fn error(&self) -> &str {
166        &self.error
167    }
168
169    /// Whether another actor had already removed the exact planned pathname
170    /// when cleanup reached its guarded deletion.
171    #[must_use]
172    pub fn target_was_already_absent(&self) -> bool {
173        self.kind == FileDeletionFailureKind::AlreadyAbsent
174    }
175}
176
177/// One concrete, deterministically ordered cleanup action.
178#[derive(Clone, Debug, PartialEq, Eq)]
179#[non_exhaustive]
180pub enum CompactionAction {
181    /// Rebuild the index of an active archive that has none.
182    ///
183    /// Reported by the read-only preview, which cannot do more than name the
184    /// work: the repair itself happens under the repository lock, and every
185    /// index-dependent decision — the segment sweep, checkpoint removal —
186    /// can only be planned once it has. The one locked plan the operator
187    /// confirms is therefore always larger than a dry-run preview that
188    /// named this.
189    RepairArchiveIndex {
190        /// Archive file name the rebuilt archive is installed under: the
191        /// lowest non-empty generation letter of its number.
192        file_name: String,
193        /// Other generation letters of the same number, whose contents are
194        /// merged into the rebuild and which are then retired to `.bak`
195        /// names. Named because confirmation is scoped to the files a plan
196        /// printed, and these leave the archive namespace.
197        retired_file_names: Vec<String>,
198        /// Why the existing index was rejected.
199        reason: String,
200        /// Whole-file bytes across every letter that will be read, which is
201        /// what ends up retained under `.bak` names.
202        bytes: u64,
203    },
204    /// Rewrite the journal while retaining readable record lines verbatim.
205    PruneJournal {
206        /// Total physical lines removed.
207        lines: usize,
208        /// Lines the tolerant reader already skips.
209        parser_ignored: usize,
210        /// Syntactic record lines whose head segment is absent.
211        missing_segments: usize,
212        /// Non-current historical node roots that do not fully traverse.
213        unreadable_revisions: usize,
214        /// Resolvable revisions older than an explicit retention bound.
215        beyond_retention: usize,
216    },
217    /// Atomically raise `store.version` from 1 to 2 before writing v2 data.
218    UpgradeManifest,
219    /// Remove checkpoints in one head update.
220    RemoveCheckpoints {
221        /// Exact checkpoint names, sorted and deduplicated.
222        names: Vec<String>,
223        /// Names selected because their valid timestamp has expired.
224        expired: usize,
225        /// Additional names selected by the opt-in `/:async` rule.
226        unreferenced: usize,
227    },
228    /// Unlink a fully reclaimable active archive.
229    RemoveReclaimableArchive {
230        /// Current archive file name.
231        file_name: String,
232        /// Segments made unavailable by the unlink.
233        segments: usize,
234        /// Current whole-file bytes.
235        bytes: u64,
236    },
237    /// Rewrite an active archive to its next letter with only survivors.
238    RewriteArchive {
239        /// Source archive file name.
240        file_name: String,
241        /// Exclusively created replacement name.
242        replacement_name: String,
243        /// Segments omitted from the replacement.
244        segments: usize,
245        /// TAR-entry bytes eligible for reclamation.
246        eligible_bytes: u64,
247    },
248    /// Remove an inactive archive generation or empty incomplete archive.
249    RemoveStaleArchive {
250        /// Exact archive file name.
251        file_name: String,
252        /// Proof supporting removal.
253        reason: StaleArchiveReason,
254        /// Current whole-file bytes.
255        bytes: u64,
256    },
257    /// Remove a provably redundant interrupted-operation staging file.
258    RemoveTemporary {
259        /// Exact file name.
260        file_name: String,
261        /// Current whole-file bytes.
262        bytes: u64,
263    },
264    /// Retire every journal revision but the one the copy publishes.
265    ///
266    /// Named separately from `PruneJournal`, which describes removing lines
267    /// that cannot resolve. This removes lines that resolve perfectly well,
268    /// by policy, because the segments behind them are what the run reclaims.
269    RetireJournalHistory {
270        /// Physical journal lines present before the run, all of which are
271        /// replaced by the single line naming the compacted head.
272        revisions: usize,
273    },
274    /// Omit orphaned version histories from the copy — the run's one
275    /// content mutation, listed so confirmation covers it explicitly.
276    PurgeOrphanedVersionHistories {
277        /// Histories the copy omits.
278        histories: u64,
279        /// Nodes those histories hold.
280        nodes: u64,
281        /// Checkpoints the run retains, whose snapshots keep the purged
282        /// histories' storage alive until they expire.
283        retained_checkpoints: u64,
284    },
285    /// Retire the output an interrupted earlier compaction left behind.
286    ///
287    /// Segments stamped ahead of the head are, by construction, the copy of a
288    /// run that died before it committed. No ordinary rule removes them, so a
289    /// killed run would otherwise leave residue that every later run steps
290    /// around while it holds bulk segments alive.
291    RetireInterruptedCompactionResidue {
292        /// Data segments found ahead of the head.
293        segments: usize,
294    },
295    /// Deep-copy the head, and every checkpoint this run retains, into a
296    /// fresh garbage-collection generation.
297    CopyHeadIntoFreshGeneration {
298        /// Distinct node records the current head reaches.
299        ///
300        /// What the copy rewrites is this minus whatever only a retired
301        /// checkpoint reaches, so it is an exact statement about the store
302        /// rather than a prediction about the copy.
303        head_nodes: u64,
304        /// The generation the copy writes into.
305        target_generation: GarbageCollectionGeneration,
306        /// Whether this is a full or a tail compaction.
307        kind: CompactionKind,
308    },
309    /// Remove an explicitly authorized old recovery backup.
310    RemoveRecoveryBackup {
311        /// Exact file name.
312        file_name: String,
313        /// Current whole-file bytes.
314        bytes: u64,
315    },
316}
317
318/// Segments this run identified as reclaimable and then declined to remove.
319///
320/// Every count here is garbage the mark phase proved removable; the archive
321/// sweep kept it anyway, because rewriting the archive that holds it would
322/// not repay the rewrite. Reporting it is what separates "this store holds no
323/// garbage" from "this store holds garbage that is not worth moving".
324#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
325pub(super) struct RetainedReclaimable {
326    /// Segments kept by Oak's 25% savings gate.
327    pub(super) below_savings_gate: usize,
328    /// Segments kept because the archive exhausted the `a`–`z` namespace.
329    pub(super) at_last_generation: usize,
330    /// Segments kept because another generation pathname is occupied.
331    pub(super) blocked_by_occupied_generation: usize,
332    /// TAR entry bytes those segments occupy, summed across every reason.
333    pub(super) bytes: u64,
334}
335
336impl RetainedReclaimable {
337    /// Segments identified as reclaimable and left in place, all reasons.
338    fn segments(self) -> usize {
339        self.below_savings_gate
340            .saturating_add(self.at_last_generation)
341            .saturating_add(self.blocked_by_occupied_generation)
342    }
343}
344
345/// What the journal-history keep-veto protects, and what it costs.
346///
347/// froe retains every readable journal revision as a tracing root, which Oak
348/// does not do: Oak judges data segments by their index generation triple
349/// alone. The veto is strictly conservative, so it can never delete anything
350/// Oak would keep — but on a long-lived store it is normally the single
351/// largest reason a cleanup reclaims nothing, and nothing in the run used to
352/// say so.
353#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
354pub(super) struct HistoryProtection {
355    /// Data segments reachable only from a historical journal revision, and
356    /// not from the current head.
357    pub(super) history_only_segments: usize,
358    /// Segments this same sweep would physically free with the veto lifted
359    /// and nothing else changed. Measured by replanning rather than reasoned
360    /// about: the veto holds bulk segments only through the data segments
361    /// that reference them, and releasing more of an archive can carry it
362    /// over the 25% rewrite gate. Counting protected data segments alone
363    /// understates this by orders of magnitude on a store whose history
364    /// holds inline binaries.
365    pub(super) would_be_reclaimable_segments: usize,
366    /// Bytes those segments occupy, whole archive files included where the
367    /// unvetoed sweep would unlink one outright.
368    pub(super) would_be_reclaimable_bytes: u64,
369}
370
371/// The external binaries the verified head references, counted while the
372/// planning walk was reading every property anyway.
373///
374/// Compaction can never touch these bytes — they live in the blob store —
375/// and a plan that says nothing about them invites the operator to expect
376/// blob-store savings from a segment-store tool. Distinctness is tracked
377/// per blob identifier (hash-keyed for identifier formats that carry no
378/// content hash of their own), and bytes are summed from the length suffix
379/// Oak's file blob stores embed in their identifiers; an identifier without
380/// one is counted but cannot contribute bytes.
381#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
382pub struct ExternalBinaryFootprint {
383    /// Distinct external blob identifiers the head references.
384    pub distinct_references: u64,
385    /// Bytes summed over the identifiers that carry a length suffix.
386    pub measured_bytes: u64,
387    /// Distinct identifiers without a parsable length suffix.
388    pub unmeasured_references: u64,
389}
390
391/// What the planner established about orphaned version histories:
392/// `nt:versionHistory` subtrees whose `jcr:versionableUuid` matches no live
393/// `jcr:uuid` outside version storage. Reachable content — so no structural
394/// sweep may touch them — yet garbage by the only definition that matters
395/// once the versionable is gone, and the figures here are what they pin.
396/// Detection runs on every plan; removal is the separate, explicitly
397/// selected purge.
398#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
399pub struct OrphanedVersionHistoryReport {
400    /// Histories whose versionables no longer exist.
401    pub orphaned_histories: u64,
402    /// Nodes those histories hold, the history nodes included.
403    pub orphaned_nodes: u64,
404    /// Inline binary bytes stored in their properties.
405    pub inline_binary_bytes: u64,
406    /// External binary references stored in their properties — bytes that
407    /// live in the blob store and return only through blob-store garbage
408    /// collection once a purge unreferences them.
409    pub external_references: u64,
410    /// Bulk segments a purge would stop referencing — an upper bound on
411    /// what it releases, not a promise. Attribution is per certified
412    /// record, so a record shared between a purged history and one the
413    /// store keeps (Oak's writer dedups identical frozen subtrees into
414    /// shared records) keeps its blocks alive without this figure knowing,
415    /// and a retained checkpoint's snapshot can pin blocks until it
416    /// expires. The sweep that follows the copy frees exactly what is
417    /// actually unreferenced, whatever this predicted.
418    pub released_bulk_segments: u64,
419    /// Indexed bytes of those bulk segments — the same upper-bound
420    /// semantics as [`Self::released_bulk_segments`].
421    pub released_bulk_bytes: u64,
422    /// The orphans' share of the copy's node records, scaled from the
423    /// head's average bytes per node. An estimate for the plan, realized
424    /// and reported exactly by the copy that runs.
425    pub node_record_bytes_estimate: u64,
426    /// Histories whose `jcr:versionableUuid` did not parse and therefore
427    /// could not be classified.
428    pub malformed_identifiers: u64,
429    /// Checkpoints the run retains. Their snapshots can pin some of the
430    /// released bulk until they expire — pinning the walks cannot see when
431    /// the snapshot shares the head's version-storage records — so a
432    /// nonzero count is the caveat on [`Self::released_bulk_bytes`].
433    pub retained_checkpoints: u64,
434}
435
436/// The purge the plan carries when one is selected: the subtree roots the
437/// copy omits, and the counts the summary restates.
438#[derive(Clone, Debug, PartialEq, Eq)]
439pub(super) struct VersionHistoryPurge {
440    pub(super) omitted_records: Vec<RecordIdentifier>,
441    /// The ancestors on the path from the content root down to each omitted
442    /// record. Their rewritten form depends on the scope — the head's copy
443    /// loses the omitted subtrees, a checkpoint snapshot's keeps them — so
444    /// the copy memoizes them per scope rather than globally.
445    pub(super) context_dependent_records: Vec<RecordIdentifier>,
446    pub(super) histories: u64,
447    pub(super) nodes: u64,
448    /// Checkpoints the run retains, whose snapshots keep the purged
449    /// histories' storage alive until they expire.
450    pub(super) retained_checkpoints: u64,
451}
452
453/// A strictly read-only cleanup analysis.
454#[derive(Clone, Debug, PartialEq, Eq)]
455pub struct CompactionPlan {
456    pub(super) directory: PathBuf,
457    pub(super) tasks: Vec<MaintenanceTask>,
458    pub(super) current_head: RecordIdentifier,
459    pub(super) actions: Vec<CompactionAction>,
460    pub(super) warnings: Vec<String>,
461    pub(super) estimated_reclaimable_bytes: u64,
462    pub(super) estimated_archive_rewrite_source_bytes: u64,
463    pub(super) retained_reclaimable: RetainedReclaimable,
464    pub(super) history_protection: HistoryProtection,
465    pub(super) fingerprint: DirectoryFingerprint,
466    pub(super) journal: JournalPlan,
467    pub(super) checkpoints: CheckpointPlan,
468    pub(super) checkpoint_archive_number: Option<u32>,
469    pub(super) stale_archives: Vec<StaleArchive>,
470    pub(super) temporaries: Vec<PlannedFileRemoval>,
471    pub(super) recovery_backups: Vec<PlannedFileRemoval>,
472    pub(super) segment_plan: Option<StandaloneSegmentCompactionPlan>,
473    /// The sweep that retires an interrupted earlier compaction's output,
474    /// applied before this run's own copy.
475    pub(super) residue_sweep: Option<StandaloneSegmentCompactionPlan>,
476    pub(super) reference_generation: GarbageCollectionGeneration,
477    pub(super) protected_history_segments: HashSet<SegmentIdentifier>,
478    pub(super) manifest_upgrade: bool,
479    /// What the planned copy is expected to write into the fresh
480    /// generation, when a copy is planned and the head closure was traced.
481    pub(super) predicted_copy_output_bytes: Option<u64>,
482    /// The external binaries the verified head references.
483    pub(super) external_binary_footprint: ExternalBinaryFootprint,
484    /// The compaction this run will actually perform: the selected kind,
485    /// unless the convergence gate proved the copy pointless and dropped it.
486    pub(super) effective_compaction_kind: Option<CompactionKind>,
487    /// Whether the planner proved the head already fully compacted.
488    pub(super) already_fully_compacted: bool,
489    /// What the planner established about orphaned version histories.
490    pub(super) orphaned_version_histories: OrphanedVersionHistoryReport,
491    /// The purge this run performs, when one is selected and non-empty.
492    pub(super) version_history_purge: Option<VersionHistoryPurge>,
493}
494
495impl CompactionPlan {
496    /// Canonical absolute repository directory this plan describes.
497    #[must_use]
498    pub fn directory(&self) -> &Path {
499        &self.directory
500    }
501
502    /// Selected cleanup categories in deterministic order.
503    #[must_use]
504    #[cfg(test)]
505    pub(crate) fn tasks(&self) -> &[MaintenanceTask] {
506        &self.tasks
507    }
508
509    /// Exact current head verified while planning.
510    #[must_use]
511    pub fn current_head(&self) -> RecordIdentifier {
512        self.current_head
513    }
514
515    /// Concrete mutations in deterministic display order.
516    #[must_use]
517    pub fn actions(&self) -> &[CompactionAction] {
518        &self.actions
519    }
520
521    /// Exact physical journal lines selected for removal. This is empty unless
522    /// the journal task was selected; internal journal analysis still
523    /// runs for the safety of other tasks.
524    #[must_use]
525    pub fn journal_line_removals(&self) -> &[JournalLineRemoval] {
526        if self.tasks.contains(&MaintenanceTask::Journal) {
527            &self.journal.removals
528        } else {
529            &[]
530        }
531    }
532
533    /// Non-fatal deferrals and malformed metadata retained for safety.
534    ///
535    /// Also carries one advisory line per index definition Oak has flagged
536    /// for reindex — `pending reindex: <path> (<type>; …)` — because an
537    /// operator compacting before an AEM restart is holding the store open
538    /// at the moment that fact is cheap to read and expensive to miss. It
539    /// is advisory in the strict sense: no action is added to the plan, no
540    /// byte the run writes changes, and nothing about the run is decided by
541    /// it.
542    #[must_use]
543    pub fn warnings(&self) -> &[String] {
544        &self.warnings
545    }
546
547    /// Conservative sum of whole files and TAR entry bytes selected for
548    /// removal. Archive overhead and deletion failures can make the actual
549    /// result differ.
550    #[must_use]
551    pub fn estimated_reclaimable_bytes(&self) -> u64 {
552        self.estimated_reclaimable_bytes
553    }
554
555    /// The bytes the planned copy is expected to write into the fresh
556    /// generation, when a copy is planned and the head closure was traced.
557    ///
558    /// This is the head closure's indexed data bytes — exact for a head a
559    /// previous compaction wrote (the deterministic copy is a fixed point),
560    /// an upper bound for a fragmented head (the dense copy writes less),
561    /// and slightly low only for the first compaction of a store another
562    /// writer produced, whose nodes gain stable-identifier blocks in the
563    /// copy. Reported so an estimate can state the run's net effect rather
564    /// than only its reclaimed side: a swap that removes one generation and
565    /// writes an equal one has a net effect of nothing, and an estimate
566    /// that hides the written side over-promises by exactly this figure.
567    #[must_use]
568    pub fn predicted_copy_output_bytes(&self) -> Option<u64> {
569        self.predicted_copy_output_bytes
570    }
571
572    /// The compaction this run will actually perform. `None` either because
573    /// none was selected or because the convergence gate proved the head is
574    /// already fully compacted and dropped the selected copy — in which
575    /// case [`Self::already_fully_compacted`] says so.
576    #[must_use]
577    pub fn effective_compaction_kind(&self) -> Option<CompactionKind> {
578        self.effective_compaction_kind
579    }
580
581    /// Whether the planner proved every data segment the head reaches
582    /// already carries the head's own compacted generation triple, with no
583    /// checkpoint to omit, no version history selected for purge, and the
584    /// journal already retired — the state a completed full compaction
585    /// leaves, in which a repeat copy would only replace a generation with
586    /// an identical one.
587    #[must_use]
588    pub fn already_fully_compacted(&self) -> bool {
589        self.already_fully_compacted
590    }
591
592    /// What the planner established about orphaned version histories.
593    #[must_use]
594    pub fn orphaned_version_histories(&self) -> OrphanedVersionHistoryReport {
595        self.orphaned_version_histories
596    }
597
598    /// The external binaries the verified head references — blob-store
599    /// content compaction can never reclaim, reported so an operator's
600    /// blob-store expectations land on blob-store garbage collection
601    /// rather than on this run.
602    #[must_use]
603    pub fn external_binary_footprint(&self) -> ExternalBinaryFootprint {
604        self.external_binary_footprint
605    }
606
607    /// Sum of the current source-file sizes for archives that will be
608    /// rewritten. Source mappings stay open through the sweep, so the
609    /// filesystem may need cumulative additional space of this order. This is
610    /// an operational proxy for archive rewriting, not a bound on other cleanup
611    /// files or filesystem allocation overhead.
612    #[must_use]
613    pub fn estimated_archive_rewrite_source_bytes(&self) -> u64 {
614        self.estimated_archive_rewrite_source_bytes
615    }
616
617    /// Segments proved reclaimable that this run will nevertheless leave in
618    /// place, because rewriting the archives holding them is not worthwhile
619    /// or not possible. Nonzero alongside a zero reclaimable estimate means
620    /// the store holds garbage this cleanup declined, not that it holds none.
621    #[must_use]
622    pub fn retained_reclaimable_segments(&self) -> usize {
623        self.retained_reclaimable.segments()
624    }
625
626    /// TAR entry bytes occupied by [`Self::retained_reclaimable_segments`].
627    #[must_use]
628    pub fn retained_reclaimable_bytes(&self) -> u64 {
629        self.retained_reclaimable.bytes
630    }
631
632    /// Data segments kept alive only because a historical journal revision
633    /// still reaches them. Zero unless the segment task ran.
634    #[must_use]
635    pub fn history_protected_segments(&self) -> usize {
636        self.history_protection.history_only_segments
637    }
638
639    /// Those of [`Self::history_protected_segments`] that Oak's generation
640    /// predicate would have reclaimed, and the bytes they occupy. This is
641    /// what retiring the journal history — a full compaction — would make
642    /// eligible; standalone cleanup never will.
643    #[must_use]
644    pub fn history_protected_reclaimable(&self) -> (usize, u64) {
645        (
646            self.history_protection.would_be_reclaimable_segments,
647            self.history_protection.would_be_reclaimable_bytes,
648        )
649    }
650
651    /// Whether application would request any mutation.
652    #[must_use]
653    pub fn is_empty(&self) -> bool {
654        self.actions.is_empty()
655    }
656}
657
658/// What a run's deep copy produced.
659#[derive(Clone, Copy, Debug, PartialEq, Eq)]
660#[non_exhaustive]
661pub struct CompactedGeneration {
662    /// Distinct node records the copy rewrote.
663    pub nodes: u64,
664    /// The garbage-collection generation the copy wrote into.
665    pub generation: GarbageCollectionGeneration,
666}
667
668/// Result of a prepared maintenance application and its final fresh
669/// verification.
670#[derive(Clone, Debug, PartialEq, Eq)]
671#[non_exhaustive]
672pub struct CompactionOutcome {
673    /// Head before cleanup.
674    pub head_before: RecordIdentifier,
675    /// Freshly reopened and verified head after cleanup.
676    pub head_after: RecordIdentifier,
677    /// Number of checkpoints removed in one logical commit.
678    pub removed_checkpoints: u64,
679    /// Journal physical lines removed.
680    pub removed_journal_lines: usize,
681    /// Active archives rewritten.
682    pub rewritten_archives: usize,
683    /// Active fully reclaimable archives unlinked.
684    pub removed_reclaimable_archives: usize,
685    /// Superseded/empty archive files unlinked.
686    pub removed_stale_archives: usize,
687    /// Proven staging files removed.
688    pub removed_temporaries: usize,
689    /// Opt-in recovery backups removed.
690    pub removed_recovery_backups: usize,
691    /// Archive indexes rebuilt before planning, under the repository lock.
692    pub repaired_archives: usize,
693    /// Recognized deletion targets this cleanup did not unlink itself.
694    ///
695    /// Most entries remain for retry; entries reported as already absent need
696    /// no further deletion attempt.
697    pub files_not_deleted: Vec<String>,
698    /// Bytes in recognized archive files before application.
699    pub archive_bytes_before: u64,
700    /// Bytes in recognized archive files after application.
701    pub archive_bytes_after: u64,
702    /// Bytes still held by retained recovery backups after application.
703    ///
704    /// These sit outside [`Self::archive_bytes_after`], which counts only
705    /// active archive names. A run that rebuilds an index retires the
706    /// original under a `.bak` name, so the directory grows by this much
707    /// while the archive figures report no change at all.
708    pub retained_recovery_backup_bytes: u64,
709    /// Distinct node records copied into the fresh generation, and the
710    /// generation they were copied into. `None` when the run did not compact.
711    pub compacted: Option<CompactedGeneration>,
712    pub(super) removed_segments: usize,
713    pub(super) journal_backup_path: Option<PathBuf>,
714    pub(super) deletion_failures: Vec<FileDeletionFailure>,
715}
716
717impl CompactionOutcome {
718    /// Orphan segments removed from the active archive set.
719    #[must_use]
720    pub fn removed_segments(&self) -> usize {
721        self.removed_segments
722    }
723
724    /// Durable byte-exact journal backup created by this cleanup, if any.
725    #[must_use]
726    pub fn journal_backup_path(&self) -> Option<&Path> {
727        self.journal_backup_path.as_deref()
728    }
729
730    /// Planned deletions this cleanup did not perform itself, making the
731    /// result partial even when another actor already removed a target.
732    #[must_use]
733    pub fn deletion_failures(&self) -> &[FileDeletionFailure] {
734        &self.deletion_failures
735    }
736
737    /// Whether this cleanup itself completed every planned deletion without
738    /// an auditable partial result.
739    #[must_use]
740    pub fn is_complete(&self) -> bool {
741        self.deletion_failures.is_empty()
742    }
743}
744
745#[cfg(test)]
746mod tests {
747    use super::*;
748    use crate::store::Repository;
749
750    use crate::writer::maintenance::options::*;
751
752    use crate::writer::maintenance::prepared::*;
753
754    use crate::writer::maintenance::test_support::*;
755    use std::io::Write as _;
756    use std::num::NonZeroUsize;
757
758    #[test]
759    fn dangling_journal_line_is_pruned_with_backup_and_archives_untouched() {
760        let directory = TestDirectory::repository("dangling-journal");
761        let missing = SegmentIdentifier::new(7, 0xA000_0000_0000_0007);
762        let journal_path = directory.path.join("journal.log");
763        let retained_journal = std::fs::read(&journal_path).expect("read retained journal");
764        let mut journal = std::fs::OpenOptions::new()
765            .append(true)
766            .open(&journal_path)
767            .expect("open journal");
768        writeln!(journal, "{missing}:0 root 123").expect("append dangling line");
769        drop(journal);
770        let archive_before =
771            std::fs::read(directory.path.join("data00000a.tar")).expect("read archive");
772        std::fs::write(
773            directory.path.join("manifest"),
774            b"custom.property=untouched\nstore.version=1\n",
775        )
776        .expect("version-one manifest");
777        let manifest_before = std::fs::read(directory.path.join("manifest")).expect("manifest");
778        let options = CompactionOptions::default().with_tasks([MaintenanceTask::Journal]);
779
780        let plan = plan_compaction(&directory.path, &options).expect("plan");
781        assert_eq!(plan.tasks(), &[MaintenanceTask::Journal]);
782        assert_eq!(plan.journal_line_removals().len(), 1);
783        let removal = &plan.journal_line_removals()[0];
784        assert_eq!(
785            removal.record_identifier().map(|record| record.segment),
786            Some(missing)
787        );
788        assert_eq!(removal.reason(), JournalRemovalReason::MissingSegment);
789        assert!(
790            removal
791                .preview_bytes()
792                .starts_with(missing.to_string().as_bytes())
793        );
794        assert!(!removal.preview_truncated());
795        assert!(plan.actions().iter().any(|action| matches!(
796            action,
797            CompactionAction::PruneJournal {
798                missing_segments: 1,
799                ..
800            }
801        )));
802        let outcome = compact(&directory.path, options).expect("apply");
803
804        assert_eq!(outcome.removed_journal_lines, 1);
805        let expected_backup =
806            canonical_fixture_directory(&directory.path).join("journal.log.bak.000");
807        assert_eq!(
808            outcome.journal_backup_path(),
809            Some(expected_backup.as_path())
810        );
811        assert!(outcome.is_complete());
812        assert!(directory.path.join("journal.log.bak.000").is_file());
813        assert!(
814            !std::fs::read_to_string(&journal_path)
815                .expect("journal")
816                .contains(&missing.to_string())
817        );
818        assert_eq!(
819            std::fs::read(&journal_path).expect("rewritten journal"),
820            retained_journal,
821            "the retained physical journal line must be byte-exact"
822        );
823        assert_eq!(
824            std::fs::read(directory.path.join("data00000a.tar")).expect("archive"),
825            archive_before
826        );
827        assert_eq!(
828            std::fs::read(directory.path.join("manifest")).expect("manifest"),
829            manifest_before
830        );
831        Repository::open(&directory.path).expect("healthy repository");
832    }
833    #[test]
834    fn deletion_absence_state_does_not_depend_on_diagnostic_text() {
835        let retained = super::FileDeletionFailure::retained(
836            "data00000a.tar".to_owned(),
837            ALREADY_ABSENT_DELETION_DETAIL,
838        );
839        let absent = super::FileDeletionFailure::already_absent(
840            "data00001a.tar".to_owned(),
841            "a deliberately different ENOENT diagnostic",
842        );
843
844        assert!(!retained.target_was_already_absent());
845        assert!(absent.target_was_already_absent());
846    }
847    #[test]
848    fn a_journal_retention_bound_retires_the_history_the_veto_protects() {
849        let (directory, old_head, new_head) = history_veto_fixture("history-veto-retention");
850        let protected = plan_compaction(
851            &directory.path,
852            &CompactionOptions::default().with_tasks([MaintenanceTask::Segments]),
853        )
854        .expect("unbounded plan");
855        assert!(protected.history_protected_reclaimable().0 != 0);
856        assert!(
857            !protected.actions().iter().any(|action| matches!(
858                action,
859                CompactionAction::RemoveReclaimableArchive { file_name, .. }
860                    if file_name == "data00000a.tar"
861            )),
862            "without a bound the veto must keep the bootstrap archive"
863        );
864
865        let bounded = CompactionOptions::default()
866            .with_tasks([MaintenanceTask::Segments, MaintenanceTask::Journal])
867            .with_journal_revision_retention(NonZeroUsize::new(1).expect("one revision"));
868        let plan = plan_compaction(&directory.path, &bounded).expect("bounded plan");
869
870        // The older line is pruned for the retention reason, not for damage.
871        assert!(
872            plan.journal_line_removals().iter().any(|removal| {
873                removal.reason() == JournalRemovalReason::BeyondRetention
874                    && removal.record_identifier() == Some(old_head)
875            }),
876            "the superseded revision must be removed as beyond retention"
877        );
878        // Releasing that root is what makes the archive eligible.
879        assert!(
880            plan.actions().iter().any(|action| matches!(
881                action,
882                CompactionAction::RemoveReclaimableArchive { file_name, .. }
883                    if file_name == "data00000a.tar"
884            )),
885            "the bound must release the bootstrap archive to Oak's predicate"
886        );
887        assert!(plan.estimated_reclaimable_bytes() != 0);
888
889        let outcome = compact(&directory.path, bounded).expect("bounded cleanup");
890        assert_eq!(outcome.head_after, new_head);
891        assert!(!directory.path.join("data00000a.tar").exists());
892        let repository = Repository::open(&directory.path).expect("healthy final repository");
893        assert_eq!(repository.head_record_identifier(), new_head);
894        // The journal keeps exactly the bound's worth of revisions, and the
895        // retired history is genuinely gone rather than merely unrooted.
896        let journal =
897            std::fs::read_to_string(directory.path.join("journal.log")).expect("read journal");
898        assert_eq!(
899            journal.lines().count(),
900            1,
901            "a bound of one leaves one journal line"
902        );
903        assert!(
904            crate::tooling::verify_node_tree(&repository, old_head).is_err(),
905            "the retired revision must no longer resolve"
906        );
907    }
908    #[test]
909    fn a_bound_counts_only_revisions_that_actually_resolve() {
910        // A line whose segment exists but whose tree does not verify used to
911        // fill a slot in the bound and then be removed as unreadable anyway,
912        // so `N = 2` kept one revision and irreversibly retired a readable
913        // one to make room for it. Every earlier retention test used N = 1,
914        // which cannot expose this: the head is always the newest resolvable
915        // line and always verifies.
916        let (directory, old_head, new_head) = history_veto_fixture("retention-counts-readable");
917
918        // A journal line naming a record that resolves to a segment but not
919        // to a readable node tree: the head's own segment, at a record
920        // number that is not a node record.
921        let unreadable = RecordIdentifier::new(new_head.segment, new_head.record_number + 1);
922        // Second newest, not newest: the newest line is the head, and a
923        // head that is not a node record is refused long before any bound.
924        let journal_path = directory.path.join("journal.log");
925        let journal = std::fs::read_to_string(&journal_path).expect("read journal");
926        let mut lines: Vec<&str> = journal.lines().collect();
927        let head_line = lines.pop().expect("a head line");
928        let unreadable_line = format!("{unreadable} root 0");
929        lines.push(&unreadable_line);
930        lines.push(head_line);
931        std::fs::write(&journal_path, format!("{}\n", lines.join("\n")))
932            .expect("insert unreadable line");
933
934        let options = CompactionOptions::default()
935            .with_tasks([MaintenanceTask::Segments, MaintenanceTask::Journal])
936            .with_journal_revision_retention(NonZeroUsize::new(2).expect("two revisions"));
937        let plan = plan_compaction(&directory.path, &options).expect("bounded plan");
938
939        // The unreadable line goes, as it always did. What must not happen is
940        // the older *readable* revision going with it to satisfy a bound the
941        // unreadable line was counted against.
942        assert!(
943            !plan.journal_line_removals().iter().any(|removal| {
944                removal.reason() == JournalRemovalReason::BeyondRetention
945                    && removal.record_identifier() == Some(old_head)
946            }),
947            "a readable revision was retired to make room for an unreadable one: {:?}",
948            plan.journal_line_removals()
949        );
950    }
951    #[test]
952    fn a_bound_larger_than_the_journal_removes_nothing() {
953        let (directory, _old_head, _new_head) = history_veto_fixture("history-veto-wide-bound");
954        let options = CompactionOptions::default()
955            .with_tasks([MaintenanceTask::Segments, MaintenanceTask::Journal])
956            .with_journal_revision_retention(NonZeroUsize::new(64).expect("wide bound"));
957        let plan = plan_compaction(&directory.path, &options).expect("wide plan");
958        assert!(
959            !plan
960                .journal_line_removals()
961                .iter()
962                .any(|removal| { removal.reason() == JournalRemovalReason::BeyondRetention }),
963            "a bound wider than the journal must retire nothing"
964        );
965        assert!(plan.history_protected_reclaimable().0 != 0);
966    }
967}