Skip to main content

prodigy/cook/execution/events/
retention.rs

1//! Event retention policy management
2
3use 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/// Event retention policy configuration
11#[derive(Debug, Clone, Serialize, Deserialize)]
12pub struct RetentionPolicy {
13    /// Maximum age of events to retain (in days)
14    pub max_age_days: Option<u32>,
15
16    /// Maximum number of events to retain
17    pub max_events: Option<usize>,
18
19    /// Maximum file size in bytes
20    pub max_file_size_bytes: Option<u64>,
21
22    /// Archive old events instead of deleting
23    pub archive_old_events: bool,
24
25    /// Path to archive directory
26    pub archive_path: Option<PathBuf>,
27
28    /// Compress archived events
29    pub compress_archives: bool,
30}
31
32impl Default for RetentionPolicy {
33    fn default() -> Self {
34        // Use global archive path
35        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),   // Keep events for 30 days by default
43            max_events: Some(100000), // Keep max 100k events
44            max_file_size_bytes: Some(100 * 1024 * 1024), // 100MB max file size
45            archive_old_events: true,
46            archive_path,
47            compress_archives: true,
48        }
49    }
50}
51
52/// Manages event retention and cleanup
53pub struct RetentionManager {
54    policy: RetentionPolicy,
55    events_path: PathBuf,
56}
57
58impl RetentionManager {
59    /// Create a new retention manager with the given policy
60    pub fn new(policy: RetentionPolicy, events_path: PathBuf) -> Self {
61        Self {
62            policy,
63            events_path,
64        }
65    }
66
67    /// Create with default policy
68    pub fn with_default_policy(events_path: PathBuf) -> Self {
69        Self::new(RetentionPolicy::default(), events_path)
70    }
71
72    /// Create with global storage support
73    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    /// Load policy from configuration file
83    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    /// Perform dry-run analysis without modifying files
90    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        // Get file metadata
102        let metadata = fs::metadata(&self.events_path)?;
103        analysis.original_size_bytes = metadata.len();
104
105        // Calculate cutoff time if age-based retention is configured
106        let cutoff_time = self.calculate_cutoff_time();
107
108        // Read and analyze events
109        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            // Report progress periodically for large files
127            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            // Parse event to check retention
135            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; // +1 for newline
139                } else {
140                    events_to_remove += 1;
141                }
142            }
143        }
144
145        // Clear the progress line
146        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        // Set archive count if archiving is enabled
155        if self.policy.archive_old_events && events_to_remove > 0 {
156            analysis.events_to_archive = events_to_remove;
157        }
158
159        // Calculate projected sizes
160        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        // Add warnings for large operations
166        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            // 100MB
175            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        // Check if cleanup would be effective
182        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        // Estimate duration based on file size and operations
187        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    /// Apply retention policy to events file
198    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        // Check file size first
206        let metadata = fs::metadata(&self.events_path)?;
207        let file_size = metadata.len();
208        stats.original_size_bytes = file_size;
209
210        // Determine if cleanup is needed
211        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        // Perform cleanup
220        self.cleanup_events(&mut stats).await?;
221
222        Ok(stats)
223    }
224
225    /// Check if cleanup is needed based on policy
226    fn needs_cleanup(&self, file_size: u64) -> Result<bool> {
227        // Check file size limit
228        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        // Check event count limit
235        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        // Check age limit
243        if self.policy.max_age_days.is_some() {
244            // We'd need to check if there are old events, which requires scanning
245            // For efficiency, we'll return true and let the cleanup process handle it
246            return Ok(true);
247        }
248
249        Ok(false)
250    }
251
252    /// Count total events in the file
253    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    /// Perform the actual cleanup of events
265    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        // Read and filter events
272        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            // Parse event to check timestamp
284            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        // Archive old events if configured
296        if self.policy.archive_old_events && !events_to_archive.is_empty() {
297            self.archive_events(&events_to_archive, stats).await?;
298        }
299
300        // Write retained events to temp file
301        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        // Replace original file with temp file
308        fs::rename(&temp_file, &self.events_path)?;
309
310        // Update final size
311        let metadata = fs::metadata(&self.events_path)?;
312        stats.final_size_bytes = metadata.len();
313
314        Ok(())
315    }
316
317    /// Calculate the cutoff time for event retention
318    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    /// Check if an event should be retained
325    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        // Check event count limit
332        if let Some(max_events) = self.policy.max_events {
333            if current_retained_count >= max_events {
334                return false;
335            }
336        }
337
338        // Check age limit
339        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    /// Archive events to the configured archive directory
351    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        // Create archive directory if it doesn't exist
359        fs::create_dir_all(archive_dir)?;
360
361        // Generate archive filename with timestamp
362        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        // Write events to archive
374        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    /// Write events to a plain text archive file
387    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    /// Write events to a compressed archive file
397    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    /// Get current retention policy
413    pub fn policy(&self) -> &RetentionPolicy {
414        &self.policy
415    }
416
417    /// Update retention policy
418    pub fn set_policy(&mut self, policy: RetentionPolicy) {
419        self.policy = policy;
420    }
421
422    /// Save policy to configuration file
423    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    /// Estimate duration for the operation based on file size and complexity
430    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        // Base estimates (in seconds)
438        const BASE_OVERHEAD_SECS: f64 = 0.5;
439        const BYTES_PER_SEC_READ: f64 = 50_000_000.0; // ~50 MB/s read speed
440        const BYTES_PER_SEC_WRITE: f64 = 30_000_000.0; // ~30 MB/s write speed
441        const EVENTS_PER_SEC_PROCESS: f64 = 10_000.0; // Processing speed
442        const ARCHIVE_OVERHEAD_FACTOR: f64 = 1.5; // Archive adds 50% overhead
443
444        // Calculate read time
445        let read_time = (file_size_bytes as f64) / BYTES_PER_SEC_READ;
446
447        // Calculate processing time
448        let processing_time = (total_events as f64) / EVENTS_PER_SEC_PROCESS;
449
450        // Calculate write time (proportional to data retained)
451        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        // Add archive overhead if enabled
456        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        // Total estimated time
465        BASE_OVERHEAD_SECS + read_time + processing_time + write_time + archive_time
466    }
467}
468
469/// Statistics from retention operations
470#[derive(Debug, Default, Clone)]
471pub struct RetentionStats {
472    /// Number of events processed
473    pub events_processed: usize,
474
475    /// Number of events retained
476    pub events_retained: usize,
477
478    /// Number of events removed
479    pub events_removed: usize,
480
481    /// Number of events archived
482    pub events_archived: usize,
483
484    /// Original file size in bytes
485    pub original_size_bytes: u64,
486
487    /// Final file size after cleanup
488    pub final_size_bytes: u64,
489
490    /// Path to archive file if created
491    pub archive_path: Option<PathBuf>,
492}
493
494/// Analysis result for dry-run operations
495#[derive(Debug, Default, Clone, Serialize, Deserialize)]
496pub struct RetentionAnalysis {
497    /// File path being analyzed
498    pub file_path: PathBuf,
499
500    /// Total number of events in the file
501    pub events_total: usize,
502
503    /// Number of events that would be retained
504    pub events_retained: usize,
505
506    /// Number of events that would be removed
507    pub events_to_remove: usize,
508
509    /// Number of events that would be archived
510    pub events_to_archive: usize,
511
512    /// Original size of the file in bytes
513    pub original_size_bytes: u64,
514
515    /// Projected size after cleanup
516    pub projected_size_bytes: u64,
517
518    /// Space that would be saved
519    pub space_to_save: u64,
520
521    /// Estimated duration for operation in seconds
522    pub estimated_duration_secs: f64,
523
524    /// Any warnings generated during analysis
525    pub warnings: Vec<String>,
526}
527
528impl RetentionAnalysis {
529    /// Display human-readable analysis results
530    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        // Display estimated duration
553        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
569/// Format duration in human-readable form
570fn 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    /// Calculate the space saved in bytes
586    pub fn space_saved(&self) -> u64 {
587        self.original_size_bytes
588            .saturating_sub(self.final_size_bytes)
589    }
590
591    /// Calculate the space saved percentage
592    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    /// Display statistics summary
601    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
624/// Extract timestamp from an event
625fn extract_event_timestamp(event: &serde_json::Value) -> Option<DateTime<Utc>> {
626    // Try various common timestamp field locations
627    let timestamp_str = event
628        .get("timestamp")
629        .or_else(|| event.get("time"))
630        .or_else(|| event.get("created_at"))
631        .or_else(|| {
632            // Look in nested event structures
633            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
654/// Automated retention task that can be run periodically
655pub struct RetentionTask {
656    manager: RetentionManager,
657    interval: std::time::Duration,
658}
659
660impl RetentionTask {
661    /// Create a new retention task
662    pub fn new(manager: RetentionManager, interval: std::time::Duration) -> Self {
663        Self { manager, interval }
664    }
665
666    /// Run the retention task once
667    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    /// Start the retention task to run periodically
685    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        // Create a small events file with a recent timestamp
719        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        // Create a policy with high limits so cleanup is not triggered
724        let policy = RetentionPolicy {
725            max_age_days: Some(365),                      // Keep events for a year
726            max_events: Some(10000),                      // Allow many events
727            max_file_size_bytes: Some(100 * 1024 * 1024), // 100MB limit
728            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        // Even though cleanup wasn't needed due to file size,
737        // the retention manager still processes events when max_age_days is set
738        // It will scan the file to check for old events
739        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}