//! Scheduled task for cleaning up old cached data. use async_trait::async_trait; use sea_orm::*; use std::sync::Arc; use tracing::{info, warn}; use crate::shared::database::get_redis_pool; use crate::shared::database::persistence::entities::image_cache; use crate::shared::utils::cache::Cache; use super::ScheduledTask; /// Cleanup old cache data to prevent disk/memory bloat. /// Runs daily at 2 AM to clean: /// - Old image cache entries (>30 days) /// - Orphaned cache keys in Redis /// - Expired data without TTL pub struct CleanupOldCache { db: Arc, } impl CleanupOldCache { pub fn new(db: Arc) -> Self { Self { db } } } #[async_trait] impl ScheduledTask for CleanupOldCache { fn name(&self) -> &'static str { "cleanup_old_cache" } fn schedule(&self) -> &'static str { // Daily at 2 AM "0 0 2 * * *" } async fn run(&self) { tracing::debug!("๐Ÿงน Starting old cache cleanup..."); let mut total_cleaned = 0; // 1. Clean old image cache (>30 days) match self.cleanup_old_images(30).await { Ok(count) => { if count > 0 { info!("โœ“ Cleaned {} old image cache entries", count); } total_cleaned += count; } Err(e) => { warn!("Failed to clean old image cache: {}", e); } } // 2. Clean orphaned Redis keys match self.cleanup_orphaned_redis_keys().await { Ok(count) => { if count > 0 { info!("โœ“ Cleaned {} orphaned Redis keys", count); } total_cleaned += count; } Err(e) => { warn!("Failed to clean orphaned Redis keys: {}", e); } } // 3. Compact Redis memory match self.compact_redis_memory().await { Ok(()) => { if total_cleaned > 0 { info!("โœ“ Redis memory compacted"); } } Err(e) => { warn!("Failed to compact Redis memory: {}", e); } } if total_cleaned > 0 { info!("๐ŸŽ‰ Cache cleanup complete: {} items cleaned", total_cleaned); } } } impl CleanupOldCache { /// Remove image cache entries older than specified days. async fn cleanup_old_images(&self, days: i64) -> Result { use chrono::{Duration, Utc}; let cutoff = Utc::now() - Duration::days(days); // Find old entries let old_images = image_cache::Entity::find() .filter(image_cache::Column::CreatedAt.lt(cutoff)) .all(self.db.as_ref()) .await .map_err(|e| e.to_string())?; let count = old_images.len(); if count == 0 { return Ok(0); } // Delete from database let ids: Vec = old_images.iter().map(|img| img.id.clone()).collect(); image_cache::Entity::delete_many() .filter(image_cache::Column::Id.is_in(ids)) .exec(self.db.as_ref()) .await .map_err(|e| e.to_string())?; // Also clean from Redis let redis_pool = get_redis_pool().map_err(|e| e.to_string())?; let cache = Cache::new(redis_pool); for img in old_images { let cache_key = format!("img_cache:{}", Self::hash_url(&img.original_url)); let _ = cache.delete(&cache_key).await; } Ok(count) } /// Clean orphaned Redis keys (keys without TTL that shouldn't exist). async fn cleanup_orphaned_redis_keys(&self) -> Result { use deadpool_redis::redis::AsyncCommands; let pool = get_redis_pool().map_err(|e| e.to_string())?; let mut conn = pool .get() .await .map_err(|e| format!("Failed to get Redis connection: {}", e))?; let mut cleaned = 0; // Find keys without TTL (should not exist) let patterns = vec!["anime:*", "komik:*", "user:*:profile", "img_cache:*"]; for pattern in patterns { let keys = { let mut iter: deadpool_redis::redis::AsyncIter<'_, String> = conn .scan_match(pattern) .await .map_err(|e| format!("Failed to scan keys: {}", e))?; let mut keys = Vec::new(); while let Some(key_result) = iter.next_item().await { if let Ok(key) = key_result { keys.push(key); } } keys }; for key in keys { let ttl: i64 = conn.ttl(key.as_str()).await.unwrap_or(-1); // TTL = -1 means no expiration (orphaned) // TTL = -2 means key doesn't exist if ttl == -1 { // Set a default TTL of 7 days for orphaned keys let _: () = conn.expire(key.as_str(), 604800).await.unwrap_or(()); cleaned += 1; } } } Ok(cleaned) } /// Compact Redis memory to free up fragmented space. async fn compact_redis_memory(&self) -> Result<(), String> { let pool = get_redis_pool().map_err(|e| e.to_string())?; let mut conn = pool .get() .await .map_err(|e| format!("Failed to get Redis connection: {}", e))?; // Run MEMORY PURGE command - using cmd method on connection let _: String = deadpool_redis::redis::cmd("MEMORY") .arg("PURGE") .query_async(&mut *conn) .await .map_err(|e| format!("Failed to purge memory: {}", e))?; Ok(()) } /// Simple hash function for URL (same as in image_cache.rs). /// Simple hash function for URL (same as in image_cache.rs). fn hash_url(url: &str) -> String { use sha2::{Digest, Sha256}; let mut hasher = Sha256::new(); hasher.update(url.as_bytes()); format!("{:x}", hasher.finalize()) } } #[cfg(test)] mod tests { use super::*; #[test] fn test_schedule() { use sea_orm::{DatabaseBackend, MockDatabase}; let db = MockDatabase::new(DatabaseBackend::Sqlite).into_connection(); let task = CleanupOldCache { db: Arc::new(db) }; assert_eq!(task.schedule(), "0 0 2 * * *"); } #[test] fn test_hash_url() { let hash = CleanupOldCache::hash_url("https://example.com/image.jpg"); assert_eq!(hash.len(), 64); // SHA256 hash length } }