//! Pruning-logikk for CAS-filer. //! //! TTL per modalitet, signaler som forlenger levetid, og disk-nødventil. //! Ref: docs/retninger/maskinrommet.md § CAS og intelligent pruning //! //! ## TTL per modalitet //! | Modalitet | Standard TTL | Begrunnelse | //! |---------------|-------------|--------------------------------------| //! | Tekst | Aldri | Billig, essensen av innhold | //! | Transkripsjon | Aldri | Tekstrepresentasjon bevarer mening | //! | Lyd | 30 dager | Transkripsjon bevarer innholdet | //! | Bilde | 30 dager | Beskrivelse/metadata bevarer kontekst| //! | Video | 14 dager | Dyrest, transkripsjon + thumbnail | //! //! ## Signaler som forlenger levetid //! - Publishing-edge → behold for alltid //! - Tilgang (last_accessed_at) innenfor TTL → forleng med ny TTL-periode //! - Uredigert transkripsjon → utranskribert lyd beholdes //! //! ## Disk-nødventil //! | Terskel | Handling | //! |---------|----------------------------------------------------------| //! | >85% | Slett generert innhold (TTS, thumbnails — regenererbart) | //! | >90% | Aggressiv pruning for alle samlinger | //! | >95% | Kritisk: alt uten publishing-edge slettes. Tekst beholdes| use chrono::{DateTime, Utc}; use sqlx::PgPool; use uuid::Uuid; use crate::cas::CasStore; /// Konfigurasjon for pruning. #[derive(Debug, Clone)] pub struct PruningConfig { /// Standard TTL for lyd i dager. pub audio_ttl_days: i64, /// Standard TTL for bilder i dager. pub image_ttl_days: i64, /// Standard TTL for video i dager. pub video_ttl_days: i64, /// Disk-terskel for å slette generert innhold (prosent). pub disk_warning_pct: f64, /// Disk-terskel for aggressiv pruning (prosent). pub disk_aggressive_pct: f64, /// Disk-terskel for kritisk alarm (prosent). pub disk_critical_pct: f64, } impl Default for PruningConfig { fn default() -> Self { Self { audio_ttl_days: 30, image_ttl_days: 30, video_ttl_days: 14, disk_warning_pct: 85.0, disk_aggressive_pct: 90.0, disk_critical_pct: 95.0, } } } /// En CAS-fil-kandidat for pruning. #[derive(Debug, sqlx::FromRow)] #[allow(dead_code)] struct PruneCandidate { id: Uuid, cas_hash: String, mime_category: String, size_bytes: i64, created_at: DateTime, last_accessed_at: Option>, has_publishing_edge: bool, is_generated: bool, has_transcription: bool, } /// Resultat fra en pruning-kjøring. #[derive(Debug, Default, serde::Serialize)] pub struct PruneResult { pub candidates_checked: usize, pub files_deleted: usize, pub bytes_freed: u64, pub disk_pct_before: f64, pub disk_pct_after: f64, pub emergency_level: &'static str, } /// Kjør pruning-logikk. Returnerer statistikk. pub async fn run_pruning( db: &PgPool, cas: &CasStore, config: &PruningConfig, ) -> Result { let disk_pct = cas.disk_usage_percent().await.map_err(|e| format!("Disk-sjekk feilet: {e}"))?; let emergency_level = if disk_pct >= config.disk_critical_pct { "critical" } else if disk_pct >= config.disk_aggressive_pct { "aggressive" } else if disk_pct >= config.disk_warning_pct { "warning" } else { "normal" }; tracing::info!( disk_pct = disk_pct, level = emergency_level, "Pruning startet" ); let mut result = PruneResult { disk_pct_before: disk_pct, emergency_level, ..Default::default() }; // Fase 1: Slett generert innhold ved disk ≥ warning (85%) if disk_pct >= config.disk_warning_pct { let freed = prune_generated(db, cas).await?; result.files_deleted += freed.0; result.bytes_freed += freed.1; tracing::info!( files = freed.0, bytes = freed.1, "Fase 1: Slettet generert innhold" ); } // Fase 2: Standard TTL-basert pruning (kjører alltid) let ttl_freed = prune_by_ttl(db, cas, config, emergency_level).await?; result.candidates_checked += ttl_freed.0; result.files_deleted += ttl_freed.1; result.bytes_freed += ttl_freed.2; // Fase 3: Kritisk — slett alt uten publishing-edge (unntatt tekst) if disk_pct >= config.disk_critical_pct { let critical_freed = prune_critical(db, cas).await?; result.files_deleted += critical_freed.0; result.bytes_freed += critical_freed.1; tracing::warn!( files = critical_freed.0, bytes = critical_freed.1, "Fase 3: KRITISK pruning — alt uten publishing-edge slettet" ); } // Sjekk disk etter pruning result.disk_pct_after = cas .disk_usage_percent() .await .unwrap_or(disk_pct); tracing::info!( files_deleted = result.files_deleted, bytes_freed = result.bytes_freed, disk_before = format!("{:.1}%", result.disk_pct_before), disk_after = format!("{:.1}%", result.disk_pct_after), "Pruning fullført" ); Ok(result) } /// Slett generert innhold (TTS, thumbnails osv.) — kan regenereres. /// Identifiseres ved metadata.tts eller metadata.generated = true. async fn prune_generated(db: &PgPool, cas: &CasStore) -> Result<(usize, u64), String> { let rows: Vec<(String, Uuid)> = sqlx::query_as( r#" SELECT metadata->>'cas_hash' AS cas_hash, id FROM nodes WHERE node_kind = 'media' AND metadata->>'cas_hash' IS NOT NULL AND ( metadata ? 'tts' OR metadata->>'generated' = 'true' ) "#, ) .fetch_all(db) .await .map_err(|e| format!("Spørring for generert innhold feilet: {e}"))?; let mut deleted = 0usize; let mut freed = 0u64; for (hash, node_id) in &rows { match cas.delete(hash).await { Ok(bytes) if bytes > 0 => { deleted += 1; freed += bytes; log_prune(db, *node_id, hash, bytes, "generated_cleanup").await; } Ok(_) => {} // Allerede borte Err(e) => { tracing::warn!(hash = %hash, error = %e, "Kunne ikke slette generert fil"); } } } Ok((deleted, freed)) } /// TTL-basert pruning: slett CAS-filer som har utløpt basert på modalitet. /// Ved aggressive/critical senkes TTL drastisk. async fn prune_by_ttl( db: &PgPool, cas: &CasStore, config: &PruningConfig, emergency_level: &str, ) -> Result<(usize, usize, u64), String> { // Hent kandidater: media-noder med CAS-hash, MIME, alder, edges, tilgangstid let candidates: Vec = sqlx::query_as( r#" SELECT n.id, n.metadata->>'cas_hash' AS cas_hash, CASE WHEN n.metadata->>'mime' LIKE 'audio/%' THEN 'audio' WHEN n.metadata->>'mime' LIKE 'image/%' THEN 'image' WHEN n.metadata->>'mime' LIKE 'video/%' THEN 'video' ELSE 'other' END AS mime_category, COALESCE((n.metadata->>'size_bytes')::bigint, (n.metadata->>'size')::bigint, 0) AS size_bytes, n.created_at, n.last_accessed_at, EXISTS( SELECT 1 FROM edges e WHERE (e.source_id = n.id OR e.target_id = n.id) AND e.edge_type IN ('belongs_to', 'has_media') AND EXISTS( SELECT 1 FROM edges pub WHERE pub.edge_type = 'publishing' AND (pub.source_id = e.target_id OR pub.source_id = e.source_id) ) ) AS has_publishing_edge, COALESCE(n.metadata ? 'tts' OR n.metadata->>'generated' = 'true', false) AS is_generated, EXISTS( SELECT 1 FROM transcription_segments ts WHERE ts.node_id = n.id ) AS has_transcription FROM nodes n WHERE n.node_kind = 'media' AND n.metadata->>'cas_hash' IS NOT NULL ORDER BY n.created_at ASC "#, ) .fetch_all(db) .await .map_err(|e| format!("TTL-kandidatspørring feilet: {e}"))?; let now = Utc::now(); let mut checked = 0usize; let mut deleted = 0usize; let mut freed = 0u64; for c in &candidates { checked += 1; // Tekst/transkripsjon slettes aldri if c.mime_category == "other" { continue; } // Publishing-edge = behold for alltid (unntatt ved kritisk) if c.has_publishing_edge && emergency_level != "critical" { continue; } // Bestem TTL basert på modalitet og nødsituasjon let ttl_days = match emergency_level { "critical" => 0, // Slett alt umiddelbart "aggressive" => match c.mime_category.as_str() { "audio" => config.audio_ttl_days / 3, // 10 dager "image" => config.image_ttl_days / 3, "video" => 1, // Video slettes nesten umiddelbart _ => i64::MAX, }, _ => match c.mime_category.as_str() { "audio" => config.audio_ttl_days, "image" => config.image_ttl_days, "video" => config.video_ttl_days, _ => i64::MAX, }, }; if ttl_days == i64::MAX { continue; } // Beregn effektiv alder — siste tilgang forlenger levetiden let reference_time = c.last_accessed_at.unwrap_or(c.created_at); let age_days = (now - reference_time).num_days(); if age_days < ttl_days { continue; // Ikke utløpt ennå } // Lyd uten transkripsjon: behold lengre (trenger transkribering først) if c.mime_category == "audio" && !c.has_transcription && emergency_level == "normal" { tracing::debug!( node_id = %c.id, "Beholder utranskribert lyd — trenger transkribering" ); continue; } // Allerede slettet generert innhold i fase 1 — hopp over if c.is_generated { continue; } // Slett filen match cas.delete(&c.cas_hash).await { Ok(bytes) if bytes > 0 => { deleted += 1; freed += bytes; log_prune(db, c.id, &c.cas_hash, bytes, "ttl_expired").await; } Ok(_) => {} // Allerede borte Err(e) => { tracing::warn!( hash = %c.cas_hash, node_id = %c.id, error = %e, "Kunne ikke slette CAS-fil" ); } } } Ok((checked, deleted, freed)) } /// Kritisk pruning: slett ALT uten publishing-edge (unntatt tekst/transkripsjon). async fn prune_critical(db: &PgPool, cas: &CasStore) -> Result<(usize, u64), String> { let rows: Vec<(String, Uuid, i64)> = sqlx::query_as( r#" SELECT n.metadata->>'cas_hash' AS cas_hash, n.id, COALESCE((n.metadata->>'size_bytes')::bigint, 0) AS size_bytes FROM nodes n WHERE n.node_kind = 'media' AND n.metadata->>'cas_hash' IS NOT NULL AND NOT EXISTS( SELECT 1 FROM edges e WHERE (e.source_id = n.id OR e.target_id = n.id) AND e.edge_type IN ('belongs_to', 'has_media') AND EXISTS( SELECT 1 FROM edges pub WHERE pub.edge_type = 'publishing' AND (pub.source_id = e.target_id OR pub.source_id = e.source_id) ) ) "#, ) .fetch_all(db) .await .map_err(|e| format!("Kritisk pruning-spørring feilet: {e}"))?; let mut deleted = 0usize; let mut freed = 0u64; for (hash, node_id, _size) in &rows { match cas.delete(hash).await { Ok(bytes) if bytes > 0 => { deleted += 1; freed += bytes; log_prune(db, *node_id, hash, bytes, "critical_emergency").await; } Ok(_) => {} Err(e) => { tracing::warn!(hash = %hash, error = %e, "Kritisk: kunne ikke slette CAS-fil"); } } } Ok((deleted, freed)) } /// Logg en pruning-hendelse til resource_usage_log. async fn log_prune(db: &PgPool, node_id: Uuid, hash: &str, bytes: u64, reason: &str) { let detail = serde_json::json!({ "hash": hash, "size_bytes": bytes, "operation": "delete", "reason": reason, }); let _ = sqlx::query( r#" INSERT INTO resource_usage_log (target_node_id, resource_type, detail) VALUES ($1, 'cas', $2) "#, ) .bind(node_id) .bind(&detail) .execute(db) .await .map_err(|e| { tracing::warn!(error = %e, "Kunne ikke logge pruning-hendelse"); }); } /// Oppdater last_accessed_at for en node basert på CAS-hash. /// Kalles fra serving.rs når en CAS-fil hentes. pub async fn touch_access(db: &PgPool, cas_hash: &str) { let _ = sqlx::query( "UPDATE nodes SET last_accessed_at = now() WHERE metadata->>'cas_hash' = $1", ) .bind(cas_hash) .execute(db) .await .map_err(|e| { tracing::debug!(error = %e, "Kunne ikke oppdatere last_accessed_at"); }); } /// Start periodisk pruning-loop som bakgrunnsoppgave. /// Kjører hvert 6. time, eller oftere ved høy diskbruk. pub fn start_pruning_loop(db: PgPool, cas: CasStore) { let config = PruningConfig::default(); tokio::spawn(async move { // Vent 60 sekunder etter oppstart før første kjøring tokio::time::sleep(std::time::Duration::from_secs(60)).await; tracing::info!("Pruning-loop startet (intervall: 6t, nødsjekk: 10min)"); loop { match run_pruning(&db, &cas, &config).await { Ok(result) => { if result.files_deleted > 0 { tracing::info!( deleted = result.files_deleted, freed_mb = result.bytes_freed / 1_048_576, "Pruning: {} filer slettet, {} MB frigitt", result.files_deleted, result.bytes_freed / 1_048_576, ); } // Ved høy diskbruk: sjekk igjen om 10 minutter let sleep_secs = if result.disk_pct_after >= config.disk_warning_pct { tracing::warn!( disk_pct = result.disk_pct_after, "Disk fortsatt høy — neste pruning om 10 min" ); 600 // 10 minutter } else { 6 * 3600 // 6 timer }; tokio::time::sleep(std::time::Duration::from_secs(sleep_secs)).await; } Err(e) => { tracing::error!(error = %e, "Pruning feilet"); // Vent 30 minutter ved feil tokio::time::sleep(std::time::Duration::from_secs(1800)).await; } } } }); }