nautilus_event_store/
retention.rs1use std::{
19 collections::BTreeSet,
20 path::{Path, PathBuf},
21};
22
23use nautilus_system::event_store::RetentionMode;
24
25use crate::{EventStore, EventStoreError, RedbBackend, RunManifest, RunStatus, SnapshotAnchor};
26
27#[derive(Clone, Debug, PartialEq, Eq)]
29pub struct RetentionRun {
30 pub manifest: RunManifest,
32 pub path: PathBuf,
34 pub snapshot_anchor: SnapshotAnchorStatus,
36}
37
38impl RetentionRun {
39 #[must_use]
41 pub fn new(
42 manifest: RunManifest,
43 path: impl Into<PathBuf>,
44 snapshot_anchor: SnapshotAnchorStatus,
45 ) -> Self {
46 Self {
47 manifest,
48 path: path.into(),
49 snapshot_anchor,
50 }
51 }
52
53 #[must_use]
55 pub fn run_id(&self) -> &str {
56 self.manifest.run_id.as_str()
57 }
58
59 #[must_use]
61 pub fn is_known_good_restore_point(&self) -> bool {
62 !matches!(
63 self.manifest.status,
64 RunStatus::Running | RunStatus::Quarantined
65 ) && matches!(&self.snapshot_anchor, SnapshotAnchorStatus::Valid(_))
66 }
67}
68
69#[derive(Clone, Debug, PartialEq, Eq)]
71pub enum SnapshotAnchorStatus {
72 Missing,
74 Valid(SnapshotAnchor),
76 Invalid(String),
78}
79
80#[derive(Clone, Debug, Default, PartialEq, Eq)]
82pub struct RetentionPlan {
83 pub sealed_runs: Vec<RetentionRun>,
85 pub reclaim_candidates: Vec<RetentionRun>,
87}
88
89pub fn plan_redb_retention(
96 base_dir: &Path,
97 instance_id: &str,
98 mode: RetentionMode,
99) -> Result<RetentionPlan, EventStoreError> {
100 Ok(plan_retention(
101 list_redb_sealed_runs(base_dir, instance_id)?,
102 mode,
103 ))
104}
105
106pub fn list_redb_sealed_runs(
115 base_dir: &Path,
116 instance_id: &str,
117) -> Result<Vec<RetentionRun>, EventStoreError> {
118 let manifests = RedbBackend::list_runs(base_dir, instance_id)?;
119 let mut runs = Vec::new();
120
121 for manifest in manifests {
122 if !manifest.is_sealed() {
123 continue;
124 }
125
126 let path = base_dir
127 .join(instance_id)
128 .join(format!("{}.redb", manifest.run_id));
129 let reader = RedbBackend::open_sealed(base_dir, instance_id, manifest.run_id.as_str())?;
130 let durable_high_watermark = reader.high_watermark()?;
131 let snapshot_anchor = match reader.latest_snapshot_anchor() {
132 Ok(anchor) => snapshot_anchor_status(durable_high_watermark, anchor),
133 Err(EventStoreError::Corrupted(msg)) => SnapshotAnchorStatus::Invalid(msg),
134 Err(e) => return Err(e),
135 };
136
137 runs.push(RetentionRun::new(manifest, path, snapshot_anchor));
138 }
139
140 Ok(runs)
141}
142
143#[must_use]
145pub fn plan_retention(mut sealed_runs: Vec<RetentionRun>, mode: RetentionMode) -> RetentionPlan {
146 sealed_runs.retain(|run| run.manifest.is_sealed());
147 sealed_runs.sort_by_key(|run| run.manifest.start_ts_init);
148
149 let reclaim_candidates = match mode {
150 RetentionMode::Full => Vec::new(),
151 RetentionMode::Bounded { keep_last } => bounded_reclaim_candidates(&sealed_runs, keep_last),
152 RetentionMode::SnapshotAnchored => snapshot_anchored_reclaim_candidates(&sealed_runs),
153 };
154
155 RetentionPlan {
156 sealed_runs,
157 reclaim_candidates,
158 }
159}
160
161fn bounded_reclaim_candidates(sealed_runs: &[RetentionRun], keep_last: usize) -> Vec<RetentionRun> {
162 let Some(latest_restore_point) = latest_known_good_restore_point(sealed_runs) else {
163 return Vec::new();
164 };
165
166 let keep_last = keep_last.min(sealed_runs.len());
167 let mut retained = BTreeSet::new();
168 retained.insert(latest_restore_point);
169
170 if keep_last > 0 {
171 for index in sealed_runs.len() - keep_last..sealed_runs.len() {
172 retained.insert(index);
173 }
174 }
175
176 sealed_runs
177 .iter()
178 .enumerate()
179 .filter(|(index, _)| !retained.contains(index))
180 .map(|(_, run)| run.clone())
181 .collect()
182}
183
184fn snapshot_anchored_reclaim_candidates(sealed_runs: &[RetentionRun]) -> Vec<RetentionRun> {
185 let Some(latest_restore_point) = latest_known_good_restore_point(sealed_runs) else {
186 return Vec::new();
187 };
188
189 sealed_runs[..latest_restore_point].to_vec()
190}
191
192fn latest_known_good_restore_point(sealed_runs: &[RetentionRun]) -> Option<usize> {
193 sealed_runs
194 .iter()
195 .rposition(RetentionRun::is_known_good_restore_point)
196}
197
198fn snapshot_anchor_status(
199 durable_high_watermark: u64,
200 anchor: Option<SnapshotAnchor>,
201) -> SnapshotAnchorStatus {
202 let Some(anchor) = anchor else {
203 return SnapshotAnchorStatus::Missing;
204 };
205
206 if anchor.high_watermark <= durable_high_watermark {
210 return SnapshotAnchorStatus::Valid(anchor);
211 }
212
213 SnapshotAnchorStatus::Invalid(format!(
214 "snapshot anchor high_watermark {} exceeds durable high_watermark {durable_high_watermark}",
215 anchor.high_watermark,
216 ))
217}
218
219#[cfg(test)]
220mod tests {
221 use rstest::rstest;
222
223 use super::*;
224
225 #[rstest]
226 fn snapshot_anchor_status_rejects_anchor_past_durable_watermark() {
227 let status = snapshot_anchor_status(
228 1,
229 Some(SnapshotAnchor::new(2, "cache://snapshots/2", "blake3:abc")),
230 );
231
232 match status {
233 SnapshotAnchorStatus::Invalid(msg) => {
234 assert!(
235 msg.contains("exceeds durable high_watermark"),
236 "msg was: {msg}",
237 );
238 }
239 other => panic!("expected Invalid, was {other:?}"),
240 }
241 }
242}