synops/maskinrommet/src/pruning.rs
vegard 6d916d9860 Pruning-logikk: TTL per modalitet, signaler, disk-nødventil (oppgave 11.3)
Implementerer automatisk opprydding av CAS-filer basert på dokumentert
spec i docs/retninger/maskinrommet.md:

- TTL per modalitet: lyd 30d, bilde 30d, video 14d, tekst aldri
- Signaler som forlenger levetid: publishing-edge, siste tilgang
  (last_accessed_at), utranskribert lyd beholdes
- Tre-trinns disk-nødventil:
  - >85%: slett generert innhold (TTS osv, kan regenereres)
  - >90%: aggressiv pruning med kraftig redusert TTL
  - >95%: kritisk — alt uten publishing-edge slettes
- Periodisk bakgrunnsloop: hvert 6. time, oftere ved høy disk
- Tilgangslogging: serving oppdaterer last_accessed_at (fire-and-forget)
- Pruning-hendelser logges til resource_usage_log

Ny modul: maskinrommet/src/pruning.rs
Ny migrasjon: 010_pruning.sql (last_accessed_at kolonne + indeks)
CasStore utvidet med delete(), disk_usage_bytes(), disk_usage_percent()

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 00:02:27 +00:00

465 lines
15 KiB
Rust

//! 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<Utc>,
last_accessed_at: Option<DateTime<Utc>>,
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<PruneResult, String> {
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<PruneCandidate> = 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, 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;
}
}
}
});
}