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}