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
61pub 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
81pub 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
139pub 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}