Skip to main content

powdb_sync/
retention.rs

1use std::fs;
2use std::io;
3use std::path::{Path, PathBuf};
4
5use crate::checkpoint::retained_segments_dir;
6use crate::metadata::{
7    minimum_retained_lsn, read_replica_cursors_unlocked, replace_replica_cursors_unlocked,
8    with_cursor_metadata_lock,
9};
10use crate::segment::{list_segment_files, read_segment_file, SegmentIdentity};
11use crate::{DatabaseIdentity, ReplicaCursor};
12
13#[derive(Debug, Clone, PartialEq, Eq)]
14pub struct RetentionGcSummary {
15    pub retain_from_lsn: Option<u64>,
16    pub segments_deleted: usize,
17    pub bytes_deleted: u64,
18    pub oldest_retained_lsn: Option<u64>,
19    pub newest_retained_lsn: Option<u64>,
20}
21
22#[derive(Debug, Clone, Copy, PartialEq, Eq)]
23pub struct RetentionPressurePolicy {
24    pub max_retained_bytes: Option<u64>,
25    pub inactive_cursor_expiry_secs: Option<u64>,
26    pub operator_retain_from_lsn: Option<u64>,
27    pub now_unix_secs: u64,
28}
29
30impl RetentionPressurePolicy {
31    pub fn conservative(now_unix_secs: u64) -> Self {
32        Self {
33            max_retained_bytes: None,
34            inactive_cursor_expiry_secs: None,
35            operator_retain_from_lsn: None,
36            now_unix_secs,
37        }
38    }
39}
40
41#[derive(Debug, Clone, PartialEq, Eq)]
42pub struct RetentionPressureSummary {
43    pub gc: RetentionGcSummary,
44    pub retained_bytes_before: u64,
45    pub retained_bytes_after: u64,
46    pub max_retained_bytes: Option<u64>,
47    pub max_retained_bytes_exceeded: bool,
48    pub inactive_cursors_retired: Vec<String>,
49    pub override_cursors_retired: Vec<String>,
50}
51
52#[derive(Debug, Clone)]
53struct RetentionCandidate {
54    path: PathBuf,
55    start_lsn: u64,
56    end_lsn: u64,
57    len: u64,
58    delete: bool,
59}
60
61/// Prune retained-unit segments that no active replica can still need.
62///
63/// `minimum_retained_lsn` returns the smallest next-required LSN across active
64/// replicas, so a segment is deletable only when its entire range ends before
65/// that boundary. Segments crossing the boundary are kept intact rather than
66/// rewritten in place.
67pub fn prune_retained_segments_for_cursors(
68    data_dir: &Path,
69    identity: DatabaseIdentity,
70) -> io::Result<RetentionGcSummary> {
71    with_cursor_metadata_lock(data_dir, || {
72        let retain_from_lsn = minimum_retained_lsn(data_dir)?;
73        prune_retained_segments(
74            &retained_segments_dir(data_dir),
75            identity.segment_identity(),
76            retain_from_lsn,
77        )
78    })
79}
80
81/// Prune retained segments with explicit production pressure controls.
82///
83/// This policy never strands an active cursor merely because retained bytes are
84/// over budget. A byte limit is reported as pressure after safe cursor-based
85/// pruning. History can be released beyond active cursors only through explicit
86/// inactive-cursor expiry or an operator retain-boundary override; cursors that
87/// fall behind such a boundary are retired and must rebootstrap.
88pub fn prune_retained_segments_with_policy(
89    data_dir: &Path,
90    identity: DatabaseIdentity,
91    policy: RetentionPressurePolicy,
92) -> io::Result<RetentionPressureSummary> {
93    if matches!(policy.operator_retain_from_lsn, Some(0)) {
94        return Err(invalid_input(
95            "operator retention boundary must be non-zero",
96        ));
97    }
98    let expected_identity = identity.segment_identity();
99    expected_identity.validate()?;
100
101    with_cursor_metadata_lock(data_dir, || {
102        let segment_dir = retained_segments_dir(data_dir);
103        let retained_bytes_before = retained_segment_bytes(&segment_dir)?;
104        let mut cursors = read_replica_cursors_unlocked(data_dir)?;
105        let inactive_cursors_retired = retire_inactive_cursors(&mut cursors, policy);
106        let override_cursors_retired = retire_cursors_behind_override(&mut cursors, policy)?;
107
108        let cursor_boundary = minimum_retained_lsn_for_cursors(&cursors)?;
109        let retain_from_lsn = match (policy.operator_retain_from_lsn, cursor_boundary) {
110            (Some(override_lsn), Some(cursor_lsn)) => Some(override_lsn.max(cursor_lsn)),
111            (Some(override_lsn), None) => Some(override_lsn),
112            (None, cursor_lsn) => cursor_lsn,
113        };
114        let candidates = retention_candidates(&segment_dir, expected_identity, retain_from_lsn)?;
115
116        if !inactive_cursors_retired.is_empty() || !override_cursors_retired.is_empty() {
117            replace_replica_cursors_unlocked(data_dir, cursors.clone())?;
118        }
119
120        let gc = delete_retention_candidates(&segment_dir, retain_from_lsn, &candidates)?;
121        let retained_bytes_after = retained_segment_bytes(&segment_dir)?;
122        let max_retained_bytes_exceeded = policy
123            .max_retained_bytes
124            .map(|max| retained_bytes_after > max)
125            .unwrap_or(false);
126
127        Ok(RetentionPressureSummary {
128            gc,
129            retained_bytes_before,
130            retained_bytes_after,
131            max_retained_bytes: policy.max_retained_bytes,
132            max_retained_bytes_exceeded,
133            inactive_cursors_retired,
134            override_cursors_retired,
135        })
136    })
137}
138
139/// Prune retained-unit segments below an explicit retention boundary.
140///
141/// Passing `None` is a conservative no-op. This prevents accidental history
142/// loss when no active replica cursor exists yet; callers that want to drop all
143/// history need a separate explicit policy.
144pub fn prune_retained_segments(
145    dir: &Path,
146    expected_identity: SegmentIdentity,
147    retain_from_lsn: Option<u64>,
148) -> io::Result<RetentionGcSummary> {
149    let candidates = retention_candidates(dir, expected_identity, retain_from_lsn)?;
150    delete_retention_candidates(dir, retain_from_lsn, &candidates)
151}
152
153fn retention_candidates(
154    dir: &Path,
155    expected_identity: SegmentIdentity,
156    retain_from_lsn: Option<u64>,
157) -> io::Result<Vec<RetentionCandidate>> {
158    expected_identity.validate()?;
159    let files = list_segment_files(dir)?;
160    let mut candidates = Vec::with_capacity(files.len());
161
162    for file in files {
163        let segment = read_segment_file(&file.path)?;
164        if segment.start_lsn != file.start_lsn || segment.end_lsn != file.end_lsn {
165            return Err(invalid_data(format!(
166                "retained segment filename range {}-{} does not match header range {}-{}",
167                file.start_lsn, file.end_lsn, segment.start_lsn, segment.end_lsn
168            )));
169        }
170        if !segment.identity.lineage_matches(expected_identity) {
171            return Err(invalid_data(
172                "retained segment identity does not match expected database history",
173            ));
174        }
175        let len = fs::metadata(&file.path)?.len();
176        let delete = retain_from_lsn
177            .map(|boundary| file.end_lsn < boundary)
178            .unwrap_or(false);
179        candidates.push(RetentionCandidate {
180            path: file.path,
181            start_lsn: file.start_lsn,
182            end_lsn: file.end_lsn,
183            len,
184            delete,
185        });
186    }
187
188    validate_retained_tail(retain_from_lsn, &candidates)?;
189    Ok(candidates)
190}
191
192fn delete_retention_candidates(
193    dir: &Path,
194    retain_from_lsn: Option<u64>,
195    candidates: &[RetentionCandidate],
196) -> io::Result<RetentionGcSummary> {
197    let mut segments_deleted = 0usize;
198    let mut bytes_deleted = 0u64;
199    for candidate in candidates.iter().filter(|candidate| candidate.delete) {
200        fs::remove_file(&candidate.path)?;
201        segments_deleted += 1;
202        bytes_deleted = bytes_deleted
203            .checked_add(candidate.len)
204            .ok_or_else(|| io::Error::other("retained segment deleted byte count overflow"))?;
205    }
206    if segments_deleted > 0 {
207        fsync_dir(dir)?;
208    }
209
210    let retained = candidates.iter().filter(|candidate| !candidate.delete);
211    let mut oldest_retained_lsn = None;
212    let mut newest_retained_lsn = None;
213    for candidate in retained {
214        oldest_retained_lsn = Some(
215            oldest_retained_lsn
216                .map(|current: u64| current.min(candidate.start_lsn))
217                .unwrap_or(candidate.start_lsn),
218        );
219        newest_retained_lsn = Some(
220            newest_retained_lsn
221                .map(|current: u64| current.max(candidate.end_lsn))
222                .unwrap_or(candidate.end_lsn),
223        );
224    }
225
226    Ok(RetentionGcSummary {
227        retain_from_lsn,
228        segments_deleted,
229        bytes_deleted,
230        oldest_retained_lsn,
231        newest_retained_lsn,
232    })
233}
234
235fn retire_inactive_cursors(
236    cursors: &mut [ReplicaCursor],
237    policy: RetentionPressurePolicy,
238) -> Vec<String> {
239    let Some(expiry_secs) = policy.inactive_cursor_expiry_secs else {
240        return Vec::new();
241    };
242    let mut retired = Vec::new();
243    for cursor in cursors.iter_mut().filter(|cursor| cursor.active) {
244        let age = policy
245            .now_unix_secs
246            .saturating_sub(cursor.updated_unix_secs);
247        if age < expiry_secs {
248            continue;
249        }
250        cursor.active = false;
251        cursor.updated_unix_secs = policy.now_unix_secs;
252        retired.push(cursor.replica_id.clone());
253    }
254    retired
255}
256
257fn retire_cursors_behind_override(
258    cursors: &mut [ReplicaCursor],
259    policy: RetentionPressurePolicy,
260) -> io::Result<Vec<String>> {
261    let Some(boundary) = policy.operator_retain_from_lsn else {
262        return Ok(Vec::new());
263    };
264    let mut retired = Vec::new();
265    for cursor in cursors.iter_mut().filter(|cursor| cursor.active) {
266        if cursor.next_required_lsn()? >= boundary {
267            continue;
268        }
269        cursor.active = false;
270        cursor.updated_unix_secs = policy.now_unix_secs;
271        retired.push(cursor.replica_id.clone());
272    }
273    Ok(retired)
274}
275
276fn minimum_retained_lsn_for_cursors(cursors: &[ReplicaCursor]) -> io::Result<Option<u64>> {
277    let mut min_lsn: Option<u64> = None;
278    for cursor in cursors {
279        if !cursor.active {
280            continue;
281        }
282        let next_lsn = cursor.next_required_lsn()?;
283        min_lsn = Some(match min_lsn {
284            Some(current) => current.min(next_lsn),
285            None => next_lsn,
286        });
287    }
288    Ok(min_lsn)
289}
290
291fn retained_segment_bytes(dir: &Path) -> io::Result<u64> {
292    let mut total = 0u64;
293    for file in list_segment_files(dir)? {
294        let len = fs::metadata(file.path)?.len();
295        total = total
296            .checked_add(len)
297            .ok_or_else(|| io::Error::other("retained segment byte count overflow"))?;
298    }
299    Ok(total)
300}
301
302fn validate_retained_tail(
303    retain_from_lsn: Option<u64>,
304    candidates: &[RetentionCandidate],
305) -> io::Result<()> {
306    let Some(boundary) = retain_from_lsn else {
307        return Ok(());
308    };
309
310    let mut retained = candidates
311        .iter()
312        .filter(|candidate| candidate.end_lsn >= boundary);
313    let Some(first) = retained.next() else {
314        return Ok(());
315    };
316
317    if first.start_lsn > boundary {
318        return Err(invalid_data(format!(
319            "retained segment gap at retention boundary: expected LSN {}, found {}",
320            boundary, first.start_lsn
321        )));
322    }
323
324    let mut expected_next_lsn = first
325        .end_lsn
326        .checked_add(1)
327        .ok_or_else(|| invalid_data("retained segment LSN overflow"))?;
328    for candidate in retained {
329        if candidate.start_lsn < expected_next_lsn {
330            return Err(invalid_data(format!(
331                "retained segment overlap after retention boundary: expected LSN {}, found {}",
332                expected_next_lsn, candidate.start_lsn
333            )));
334        }
335        if candidate.start_lsn > expected_next_lsn {
336            return Err(invalid_data(format!(
337                "retained segment gap after retention boundary: expected LSN {}, found {}",
338                expected_next_lsn, candidate.start_lsn
339            )));
340        }
341        expected_next_lsn = candidate
342            .end_lsn
343            .checked_add(1)
344            .ok_or_else(|| invalid_data("retained segment LSN overflow"))?;
345    }
346    Ok(())
347}
348
349#[cfg(unix)]
350fn fsync_dir(dir: &Path) -> io::Result<()> {
351    fs::File::open(dir)?.sync_all()
352}
353
354#[cfg(not(unix))]
355fn fsync_dir(_dir: &Path) -> io::Result<()> {
356    Ok(())
357}
358
359fn invalid_data(message: impl Into<String>) -> io::Error {
360    io::Error::new(io::ErrorKind::InvalidData, message.into())
361}
362
363fn invalid_input(message: impl Into<String>) -> io::Error {
364    io::Error::new(io::ErrorKind::InvalidInput, message.into())
365}
366
367#[cfg(test)]
368mod tests {
369    use super::*;
370    use crate::metadata::{upsert_replica_cursor, ReplicaCursor};
371    use crate::segment::{
372        read_units_since, segment_file_name, write_segment_atomic, RetainedSegment, RetainedUnit,
373    };
374
375    fn database_identity() -> DatabaseIdentity {
376        DatabaseIdentity {
377            database_id: *b"0123456789abcdef",
378            primary_generation: 3,
379        }
380    }
381
382    fn unit(lsn: u64) -> RetainedUnit {
383        RetainedUnit {
384            tx_id: 1,
385            record_type: 1,
386            lsn,
387            data: vec![lsn as u8],
388        }
389    }
390
391    fn write_segment(dir: &Path, start_lsn: u64, end_lsn: u64) {
392        let units = (start_lsn..=end_lsn).map(unit).collect();
393        let segment = RetainedSegment::new(database_identity().segment_identity(), units).unwrap();
394        write_segment_atomic(dir, &segment).unwrap();
395    }
396
397    #[test]
398    fn prunes_only_segments_fully_below_active_cursor_boundary() {
399        let data_dir = tempfile::tempdir().unwrap();
400        let segment_dir = retained_segments_dir(data_dir.path());
401        write_segment(&segment_dir, 1, 5);
402        write_segment(&segment_dir, 6, 10);
403        write_segment(&segment_dir, 11, 15);
404
405        upsert_replica_cursor(data_dir.path(), ReplicaCursor::active("replica-a", 10)).unwrap();
406        let summary = prune_retained_segments_for_cursors(data_dir.path(), database_identity())
407            .expect("GC should succeed");
408
409        assert_eq!(summary.retain_from_lsn, Some(11));
410        assert_eq!(summary.segments_deleted, 2);
411        assert!(summary.bytes_deleted > 0);
412        assert_eq!(summary.oldest_retained_lsn, Some(11));
413        assert_eq!(summary.newest_retained_lsn, Some(15));
414
415        let remaining = list_segment_files(&segment_dir).unwrap();
416        assert_eq!(remaining.len(), 1);
417        assert_eq!(remaining[0].start_lsn, 11);
418        assert_eq!(remaining[0].end_lsn, 15);
419
420        let units = read_units_since(
421            &segment_dir,
422            database_identity().segment_identity(),
423            10,
424            100,
425        )
426        .unwrap();
427        let lsns: Vec<u64> = units.into_iter().map(|unit| unit.lsn).collect();
428        assert_eq!(lsns, vec![11, 12, 13, 14, 15]);
429    }
430
431    #[test]
432    fn keeps_segment_that_crosses_retention_boundary() {
433        let data_dir = tempfile::tempdir().unwrap();
434        let segment_dir = retained_segments_dir(data_dir.path());
435        write_segment(&segment_dir, 1, 20);
436        write_segment(&segment_dir, 21, 25);
437
438        let summary = prune_retained_segments(
439            &segment_dir,
440            database_identity().segment_identity(),
441            Some(11),
442        )
443        .unwrap();
444
445        assert_eq!(summary.segments_deleted, 0);
446        assert_eq!(summary.oldest_retained_lsn, Some(1));
447        assert_eq!(summary.newest_retained_lsn, Some(25));
448        assert_eq!(list_segment_files(&segment_dir).unwrap().len(), 2);
449    }
450
451    #[test]
452    fn no_active_cursor_boundary_is_noop() {
453        let data_dir = tempfile::tempdir().unwrap();
454        let segment_dir = retained_segments_dir(data_dir.path());
455        write_segment(&segment_dir, 1, 5);
456
457        let summary =
458            prune_retained_segments_for_cursors(data_dir.path(), database_identity()).unwrap();
459
460        assert_eq!(summary.retain_from_lsn, None);
461        assert_eq!(summary.segments_deleted, 0);
462        assert_eq!(summary.oldest_retained_lsn, Some(1));
463        assert_eq!(summary.newest_retained_lsn, Some(5));
464        assert_eq!(list_segment_files(&segment_dir).unwrap().len(), 1);
465    }
466
467    #[test]
468    fn corrupt_or_mismatched_segments_block_gc_before_deleting() {
469        let data_dir = tempfile::tempdir().unwrap();
470        let segment_dir = retained_segments_dir(data_dir.path());
471        write_segment(&segment_dir, 1, 5);
472        write_segment(&segment_dir, 11, 15);
473
474        let segment = RetainedSegment::new(
475            database_identity().segment_identity(),
476            (21..=25).map(unit).collect(),
477        )
478        .unwrap();
479        fs::write(
480            segment_dir.join(segment_file_name(21, 26)),
481            segment.to_bytes().unwrap(),
482        )
483        .unwrap();
484
485        let err = prune_retained_segments(
486            &segment_dir,
487            database_identity().segment_identity(),
488            Some(11),
489        )
490        .unwrap_err();
491        assert_eq!(err.kind(), io::ErrorKind::InvalidData);
492        assert!(err.to_string().contains("filename range"));
493
494        let remaining = list_segment_files(&segment_dir).unwrap();
495        assert_eq!(
496            remaining.len(),
497            3,
498            "GC must validate the full candidate set before deleting anything"
499        );
500    }
501
502    #[test]
503    fn gap_at_retention_boundary_blocks_gc_before_deleting() {
504        let data_dir = tempfile::tempdir().unwrap();
505        let segment_dir = retained_segments_dir(data_dir.path());
506        write_segment(&segment_dir, 1, 5);
507        write_segment(&segment_dir, 13, 15);
508
509        let err = prune_retained_segments(
510            &segment_dir,
511            database_identity().segment_identity(),
512            Some(11),
513        )
514        .unwrap_err();
515        assert_eq!(err.kind(), io::ErrorKind::InvalidData);
516        assert!(err.to_string().contains("gap"));
517
518        let remaining = list_segment_files(&segment_dir).unwrap();
519        assert_eq!(
520            remaining.len(),
521            2,
522            "GC must not delete older segments when the retained tail is already gapped"
523        );
524    }
525
526    #[test]
527    fn invalid_identity_is_rejected_even_without_segments() {
528        let data_dir = tempfile::tempdir().unwrap();
529        let err = prune_retained_segments(
530            &retained_segments_dir(data_dir.path()),
531            SegmentIdentity::current([0; 16], 1),
532            Some(1),
533        )
534        .unwrap_err();
535        assert_eq!(err.kind(), io::ErrorKind::InvalidData);
536    }
537}