prodigy/cook/execution/events/
retention.rs1use anyhow::Result;
4use chrono::{DateTime, Duration, Utc};
5use serde::{Deserialize, Serialize};
6use std::fs;
7use std::io::{BufRead, BufReader, Write};
8use std::path::{Path, PathBuf};
9
10#[derive(Debug, Clone, Serialize, Deserialize)]
12pub struct RetentionPolicy {
13 pub max_age_days: Option<u32>,
15
16 pub max_events: Option<usize>,
18
19 pub max_file_size_bytes: Option<u64>,
21
22 pub archive_old_events: bool,
24
25 pub archive_path: Option<PathBuf>,
27
28 pub compress_archives: bool,
30}
31
32impl Default for RetentionPolicy {
33 fn default() -> Self {
34 let archive_path = if let Ok(global_base) = crate::storage::get_default_storage_dir() {
36 Some(global_base.join("events").join("archive"))
37 } else {
38 Some(PathBuf::from(".prodigy/events/archive"))
39 };
40
41 Self {
42 max_age_days: Some(30), max_events: Some(100000), max_file_size_bytes: Some(100 * 1024 * 1024), archive_old_events: true,
46 archive_path,
47 compress_archives: true,
48 }
49 }
50}
51
52pub struct RetentionManager {
54 policy: RetentionPolicy,
55 events_path: PathBuf,
56}
57
58impl RetentionManager {
59 pub fn new(policy: RetentionPolicy, events_path: PathBuf) -> Self {
61 Self {
62 policy,
63 events_path,
64 }
65 }
66
67 pub fn with_default_policy(events_path: PathBuf) -> Self {
69 Self::new(RetentionPolicy::default(), events_path)
70 }
71
72 pub async fn with_global_storage(repo_path: &Path, job_id: &str) -> Result<Self> {
74 use crate::storage::{extract_repo_name, GlobalStorage};
75
76 let storage = GlobalStorage::new()?;
77 let repo_name = extract_repo_name(repo_path)?;
78 let events_path = storage.get_events_dir(&repo_name, job_id).await?;
79 Ok(Self::with_default_policy(events_path))
80 }
81
82 pub fn from_config_file(config_path: &Path, events_path: PathBuf) -> Result<Self> {
84 let config_content = fs::read_to_string(config_path)?;
85 let policy: RetentionPolicy = serde_yaml::from_str(&config_content)?;
86 Ok(Self::new(policy, events_path))
87 }
88
89 pub async fn analyze_retention(&self) -> Result<RetentionAnalysis> {
91 let mut analysis = RetentionAnalysis {
92 file_path: self.events_path.clone(),
93 ..Default::default()
94 };
95
96 if !self.events_path.exists() {
97 analysis.warnings.push("File does not exist".to_string());
98 return Ok(analysis);
99 }
100
101 let metadata = fs::metadata(&self.events_path)?;
103 analysis.original_size_bytes = metadata.len();
104
105 let cutoff_time = self.calculate_cutoff_time();
107
108 let file = fs::File::open(&self.events_path)?;
110 let reader = BufReader::new(file);
111
112 let mut events_to_keep = 0usize;
113 let mut events_to_remove = 0usize;
114 let mut bytes_retained = 0u64;
115 let mut last_progress_report = 0usize;
116 const PROGRESS_REPORT_INTERVAL: usize = 10000;
117
118 for line in reader.lines() {
119 let line = line?;
120 if line.trim().is_empty() {
121 continue;
122 }
123
124 analysis.events_total += 1;
125
126 if analysis.events_total >= last_progress_report + PROGRESS_REPORT_INTERVAL {
128 eprint!("\rAnalyzing events: {} processed...", analysis.events_total);
129 use std::io::Write;
130 std::io::stderr().flush().ok();
131 last_progress_report = analysis.events_total;
132 }
133
134 if let Ok(event) = serde_json::from_str::<serde_json::Value>(&line) {
136 if self.should_retain_event(&event, cutoff_time, events_to_keep) {
137 events_to_keep += 1;
138 bytes_retained += line.len() as u64 + 1; } else {
140 events_to_remove += 1;
141 }
142 }
143 }
144
145 if last_progress_report > 0 {
147 eprint!("\r{}\r", " ".repeat(50));
148 std::io::stderr().flush().ok();
149 }
150
151 analysis.events_retained = events_to_keep;
152 analysis.events_to_remove = events_to_remove;
153
154 if self.policy.archive_old_events && events_to_remove > 0 {
156 analysis.events_to_archive = events_to_remove;
157 }
158
159 analysis.projected_size_bytes = bytes_retained;
161 analysis.space_to_save = analysis
162 .original_size_bytes
163 .saturating_sub(analysis.projected_size_bytes);
164
165 if analysis.events_to_remove > 10000 {
167 analysis.warnings.push(format!(
168 "Large number of events will be removed: {}",
169 analysis.events_to_remove
170 ));
171 }
172
173 if analysis.space_to_save > 100 * 1024 * 1024 {
174 analysis.warnings.push(format!(
176 "Large amount of space will be freed: {:.1} MB",
177 analysis.space_to_save as f64 / (1024.0 * 1024.0)
178 ));
179 }
180
181 if self.needs_cleanup(analysis.original_size_bytes)? && analysis.events_to_remove == 0 {
183 analysis.warnings.push("Cleanup triggered but no events would be removed - consider adjusting retention policy".to_string());
184 }
185
186 analysis.estimated_duration_secs = self.estimate_operation_duration(
188 analysis.original_size_bytes,
189 analysis.events_total,
190 analysis.events_to_remove,
191 self.policy.archive_old_events,
192 );
193
194 Ok(analysis)
195 }
196
197 pub async fn apply_retention(&self) -> Result<RetentionStats> {
199 let mut stats = RetentionStats::default();
200
201 if !self.events_path.exists() {
202 return Ok(stats);
203 }
204
205 let metadata = fs::metadata(&self.events_path)?;
207 let file_size = metadata.len();
208 stats.original_size_bytes = file_size;
209
210 let needs_cleanup = self.needs_cleanup(file_size)?;
212
213 if !needs_cleanup {
214 stats.events_retained = self.count_events()?;
215 stats.final_size_bytes = file_size;
216 return Ok(stats);
217 }
218
219 self.cleanup_events(&mut stats).await?;
221
222 Ok(stats)
223 }
224
225 fn needs_cleanup(&self, file_size: u64) -> Result<bool> {
227 if let Some(max_size) = self.policy.max_file_size_bytes {
229 if file_size > max_size {
230 return Ok(true);
231 }
232 }
233
234 if let Some(max_events) = self.policy.max_events {
236 let event_count = self.count_events()?;
237 if event_count > max_events {
238 return Ok(true);
239 }
240 }
241
242 if self.policy.max_age_days.is_some() {
244 return Ok(true);
247 }
248
249 Ok(false)
250 }
251
252 fn count_events(&self) -> Result<usize> {
254 let file = fs::File::open(&self.events_path)?;
255 let reader = BufReader::new(file);
256 let count = reader
257 .lines()
258 .map_while(Result::ok)
259 .filter(|l| !l.trim().is_empty())
260 .count();
261 Ok(count)
262 }
263
264 async fn cleanup_events(&self, stats: &mut RetentionStats) -> Result<()> {
266 let cutoff_time = self.calculate_cutoff_time();
267 let temp_file = self.events_path.with_extension("tmp");
268 let mut events_to_archive = Vec::new();
269 let mut events_to_keep = Vec::new();
270
271 let file = fs::File::open(&self.events_path)?;
273 let reader = BufReader::new(file);
274
275 for line in reader.lines() {
276 let line = line?;
277 if line.trim().is_empty() {
278 continue;
279 }
280
281 stats.events_processed += 1;
282
283 if let Ok(event) = serde_json::from_str::<serde_json::Value>(&line) {
285 if self.should_retain_event(&event, cutoff_time, stats.events_retained) {
286 events_to_keep.push(line);
287 stats.events_retained += 1;
288 } else {
289 events_to_archive.push(line);
290 stats.events_removed += 1;
291 }
292 }
293 }
294
295 if self.policy.archive_old_events && !events_to_archive.is_empty() {
297 self.archive_events(&events_to_archive, stats).await?;
298 }
299
300 let mut temp_writer = fs::File::create(&temp_file)?;
302 for event in events_to_keep {
303 writeln!(temp_writer, "{}", event)?;
304 }
305 temp_writer.sync_all()?;
306
307 fs::rename(&temp_file, &self.events_path)?;
309
310 let metadata = fs::metadata(&self.events_path)?;
312 stats.final_size_bytes = metadata.len();
313
314 Ok(())
315 }
316
317 fn calculate_cutoff_time(&self) -> Option<DateTime<Utc>> {
319 self.policy
320 .max_age_days
321 .map(|days| Utc::now() - Duration::days(days as i64))
322 }
323
324 fn should_retain_event(
326 &self,
327 event: &serde_json::Value,
328 cutoff_time: Option<DateTime<Utc>>,
329 current_retained_count: usize,
330 ) -> bool {
331 if let Some(max_events) = self.policy.max_events {
333 if current_retained_count >= max_events {
334 return false;
335 }
336 }
337
338 if let Some(cutoff) = cutoff_time {
340 if let Some(timestamp) = extract_event_timestamp(event) {
341 if timestamp < cutoff {
342 return false;
343 }
344 }
345 }
346
347 true
348 }
349
350 async fn archive_events(&self, events: &[String], stats: &mut RetentionStats) -> Result<()> {
352 let archive_dir = self
353 .policy
354 .archive_path
355 .as_ref()
356 .ok_or_else(|| anyhow::anyhow!("Archive path not configured"))?;
357
358 fs::create_dir_all(archive_dir)?;
360
361 let archive_filename = format!(
363 "events_archive_{}.jsonl{}",
364 Utc::now().format("%Y%m%d_%H%M%S"),
365 if self.policy.compress_archives {
366 ".gz"
367 } else {
368 ""
369 }
370 );
371 let archive_path = archive_dir.join(archive_filename);
372
373 if self.policy.compress_archives {
375 self.write_compressed_archive(&archive_path, events)?;
376 } else {
377 self.write_plain_archive(&archive_path, events)?;
378 }
379
380 stats.events_archived = events.len();
381 stats.archive_path = Some(archive_path);
382
383 Ok(())
384 }
385
386 fn write_plain_archive(&self, path: &Path, events: &[String]) -> Result<()> {
388 let mut file = fs::File::create(path)?;
389 for event in events {
390 writeln!(file, "{}", event)?;
391 }
392 file.sync_all()?;
393 Ok(())
394 }
395
396 fn write_compressed_archive(&self, path: &Path, events: &[String]) -> Result<()> {
398 use flate2::write::GzEncoder;
399 use flate2::Compression;
400
401 let file = fs::File::create(path)?;
402 let mut encoder = GzEncoder::new(file, Compression::default());
403
404 for event in events {
405 writeln!(encoder, "{}", event)?;
406 }
407
408 encoder.finish()?;
409 Ok(())
410 }
411
412 pub fn policy(&self) -> &RetentionPolicy {
414 &self.policy
415 }
416
417 pub fn set_policy(&mut self, policy: RetentionPolicy) {
419 self.policy = policy;
420 }
421
422 pub fn save_policy_to_file(&self, config_path: &Path) -> Result<()> {
424 let yaml = serde_yaml::to_string(&self.policy)?;
425 fs::write(config_path, yaml)?;
426 Ok(())
427 }
428
429 fn estimate_operation_duration(
431 &self,
432 file_size_bytes: u64,
433 total_events: usize,
434 events_to_process: usize,
435 archive_enabled: bool,
436 ) -> f64 {
437 const BASE_OVERHEAD_SECS: f64 = 0.5;
439 const BYTES_PER_SEC_READ: f64 = 50_000_000.0; const BYTES_PER_SEC_WRITE: f64 = 30_000_000.0; const EVENTS_PER_SEC_PROCESS: f64 = 10_000.0; const ARCHIVE_OVERHEAD_FACTOR: f64 = 1.5; let read_time = (file_size_bytes as f64) / BYTES_PER_SEC_READ;
446
447 let processing_time = (total_events as f64) / EVENTS_PER_SEC_PROCESS;
449
450 let retention_ratio = 1.0 - (events_to_process as f64 / total_events.max(1) as f64);
452 let write_size = (file_size_bytes as f64) * retention_ratio;
453 let write_time = write_size / BYTES_PER_SEC_WRITE;
454
455 let archive_time = if archive_enabled && events_to_process > 0 {
457 let archive_size =
458 (file_size_bytes as f64) * (events_to_process as f64 / total_events.max(1) as f64);
459 (archive_size / BYTES_PER_SEC_WRITE) * ARCHIVE_OVERHEAD_FACTOR
460 } else {
461 0.0
462 };
463
464 BASE_OVERHEAD_SECS + read_time + processing_time + write_time + archive_time
466 }
467}
468
469#[derive(Debug, Default, Clone)]
471pub struct RetentionStats {
472 pub events_processed: usize,
474
475 pub events_retained: usize,
477
478 pub events_removed: usize,
480
481 pub events_archived: usize,
483
484 pub original_size_bytes: u64,
486
487 pub final_size_bytes: u64,
489
490 pub archive_path: Option<PathBuf>,
492}
493
494#[derive(Debug, Default, Clone, Serialize, Deserialize)]
496pub struct RetentionAnalysis {
497 pub file_path: PathBuf,
499
500 pub events_total: usize,
502
503 pub events_retained: usize,
505
506 pub events_to_remove: usize,
508
509 pub events_to_archive: usize,
511
512 pub original_size_bytes: u64,
514
515 pub projected_size_bytes: u64,
517
518 pub space_to_save: u64,
520
521 pub estimated_duration_secs: f64,
523
524 pub warnings: Vec<String>,
526}
527
528impl RetentionAnalysis {
529 pub fn display_human(&self) {
531 println!("Cleanup Analysis (DRY RUN)");
532 println!("========================");
533 println!("File: {}", self.file_path.display());
534 println!("Total events: {}", self.events_total);
535 println!("Events to retain: {}", self.events_retained);
536 println!("Events to remove: {}", self.events_to_remove);
537 if self.events_to_archive > 0 {
538 println!("Events to archive: {}", self.events_to_archive);
539 }
540 println!("Current size: {} bytes", self.original_size_bytes);
541 println!("Projected size: {} bytes", self.projected_size_bytes);
542 println!(
543 "Space to save: {} bytes ({:.1}%)",
544 self.space_to_save,
545 if self.original_size_bytes > 0 {
546 (self.space_to_save as f64 / self.original_size_bytes as f64) * 100.0
547 } else {
548 0.0
549 }
550 );
551
552 if self.estimated_duration_secs > 0.0 {
554 println!(
555 "Estimated time: {}",
556 format_duration(self.estimated_duration_secs)
557 );
558 }
559
560 if !self.warnings.is_empty() {
561 println!("\nWarnings:");
562 for warning in &self.warnings {
563 println!(" ⚠️ {}", warning);
564 }
565 }
566 }
567}
568
569fn format_duration(secs: f64) -> String {
571 if secs < 1.0 {
572 format!("{:.0} ms", secs * 1000.0)
573 } else if secs < 60.0 {
574 format!("{:.1} seconds", secs)
575 } else if secs < 3600.0 {
576 let mins = secs / 60.0;
577 format!("{:.1} minutes", mins)
578 } else {
579 let hours = secs / 3600.0;
580 format!("{:.1} hours", hours)
581 }
582}
583
584impl RetentionStats {
585 pub fn space_saved(&self) -> u64 {
587 self.original_size_bytes
588 .saturating_sub(self.final_size_bytes)
589 }
590
591 pub fn space_saved_percentage(&self) -> f64 {
593 if self.original_size_bytes > 0 {
594 (self.space_saved() as f64 / self.original_size_bytes as f64) * 100.0
595 } else {
596 0.0
597 }
598 }
599
600 pub fn display_summary(&self) {
602 println!("Event Retention Summary:");
603 println!(" Events processed: {}", self.events_processed);
604 println!(" Events retained: {}", self.events_retained);
605 println!(" Events removed: {}", self.events_removed);
606
607 if self.events_archived > 0 {
608 println!(" Events archived: {}", self.events_archived);
609 if let Some(ref path) = self.archive_path {
610 println!(" Archive location: {}", path.display());
611 }
612 }
613
614 println!(" Original size: {} bytes", self.original_size_bytes);
615 println!(" Final size: {} bytes", self.final_size_bytes);
616 println!(
617 " Space saved: {} bytes ({:.1}%)",
618 self.space_saved(),
619 self.space_saved_percentage()
620 );
621 }
622}
623
624fn extract_event_timestamp(event: &serde_json::Value) -> Option<DateTime<Utc>> {
626 let timestamp_str = event
628 .get("timestamp")
629 .or_else(|| event.get("time"))
630 .or_else(|| event.get("created_at"))
631 .or_else(|| {
632 for key in [
634 "JobStarted",
635 "JobCompleted",
636 "AgentStarted",
637 "AgentCompleted",
638 ] {
639 if let Some(nested) = event.get(key) {
640 if let Some(ts) = nested.get("timestamp") {
641 return Some(ts);
642 }
643 }
644 }
645 None
646 })
647 .and_then(|v| v.as_str());
648
649 timestamp_str
650 .and_then(|ts| DateTime::parse_from_rfc3339(ts).ok())
651 .map(|dt| dt.with_timezone(&Utc))
652}
653
654pub struct RetentionTask {
656 manager: RetentionManager,
657 interval: std::time::Duration,
658}
659
660impl RetentionTask {
661 pub fn new(manager: RetentionManager, interval: std::time::Duration) -> Self {
663 Self { manager, interval }
664 }
665
666 pub async fn run_once(&self) -> Result<RetentionStats> {
668 log::info!("Running event retention cleanup...");
669 let stats = self.manager.apply_retention().await?;
670
671 if stats.events_removed > 0 {
672 log::info!(
673 "Retention cleanup completed: {} events removed, {:.1}% space saved",
674 stats.events_removed,
675 stats.space_saved_percentage()
676 );
677 } else {
678 log::debug!("Retention cleanup completed: no events removed");
679 }
680
681 Ok(stats)
682 }
683
684 pub async fn start(self) {
686 let mut interval = tokio::time::interval(self.interval);
687
688 loop {
689 interval.tick().await;
690
691 if let Err(e) = self.run_once().await {
692 log::error!("Retention task failed: {}", e);
693 }
694 }
695 }
696}
697
698#[cfg(test)]
699mod tests {
700 use super::*;
701 use tempfile::TempDir;
702
703 #[test]
704 fn test_default_retention_policy() {
705 let policy = RetentionPolicy::default();
706 assert_eq!(policy.max_age_days, Some(30));
707 assert_eq!(policy.max_events, Some(100000));
708 assert_eq!(policy.max_file_size_bytes, Some(100 * 1024 * 1024));
709 assert!(policy.archive_old_events);
710 assert!(policy.compress_archives);
711 }
712
713 #[tokio::test]
714 async fn test_retention_manager_no_cleanup_needed() {
715 let temp_dir = TempDir::new().unwrap();
716 let events_file = temp_dir.path().join("events.jsonl");
717
718 let recent_timestamp = Utc::now().to_rfc3339();
720 let content = format!(r#"{{"timestamp":"{}","event":"test"}}"#, recent_timestamp);
721 std::fs::write(&events_file, content).unwrap();
722
723 let policy = RetentionPolicy {
725 max_age_days: Some(365), max_events: Some(10000), max_file_size_bytes: Some(100 * 1024 * 1024), archive_old_events: false,
729 archive_path: None,
730 compress_archives: false,
731 };
732
733 let manager = RetentionManager::new(policy, events_file);
734 let stats = manager.apply_retention().await.unwrap();
735
736 assert_eq!(stats.events_processed, 1);
740 assert_eq!(stats.events_retained, 1);
741 assert_eq!(stats.events_removed, 0);
742 }
743
744 #[test]
745 fn test_extract_event_timestamp() {
746 let event_json = r#"{
747 "timestamp": "2024-01-01T12:00:00Z",
748 "event_type": "JobStarted"
749 }"#;
750
751 let event: serde_json::Value = serde_json::from_str(event_json).unwrap();
752 let timestamp = extract_event_timestamp(&event);
753
754 assert!(timestamp.is_some());
755 use chrono::Datelike;
756 let ts = timestamp.unwrap();
757 assert_eq!(ts.year(), 2024);
758 assert_eq!(ts.month(), 1);
759 assert_eq!(ts.day(), 1);
760 }
761
762 #[test]
763 fn test_retention_stats_calculations() {
764 let stats = RetentionStats {
765 original_size_bytes: 1000,
766 final_size_bytes: 250,
767 ..Default::default()
768 };
769
770 assert_eq!(stats.space_saved(), 750);
771 assert_eq!(stats.space_saved_percentage(), 75.0);
772 }
773}