Skip to main content

qem/document/
compaction.rs

1use super::*;
2
3const DEFAULT_COMPACTION_MIN_TOTAL_BYTES: usize = 1024 * 1024;
4const DEFAULT_COMPACTION_MIN_PIECES: usize = 1024;
5const DEFAULT_COMPACTION_SMALL_PIECE_BYTES: usize = 1024;
6const DEFAULT_COMPACTION_MAX_AVERAGE_PIECE_BYTES: usize = 4096;
7const DEFAULT_COMPACTION_MIN_RATIO: f64 = 0.35;
8const DEFAULT_COMPACTION_FORCED_PIECES: usize = 8192;
9const DEFAULT_COMPACTION_FORCED_RATIO: f64 = 0.50;
10
11/// Policy thresholds used to decide when a piece-table document should be compacted.
12#[derive(Clone, Copy, Debug, PartialEq)]
13pub struct CompactionPolicy {
14    /// Minimum document size before compaction is considered worthwhile.
15    pub min_total_bytes: usize,
16    /// Minimum piece count before broader compaction is considered.
17    pub min_piece_count: usize,
18    /// Pieces at or below this size contribute to fragmentation ratio.
19    pub small_piece_threshold_bytes: usize,
20    /// Maximum average piece size allowed for deferred compaction recommendations.
21    pub max_average_piece_bytes: usize,
22    /// Minimum ratio of small pieces required before deferred compaction is recommended.
23    pub min_fragmentation_ratio: f64,
24    /// Hard piece-count threshold for forced compaction recommendations.
25    pub forced_piece_count: usize,
26    /// Hard fragmentation ratio threshold for forced compaction recommendations.
27    pub forced_fragmentation_ratio: f64,
28}
29
30impl Default for CompactionPolicy {
31    fn default() -> Self {
32        Self {
33            min_total_bytes: DEFAULT_COMPACTION_MIN_TOTAL_BYTES,
34            min_piece_count: DEFAULT_COMPACTION_MIN_PIECES,
35            small_piece_threshold_bytes: DEFAULT_COMPACTION_SMALL_PIECE_BYTES,
36            max_average_piece_bytes: DEFAULT_COMPACTION_MAX_AVERAGE_PIECE_BYTES,
37            min_fragmentation_ratio: DEFAULT_COMPACTION_MIN_RATIO,
38            forced_piece_count: DEFAULT_COMPACTION_FORCED_PIECES,
39            forced_fragmentation_ratio: DEFAULT_COMPACTION_FORCED_RATIO,
40        }
41    }
42}
43
44/// How urgently the engine recommends running a broader compaction pass.
45#[derive(Clone, Copy, Debug, PartialEq, Eq)]
46pub enum CompactionUrgency {
47    /// Compaction is worth scheduling in idle/background time.
48    Deferred,
49    /// Compaction should happen before persistence-sensitive boundaries like save.
50    Forced,
51}
52
53/// A policy decision derived from current piece-table fragmentation metrics.
54#[derive(Clone, Copy, Debug, PartialEq)]
55pub struct CompactionRecommendation {
56    urgency: CompactionUrgency,
57    stats: FragmentationStats,
58}
59
60impl CompactionRecommendation {
61    /// Returns the urgency class for the recommendation.
62    pub fn urgency(self) -> CompactionUrgency {
63        self.urgency
64    }
65
66    /// Returns the fragmentation metrics that triggered the recommendation.
67    pub fn stats(self) -> FragmentationStats {
68        self.stats
69    }
70}
71
72/// Outcome of an idle compaction pass.
73#[derive(Clone, Copy, Debug, PartialEq)]
74pub enum IdleCompactionOutcome {
75    /// No compaction work was needed for the current document state.
76    NotNeeded,
77    /// Deferred compaction ran and rewrote the active piece-table snapshot.
78    Compacted(CompactionRecommendation),
79    /// A hard threshold is pending and should be handled at an explicit save or
80    /// operator-controlled maintenance boundary.
81    ForcedPending(CompactionRecommendation),
82}
83
84impl IdleCompactionOutcome {
85    /// Returns the recommendation associated with this outcome, if any.
86    pub const fn recommendation(self) -> Option<CompactionRecommendation> {
87        match self {
88            Self::NotNeeded => None,
89            Self::Compacted(recommendation) | Self::ForcedPending(recommendation) => {
90                Some(recommendation)
91            }
92        }
93    }
94
95    /// Returns `true` when this idle pass actually compacted the document.
96    pub const fn is_compacted(self) -> bool {
97        matches!(self, Self::Compacted(_))
98    }
99
100    /// Returns `true` when a forced compaction threshold is pending.
101    pub const fn is_forced_pending(self) -> bool {
102        matches!(self, Self::ForcedPending(_))
103    }
104}
105
106impl Document {
107    /// Returns a compaction recommendation for piece-table backed documents.
108    ///
109    /// Local adjacent coalescing already runs on the hot edit path. This API is
110    /// for broader deferred/forced compaction decisions only.
111    pub fn compaction_recommendation(&self) -> Option<CompactionRecommendation> {
112        self.compaction_recommendation_with_policy(CompactionPolicy::default())
113    }
114
115    /// Returns a compaction recommendation using a caller-provided policy.
116    pub fn compaction_recommendation_with_policy(
117        &self,
118        policy: CompactionPolicy,
119    ) -> Option<CompactionRecommendation> {
120        self.piece_table
121            .as_ref()
122            .and_then(|piece_table| piece_table.compaction_recommendation(policy))
123    }
124
125    /// Compacts the current piece-table backed document state into a dense snapshot.
126    ///
127    /// This does not create a new undo step. Older undo history remains intact,
128    /// while the current history entry is rewritten into a compact representation.
129    /// Returns `Ok(false)` when the document is not piece-table backed or is
130    /// already compact enough that no rewrite is needed.
131    pub fn compact_piece_table(&mut self) -> io::Result<bool> {
132        let Some(piece_table) = self.piece_table.as_mut() else {
133            return Ok(false);
134        };
135        piece_table.compact_current_state()
136    }
137
138    /// Compacts the current piece-table state when the provided policy recommends it.
139    ///
140    /// Returns the triggering recommendation when compaction ran.
141    pub fn compact_piece_table_if_recommended(
142        &mut self,
143        policy: CompactionPolicy,
144    ) -> io::Result<Option<CompactionRecommendation>> {
145        let Some(recommendation) = self.compaction_recommendation_with_policy(policy) else {
146            return Ok(None);
147        };
148        if self.compact_piece_table()? {
149            Ok(Some(recommendation))
150        } else {
151            Ok(None)
152        }
153    }
154
155    /// Runs an idle-time compaction pass with the default policy.
156    ///
157    /// This performs only `Deferred` maintenance work. `Forced`
158    /// recommendations are surfaced as [`IdleCompactionOutcome::ForcedPending`]
159    /// so callers can keep heavier compaction tied to explicit maintenance or
160    /// save boundaries.
161    pub fn run_idle_compaction(&mut self) -> io::Result<IdleCompactionOutcome> {
162        self.run_idle_compaction_with_policy(CompactionPolicy::default())
163    }
164
165    /// Runs an idle-time compaction pass using a caller-provided policy.
166    pub fn run_idle_compaction_with_policy(
167        &mut self,
168        policy: CompactionPolicy,
169    ) -> io::Result<IdleCompactionOutcome> {
170        let Some(recommendation) = self.compaction_recommendation_with_policy(policy) else {
171            return Ok(IdleCompactionOutcome::NotNeeded);
172        };
173        if recommendation.urgency() == CompactionUrgency::Forced {
174            return Ok(IdleCompactionOutcome::ForcedPending(recommendation));
175        }
176        if self.compact_piece_table()? {
177            Ok(IdleCompactionOutcome::Compacted(recommendation))
178        } else {
179            Ok(IdleCompactionOutcome::NotNeeded)
180        }
181    }
182}
183
184impl PieceTable {
185    pub(super) fn compact_current_state(&mut self) -> io::Result<bool> {
186        let stats = self.fragmentation_stats();
187        if stats.piece_count <= 1 && self.full_index {
188            return Ok(false);
189        }
190
191        let mut compacted = self.read_range(0, self.total_len);
192        let compacted_len = compacted.len();
193        let line_breaks = count_line_breaks_in_bytes(&compacted);
194        let add_start = self.add.len();
195        self.add.append(&mut compacted);
196
197        let replacement = if compacted_len == 0 {
198            Vec::new()
199        } else {
200            vec![Piece {
201                src: PieceSource::Add,
202                start: add_start,
203                len: compacted_len,
204                line_breaks,
205            }]
206        };
207        self.pieces.replace_current_root_with_pieces(replacement);
208        self.total_len = compacted_len;
209        self.known_byte_len = compacted_len;
210        self.full_index = true;
211        self.refresh_known_line_count();
212        self.schedule_session_flush()?;
213        Ok(true)
214    }
215
216    pub(super) fn compaction_recommendation(
217        &self,
218        policy: CompactionPolicy,
219    ) -> Option<CompactionRecommendation> {
220        if self.total_len < policy.min_total_bytes {
221            return None;
222        }
223
224        let stats = self.fragmentation_stats_with_threshold(policy.small_piece_threshold_bytes);
225        if stats.piece_count < policy.min_piece_count {
226            return None;
227        }
228
229        let ratio = stats.fragmentation_ratio();
230        if stats.piece_count >= policy.forced_piece_count
231            && ratio >= policy.forced_fragmentation_ratio
232        {
233            return Some(CompactionRecommendation {
234                urgency: CompactionUrgency::Forced,
235                stats,
236            });
237        }
238
239        if ratio >= policy.min_fragmentation_ratio
240            && stats.average_piece_bytes() <= policy.max_average_piece_bytes as f64
241        {
242            return Some(CompactionRecommendation {
243                urgency: CompactionUrgency::Deferred,
244                stats,
245            });
246        }
247
248        None
249    }
250}