Skip to main content

prikk_store/
compact.rs

1//! RFC 102 Stage 6 Step 2's compactor (design-v1.md §15, §15.6-§15.9): reclaims dead records from the
2//! three genuine compaction targets by writing their live, reduced record set to the currently-retired
3//! slot, then durably switching the generation log to name it live -- acceptance criterion 1's
4//! ordering: the new slot's bytes are durable before the generation record that makes them
5//! authoritative is appended (`generation::append_generation_record`).
6//!
7//! **Refuses on any known-corrupt record (§15.3, non-negotiable).** A naive compactor that read via
8//! the resync reader and wrote back only what it yields would silently drop a corrupt record from the
9//! new slot while abandoning the old one -- corruption becomes permanent deletion, through the exact
10//! mechanism built to survive it, and the operation reports success. Every function below fails closed
11//! on *any* damaged entry, not just the latest -- stricter than the read path's own "damaged latest
12//! entry" contract, because compaction is destructive to the retired slot in a way an ordinary read
13//! never is.
14//!
15//! **The container lock is held for the whole operation** -- resolve, read, reduce, and (for a real
16//! run) truncate, write, switch -- so a concurrent writer for this container is excluded throughout,
17//! not just during the final switch. This is what lets the writers that share the same lock
18//! (`publish`, `add_trusted_maintainer`/`remove_trusted_maintainer`, `import_bundle`) stay ignorant of
19//! compaction: they cannot observe a torn state, because they cannot run at all while this holds the
20//! lock. `plan_compact_*` holds the same lock for the same reason, even though it never writes --
21//! numbers read without it could go stale before they reach the terminal, and an operator acts on what
22//! a preview reports.
23//!
24//! **Never touches the ref-log or trust-key containers.** Neither is a compaction target -- the ref
25//! log is DC-38/DC-69's audit trail, and the trust key container is TOFU history (`trust.rs:77`) --
26//! and this module has no function for either, which is the enforcement, not a rule stated in prose.
27//!
28//! **Compaction discards no information any read path depends on -- not merely "nothing reads it."**
29//! `refs/verify/scan.rs`'s own read path (`read_pointers`) iterates every ref-pointer-index record in
30//! append order and overwrites per `ref_name_key`, keeping only the last -- the exact reduction
31//! `compact_ref_pointer_index` performs and persists. `verify`'s output is unchanged by construction,
32//! not merely by inspection: compaction persists the reduction the reader already computes at read
33//! time, it does not invent a new one.
34//!
35//! **No confirmation prompt, unlike `unlock`.** `prikk unlock`'s prompt exists because the tool is
36//! asking the operator to supply a fact it cannot check itself -- whether a process is truly gone.
37//! Compaction has no equivalent unknown: the container lock excludes concurrent writers, the
38//! corruption refusal checks every record before touching anything, and the reduction persists exactly
39//! what every reader already resolves. A prompt here would gate on nothing -- the same "a check that
40//! cannot fail is worse than no check" shape §15.9 already named for a different case in this stage.
41
42use prikk_error::{PrikkError, Result};
43
44use crate::fsutil::{append_file_required, truncate_file_empty_required};
45use crate::generation::{self, GenerationRecord};
46use crate::layout::{LockableContainer, RepositoryLayout};
47use crate::lock::acquire_container_locks;
48use crate::received_index::{
49    ReceivedIndexEntry, encode_received_index_record, replay_received_index,
50};
51use crate::refs::{PointerIndexEntry, encode_pointer_index_record, replay_pointer_index};
52use crate::trust_index::{encode_trust_policy_record, replay_trust_policy};
53
54/// Outcome of one compaction run: how many live records existed before and after reduction. This is
55/// the deduplication compaction performs on index/pointer *records*, not object deletion -- nothing
56/// in this module ever deletes an object; `entries_before - entries_after` counts stale pointer/
57/// snapshot records reclaimed, never data. `plan_compact_*` returns the same shape without writing
58/// anything -- what a real run *would* report.
59#[derive(Debug, Clone, Copy, PartialEq, Eq)]
60pub struct CompactionReport {
61    /// Which container this run (or preview) targets.
62    pub container: LockableContainer,
63    /// Live record count before reduction (the resolved slot's own entry count, corruption-checked).
64    pub entries_before: usize,
65    /// Live record count after reduction (what was, or would be, written to the newly-published
66    /// slot).
67    pub entries_after: usize,
68}
69
70/// Whether a compaction run publishes its reduction or only reports it.
71#[derive(Debug, Clone, Copy, PartialEq, Eq)]
72enum CompactionMode {
73    /// Truncate the retired slot, write the reduced set, and switch the generation record.
74    Execute,
75    /// Compute the same reduction and report the same counts, but touch nothing on disk.
76    PlanOnly,
77}
78
79fn run_ref_pointer_index_compaction(
80    layout: &RepositoryLayout,
81    mode: CompactionMode,
82) -> Result<CompactionReport> {
83    layout.require_current_format()?;
84    let _lock = acquire_container_locks(layout, &[LockableContainer::RefPointerIndex])?;
85    let generation_log_path = layout.ref_pointer_index_generation_log_path();
86    let live_slot = generation::resolve_live_slot(layout, &generation_log_path)?;
87
88    let replay = replay_pointer_index(layout)?;
89    if replay.has_item_failure() {
90        return Err(PrikkError::Integrity(
91            "ref pointer index has a damaged entry; compaction refuses to run on a corrupt \
92             container -- run doctor first"
93                .to_string(),
94        ));
95    }
96    let entries_before = replay.entries.len();
97    let mut compacted: Vec<PointerIndexEntry> = Vec::new();
98    for entry in replay.entries {
99        compacted
100            .retain(|existing: &PointerIndexEntry| existing.ref_name_key != entry.ref_name_key);
101        compacted.push(entry);
102    }
103    let entries_after = compacted.len();
104
105    if mode == CompactionMode::Execute {
106        let target_slot = live_slot.other();
107        let target_relative =
108            layout.repository_relative(&layout.ref_pointer_index_slot_path(target_slot))?;
109        truncate_file_empty_required(layout.repository_mutation_root(), &target_relative)?;
110        let mut buffer = Vec::new();
111        for entry in &compacted {
112            buffer.extend_from_slice(&encode_pointer_index_record(entry)?);
113        }
114        append_file_required(layout.repository_mutation_root(), &target_relative, &buffer)?;
115        generation::append_generation_record(
116            layout,
117            &generation_log_path,
118            &GenerationRecord {
119                live_slot: target_slot,
120            },
121        )?;
122    }
123
124    Ok(CompactionReport {
125        container: LockableContainer::RefPointerIndex,
126        entries_before,
127        entries_after,
128    })
129}
130
131/// Compact the ref-pointer-index container: last entry per `ref_name_key` survives, matching
132/// `lookup_ref_pointer`'s own reverse-scan resolution exactly -- compaction changes which bytes are on
133/// disk, never which pointer a lookup resolves to.
134pub fn compact_ref_pointer_index(layout: &RepositoryLayout) -> Result<CompactionReport> {
135    run_ref_pointer_index_compaction(layout, CompactionMode::Execute)
136}
137
138/// Report what `compact_ref_pointer_index` would reclaim, without writing anything.
139pub fn plan_compact_ref_pointer_index(layout: &RepositoryLayout) -> Result<CompactionReport> {
140    run_ref_pointer_index_compaction(layout, CompactionMode::PlanOnly)
141}
142
143fn run_received_index_compaction(
144    layout: &RepositoryLayout,
145    mode: CompactionMode,
146) -> Result<CompactionReport> {
147    layout.require_current_format()?;
148    let _lock = acquire_container_locks(layout, &[LockableContainer::ReceivedIndex])?;
149    let generation_log_path = layout.received_index_generation_log_path();
150    let live_slot = generation::resolve_live_slot(layout, &generation_log_path)?;
151
152    let replay = replay_received_index(layout)?;
153    if replay.has_item_failure() {
154        return Err(PrikkError::Integrity(
155            "received-ref index has a damaged entry; compaction refuses to run on a corrupt \
156             container -- run doctor first"
157                .to_string(),
158        ));
159    }
160    let entries_before = replay.entries.len();
161    let mut compacted: Vec<ReceivedIndexEntry> = Vec::new();
162    for entry in replay.entries {
163        compacted
164            .retain(|existing: &ReceivedIndexEntry| existing.ref_name_key != entry.ref_name_key);
165        compacted.push(entry);
166    }
167    let entries_after = compacted.len();
168
169    if mode == CompactionMode::Execute {
170        let target_slot = live_slot.other();
171        let target_relative =
172            layout.repository_relative(&layout.received_index_slot_path(target_slot))?;
173        truncate_file_empty_required(layout.repository_mutation_root(), &target_relative)?;
174        let mut buffer = Vec::new();
175        for entry in &compacted {
176            buffer.extend_from_slice(&encode_received_index_record(entry)?);
177        }
178        append_file_required(layout.repository_mutation_root(), &target_relative, &buffer)?;
179        generation::append_generation_record(
180            layout,
181            &generation_log_path,
182            &GenerationRecord {
183                live_slot: target_slot,
184            },
185        )?;
186    }
187
188    Ok(CompactionReport {
189        container: LockableContainer::ReceivedIndex,
190        entries_before,
191        entries_after,
192    })
193}
194
195/// Compact the received-index container: last entry per `ref_name_key` survives, matching
196/// `lookup_received_index_entry`'s own resolution exactly.
197pub fn compact_received_index(layout: &RepositoryLayout) -> Result<CompactionReport> {
198    run_received_index_compaction(layout, CompactionMode::Execute)
199}
200
201/// Report what `compact_received_index` would reclaim, without writing anything.
202pub fn plan_compact_received_index(layout: &RepositoryLayout) -> Result<CompactionReport> {
203    run_received_index_compaction(layout, CompactionMode::PlanOnly)
204}
205
206fn run_trust_policy_compaction(
207    layout: &RepositoryLayout,
208    mode: CompactionMode,
209) -> Result<CompactionReport> {
210    layout.require_current_format()?;
211    let _lock = acquire_container_locks(layout, &[LockableContainer::TrustPolicy])?;
212    let generation_log_path = layout.trust_policy_generation_log_path();
213    let live_slot = generation::resolve_live_slot(layout, &generation_log_path)?;
214
215    let replay = replay_trust_policy(layout)?;
216    if replay.has_item_failure() {
217        return Err(PrikkError::Integrity(
218            "trust policy container has a damaged snapshot; compaction refuses to run on a \
219             corrupt container -- run doctor first"
220                .to_string(),
221        ));
222    }
223    let entries_before = replay.entries.len();
224    let last_snapshot = replay.entries.into_iter().next_back();
225    let entries_after = usize::from(last_snapshot.is_some());
226
227    if mode == CompactionMode::Execute {
228        let target_slot = live_slot.other();
229        let target_relative =
230            layout.repository_relative(&layout.trust_policy_container_slot_path(target_slot))?;
231        truncate_file_empty_required(layout.repository_mutation_root(), &target_relative)?;
232        if let Some(entry) = &last_snapshot {
233            let record = encode_trust_policy_record(entry)?;
234            append_file_required(layout.repository_mutation_root(), &target_relative, &record)?;
235        }
236        generation::append_generation_record(
237            layout,
238            &generation_log_path,
239            &GenerationRecord {
240                live_slot: target_slot,
241            },
242        )?;
243    }
244
245    Ok(CompactionReport {
246        container: LockableContainer::TrustPolicy,
247        entries_before,
248        entries_after,
249    })
250}
251
252/// Compact the trust-policy container: only the last complete snapshot survives -- not a per-key
253/// reduction like the other two, because this container is snapshots, not an append log of individual
254/// adoptions (`trust_index.rs`'s own module doc). Every earlier snapshot is, by definition, entirely
255/// superseded.
256pub fn compact_trust_policy(layout: &RepositoryLayout) -> Result<CompactionReport> {
257    run_trust_policy_compaction(layout, CompactionMode::Execute)
258}
259
260/// Report what `compact_trust_policy` would reclaim, without writing anything.
261pub fn plan_compact_trust_policy(layout: &RepositoryLayout) -> Result<CompactionReport> {
262    run_trust_policy_compaction(layout, CompactionMode::PlanOnly)
263}
264
265#[cfg(test)]
266mod tests;