1use serde::{Deserialize, Serialize};
7use thiserror::Error;
8
9const CRC32_TABLE: [u32; 256] = build_crc32_table();
15
16const fn build_crc32_table() -> [u32; 256] {
17 let mut table = [0u32; 256];
18 let mut i = 0usize;
19 while i < 256 {
20 let mut crc = i as u32;
21 let mut j = 0;
22 while j < 8 {
23 if crc & 1 != 0 {
24 crc = (crc >> 1) ^ 0xEDB8_8320u32;
25 } else {
26 crc >>= 1;
27 }
28 j += 1;
29 }
30 table[i] = crc;
31 i += 1;
32 }
33 table
34}
35
36pub fn crc32(data: &[u8]) -> u32 {
44 let mut crc: u32 = 0xFFFF_FFFF;
45 for &byte in data {
46 let idx = ((crc ^ u32::from(byte)) & 0xFF) as usize;
47 crc = (crc >> 8) ^ CRC32_TABLE[idx];
48 }
49 crc ^ 0xFFFF_FFFF
50}
51
52#[derive(Debug, Error, PartialEq, Eq)]
58pub enum ValidationError {
59 #[error("CRC-32 mismatch: expected 0x{expected:08X}, actual 0x{actual:08X}")]
61 CrcMismatch { expected: u32, actual: u32 },
62
63 #[error("size mismatch: expected {expected} bytes, actual {actual} bytes")]
65 SizeMismatch { expected: u64, actual: u64 },
66}
67
68#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
74pub struct CheckpointRecord {
75 pub id: String,
77 pub cid: String,
79 pub round: u64,
81 pub created_at_ms: u64,
83 pub size_bytes: u64,
85 pub crc32: u32,
87 pub is_pinned: bool,
90}
91
92#[derive(Debug, Clone, Serialize, Deserialize)]
98pub struct RetentionPolicy {
99 pub keep_last_n: usize,
102 pub keep_pinned: bool,
104 pub max_total_bytes: Option<u64>,
109 pub min_age_ms: u64,
112}
113
114impl Default for RetentionPolicy {
115 fn default() -> Self {
116 Self {
117 keep_last_n: 5,
118 keep_pinned: true,
119 max_total_bytes: None,
120 min_age_ms: 0,
121 }
122 }
123}
124
125pub struct CheckpointPruner {
149 records: Vec<CheckpointRecord>,
151 policy: RetentionPolicy,
153 now_ms: u64,
157}
158
159impl CheckpointPruner {
160 pub fn new(mut records: Vec<CheckpointRecord>, policy: RetentionPolicy) -> Self {
162 records.sort_by_key(|r| r.round);
163 Self {
164 records,
165 policy,
166 now_ms: 0,
167 }
168 }
169
170 pub fn with_now_ms(mut self, now_ms: u64) -> Self {
175 self.now_ms = now_ms;
176 self
177 }
178
179 pub fn prune(&mut self) -> Vec<CheckpointRecord> {
184 let n = self.records.len();
187 let mut keep = vec![false; n];
188
189 let keep_last_n = self.policy.keep_last_n;
193 if keep_last_n > 0 && n > 0 {
194 let start = n.saturating_sub(keep_last_n);
195 for k in &mut keep[start..] {
196 *k = true;
197 }
198 }
199
200 if self.policy.keep_pinned {
202 for (k, rec) in keep.iter_mut().zip(self.records.iter()) {
203 if rec.is_pinned {
204 *k = true;
205 }
206 }
207 }
208
209 for (k, rec) in keep.iter_mut().zip(self.records.iter()) {
212 let age_ms = self.now_ms.saturating_sub(rec.created_at_ms);
213 if age_ms < self.policy.min_age_ms {
214 *k = true;
215 }
216 }
217
218 if let Some(budget) = self.policy.max_total_bytes {
222 let survivor_bytes: u64 = self
225 .records
226 .iter()
227 .enumerate()
228 .filter(|(i, _)| keep[*i])
229 .map(|(_, r)| r.size_bytes)
230 .sum();
231
232 if survivor_bytes > budget {
233 let mut excess = survivor_bytes - budget;
234 let evict_flags: Vec<bool> = keep
239 .iter()
240 .zip(self.records.iter())
241 .map(|(k, rec)| {
242 if !k {
243 return false; }
245 if self.policy.keep_pinned && rec.is_pinned {
246 return false;
247 }
248 let age_ms = self.now_ms.saturating_sub(rec.created_at_ms);
249 if age_ms < self.policy.min_age_ms {
250 return false;
251 }
252 true })
254 .collect();
255
256 for (flag, (k, rec)) in evict_flags
257 .iter()
258 .zip(keep.iter_mut().zip(self.records.iter()))
259 {
260 if excess == 0 {
261 break;
262 }
263 if *flag {
264 *k = false;
265 excess = excess.saturating_sub(rec.size_bytes);
266 }
267 }
268 }
269 }
270
271 let mut to_delete = Vec::new();
274 let mut survivors = Vec::new();
275 for (i, rec) in self.records.drain(..).enumerate() {
276 if keep[i] {
277 survivors.push(rec);
278 } else {
279 to_delete.push(rec);
280 }
281 }
282 self.records = survivors;
283 to_delete
284 }
285
286 pub fn surviving_count(&self) -> usize {
288 self.records.len()
289 }
290
291 pub fn total_bytes(&self) -> u64 {
293 self.records.iter().map(|r| r.size_bytes).sum()
294 }
295
296 pub fn pinned_count(&self) -> usize {
298 self.records.iter().filter(|r| r.is_pinned).count()
299 }
300
301 pub fn records(&self) -> &[CheckpointRecord] {
303 &self.records
304 }
305}
306
307pub struct CheckpointValidator;
313
314impl CheckpointValidator {
315 pub fn compute_crc32(data: &[u8]) -> u32 {
317 crc32(data)
318 }
319
320 pub fn validate(data: &[u8], expected_crc32: u32) -> Result<(), ValidationError> {
322 let actual = crc32(data);
323 if actual != expected_crc32 {
324 return Err(ValidationError::CrcMismatch {
325 expected: expected_crc32,
326 actual,
327 });
328 }
329 Ok(())
330 }
331
332 pub fn validate_record(record: &CheckpointRecord, data: &[u8]) -> Result<(), ValidationError> {
336 let actual_crc = crc32(data);
338 if actual_crc != record.crc32 {
339 return Err(ValidationError::CrcMismatch {
340 expected: record.crc32,
341 actual: actual_crc,
342 });
343 }
344 let actual_size = data.len() as u64;
346 if actual_size != record.size_bytes {
347 return Err(ValidationError::SizeMismatch {
348 expected: record.size_bytes,
349 actual: actual_size,
350 });
351 }
352 Ok(())
353 }
354}
355
356#[cfg(test)]
361mod tests {
362 use super::*;
363
364 fn make_record(id: &str, round: u64, size_bytes: u64, is_pinned: bool) -> CheckpointRecord {
367 CheckpointRecord {
368 id: id.to_string(),
369 cid: format!("Qm{round}"),
370 round,
371 created_at_ms: round * 1000, size_bytes,
373 crc32: 0,
374 is_pinned,
375 }
376 }
377
378 fn make_record_timed(
379 id: &str,
380 round: u64,
381 size_bytes: u64,
382 is_pinned: bool,
383 created_at_ms: u64,
384 ) -> CheckpointRecord {
385 CheckpointRecord {
386 id: id.to_string(),
387 cid: format!("Qm{round}"),
388 round,
389 created_at_ms,
390 size_bytes,
391 crc32: 0,
392 is_pinned,
393 }
394 }
395
396 #[test]
401 fn test_crc32_known_vector() {
402 assert_eq!(crc32(b"123456789"), 0xCBF4_3926);
404 }
405
406 #[test]
407 fn test_crc32_empty() {
408 assert_eq!(crc32(b""), 0x0000_0000);
410 }
411
412 #[test]
413 fn test_crc32_single_byte() {
414 let v = crc32(b"a");
416 assert_ne!(v, 0); }
418
419 #[test]
420 fn test_crc32_deterministic() {
421 let data = b"hello, checkpoint!";
422 assert_eq!(crc32(data), crc32(data));
423 }
424
425 #[test]
426 fn test_crc32_sensitive_to_changes() {
427 let a = crc32(b"hello");
428 let b = crc32(b"hellp"); assert_ne!(a, b);
430 }
431
432 #[test]
437 fn test_validate_passes_for_correct_data() {
438 let data = b"gradient checkpoint payload";
439 let checksum = crc32(data);
440 assert!(CheckpointValidator::validate(data, checksum).is_ok());
441 }
442
443 #[test]
444 fn test_validate_fails_for_corrupted_data() {
445 let data = b"gradient checkpoint payload";
446 let checksum = crc32(data);
447 let mut corrupted = data.to_vec();
448 corrupted[0] ^= 0xFF; let err = CheckpointValidator::validate(&corrupted, checksum).unwrap_err();
450 assert!(matches!(err, ValidationError::CrcMismatch { .. }));
451 }
452
453 #[test]
454 fn test_validate_record_passes() {
455 let data = b"some checkpoint data";
456 let record = CheckpointRecord {
457 id: "cp_ok".into(),
458 cid: "Qmabc".into(),
459 round: 1,
460 created_at_ms: 0,
461 size_bytes: data.len() as u64,
462 crc32: crc32(data),
463 is_pinned: false,
464 };
465 assert!(CheckpointValidator::validate_record(&record, data).is_ok());
466 }
467
468 #[test]
469 fn test_validate_record_crc_mismatch() {
470 let data = b"some checkpoint data";
471 let record = CheckpointRecord {
472 id: "cp_bad_crc".into(),
473 cid: "Qmabc".into(),
474 round: 1,
475 created_at_ms: 0,
476 size_bytes: data.len() as u64,
477 crc32: 0xDEAD_BEEF, is_pinned: false,
479 };
480 let err = CheckpointValidator::validate_record(&record, data).unwrap_err();
481 assert!(matches!(err, ValidationError::CrcMismatch { .. }));
482 }
483
484 #[test]
485 fn test_validate_record_size_mismatch() {
486 let data = b"some checkpoint data";
487 let record = CheckpointRecord {
488 id: "cp_bad_size".into(),
489 cid: "Qmabc".into(),
490 round: 1,
491 created_at_ms: 0,
492 size_bytes: 9999, crc32: crc32(data),
494 is_pinned: false,
495 };
496 let err = CheckpointValidator::validate_record(&record, data).unwrap_err();
497 assert!(matches!(err, ValidationError::SizeMismatch { .. }));
498 }
499
500 #[test]
501 fn test_compute_crc32_matches_standalone() {
502 let data = b"cross-check";
503 assert_eq!(CheckpointValidator::compute_crc32(data), crc32(data));
504 }
505
506 #[test]
511 fn test_keep_last_n_trims_oldest() {
512 let records: Vec<CheckpointRecord> = (1..=5)
513 .map(|r| make_record(&format!("cp_{r}"), r, 100, false))
514 .collect();
515 let policy = RetentionPolicy {
516 keep_last_n: 3,
517 ..Default::default()
518 };
519 let mut pruner = CheckpointPruner::new(records, policy);
520 let deleted = pruner.prune();
521 assert_eq!(deleted.len(), 2);
523 let deleted_rounds: Vec<u64> = deleted.iter().map(|r| r.round).collect();
524 assert!(deleted_rounds.contains(&1));
525 assert!(deleted_rounds.contains(&2));
526 assert_eq!(pruner.surviving_count(), 3);
527 }
528
529 #[test]
530 fn test_keep_pinned_preserves_old_checkpoints() {
531 let mut records: Vec<CheckpointRecord> = (1..=5)
532 .map(|r| make_record(&format!("cp_{r}"), r, 100, false))
533 .collect();
534 records[0].is_pinned = true;
536
537 let policy = RetentionPolicy {
538 keep_last_n: 3,
539 keep_pinned: true,
540 ..Default::default()
541 };
542 let mut pruner = CheckpointPruner::new(records, policy);
543 let deleted = pruner.prune();
544 assert_eq!(deleted.len(), 1);
546 assert_eq!(deleted[0].round, 2);
547 assert_eq!(pruner.surviving_count(), 4);
548 assert_eq!(pruner.pinned_count(), 1);
549 }
550
551 #[test]
552 fn test_keep_pinned_false_allows_pruning_pinned() {
553 let mut records: Vec<CheckpointRecord> = (1..=5)
554 .map(|r| make_record(&format!("cp_{r}"), r, 100, false))
555 .collect();
556 records[0].is_pinned = true;
557
558 let policy = RetentionPolicy {
559 keep_last_n: 3,
560 keep_pinned: false,
561 ..Default::default()
562 };
563 let mut pruner = CheckpointPruner::new(records, policy);
564 let deleted = pruner.prune();
565 assert_eq!(deleted.len(), 2);
566 assert_eq!(pruner.surviving_count(), 3);
567 }
568
569 #[test]
570 fn test_max_total_bytes_budget_enforcement() {
571 let records: Vec<CheckpointRecord> = (1..=5)
574 .map(|r| make_record(&format!("cp_{r}"), r, 100, false))
575 .collect();
576 let policy = RetentionPolicy {
577 keep_last_n: 5, keep_pinned: true,
579 max_total_bytes: Some(250),
580 min_age_ms: 0,
581 };
582 let mut pruner = CheckpointPruner::new(records, policy);
583 let deleted = pruner.prune();
584 assert!(pruner.total_bytes() <= 250);
589 assert!(!deleted.is_empty());
590 }
591
592 #[test]
593 fn test_min_age_ms_protects_young_checkpoints() {
594 let now_ms: u64 = 100_000;
605 let records = vec![
606 make_record_timed("cp_1", 1, 100, false, 0),
607 make_record_timed("cp_2", 2, 100, false, 1_000),
608 make_record_timed("cp_3", 3, 100, false, 2_000),
609 make_record_timed("cp_4", 4, 100, false, 80_000),
610 ];
611
612 let policy = RetentionPolicy {
613 keep_last_n: 1,
614 keep_pinned: true,
615 max_total_bytes: None,
616 min_age_ms: 50_000,
617 };
618 let mut pruner = CheckpointPruner::new(records, policy).with_now_ms(now_ms);
619 let deleted = pruner.prune();
620
621 let surviving_rounds: Vec<u64> = pruner.records().iter().map(|r| r.round).collect();
622 assert!(
623 surviving_rounds.contains(&4),
624 "round 4 must survive (keep_last_n)"
625 );
626
627 assert_eq!(deleted.len(), 3, "rounds 1, 2, 3 should be deleted");
629 }
630
631 #[test]
632 fn test_min_age_ms_protects_within_window() {
633 let now_ms: u64 = 10_000;
634 let records = vec![
636 make_record_timed("cp_1", 1, 100, false, 0),
637 make_record_timed("cp_2", 2, 100, false, 0),
638 make_record_timed("cp_young", 3, 100, false, 8_000), ];
640 let policy = RetentionPolicy {
641 keep_last_n: 1,
642 keep_pinned: true,
643 max_total_bytes: None,
644 min_age_ms: 5_000, };
646 let mut pruner = CheckpointPruner::new(records, policy).with_now_ms(now_ms);
647 let _deleted = pruner.prune();
648 let surviving: Vec<u64> = pruner.records().iter().map(|r| r.round).collect();
649 assert!(surviving.contains(&3), "young checkpoint must survive");
651 }
652
653 #[test]
654 fn test_combined_keep_last_n_and_keep_pinned() {
655 let mut records: Vec<CheckpointRecord> = (1..=6)
659 .map(|r| make_record(&format!("cp_{r}"), r, 100, false))
660 .collect();
661 records[0].is_pinned = true; records[2].is_pinned = true; let policy = RetentionPolicy {
665 keep_last_n: 2,
666 keep_pinned: true,
667 max_total_bytes: None,
668 min_age_ms: 0,
669 };
670 let mut pruner = CheckpointPruner::new(records, policy);
671 let deleted = pruner.prune();
672
673 let deleted_rounds: Vec<u64> = deleted.iter().map(|r| r.round).collect();
674 assert!(deleted_rounds.contains(&2), "round 2 must be deleted");
675 assert!(deleted_rounds.contains(&4), "round 4 must be deleted");
676 assert_eq!(deleted.len(), 2, "exactly 2 records should be deleted");
677
678 let surviving: Vec<u64> = pruner.records().iter().map(|r| r.round).collect();
679 assert!(surviving.contains(&1), "pinned round 1 must survive");
680 assert!(surviving.contains(&3), "pinned round 3 must survive");
681 assert!(surviving.contains(&5), "keep_last_n round 5 must survive");
682 assert!(surviving.contains(&6), "keep_last_n round 6 must survive");
683 }
684
685 #[test]
686 fn test_prune_returns_correct_delete_set() {
687 let records: Vec<CheckpointRecord> = (1..=4)
688 .map(|r| make_record(&format!("cp_{r}"), r, 50, false))
689 .collect();
690 let policy = RetentionPolicy {
691 keep_last_n: 2,
692 ..Default::default()
693 };
694 let mut pruner = CheckpointPruner::new(records, policy);
695 let deleted = pruner.prune();
696 assert_eq!(deleted.len(), 2);
697 let ids: Vec<&str> = deleted.iter().map(|r| r.id.as_str()).collect();
698 assert!(ids.contains(&"cp_1"));
699 assert!(ids.contains(&"cp_2"));
700 }
701
702 #[test]
703 fn test_surviving_count_and_total_bytes() {
704 let records: Vec<CheckpointRecord> = (1..=6)
705 .map(|r| make_record(&format!("cp_{r}"), r, 200, false))
706 .collect();
707 let policy = RetentionPolicy {
708 keep_last_n: 4,
709 ..Default::default()
710 };
711 let mut pruner = CheckpointPruner::new(records, policy);
712 pruner.prune();
713 assert_eq!(pruner.surviving_count(), 4);
714 assert_eq!(pruner.total_bytes(), 4 * 200);
715 }
716
717 #[test]
718 fn test_prune_idempotent_after_first_call() {
719 let records: Vec<CheckpointRecord> = (1..=5)
720 .map(|r| make_record(&format!("cp_{r}"), r, 100, false))
721 .collect();
722 let policy = RetentionPolicy {
723 keep_last_n: 3,
724 ..Default::default()
725 };
726 let mut pruner = CheckpointPruner::new(records, policy);
727 let first = pruner.prune();
728 let second = pruner.prune();
729 assert_eq!(first.len(), 2);
730 assert!(second.is_empty(), "second prune should delete nothing");
731 }
732
733 #[test]
734 fn test_pinned_count_accuracy() {
735 let mut records: Vec<CheckpointRecord> = (1..=5)
736 .map(|r| make_record(&format!("cp_{r}"), r, 100, false))
737 .collect();
738 records[1].is_pinned = true;
739 records[3].is_pinned = true;
740 let policy = RetentionPolicy::default();
741 let pruner = CheckpointPruner::new(records, policy);
742 assert_eq!(pruner.pinned_count(), 2);
743 }
744}