Fase 11 (produksjon) validert — LiveKit, pruning og podcast-RSS: - rss.rs + synops-rss: Les filstørrelse fra både 'size_bytes' (intentions) og 'size' (publishing) med COALESCE — forhindrer manglende enclosure- størrelse i podcast-feeds avhengig av opplastingsmetode. - pruning.rs + synops-prune: Samme COALESCE-fix for konsistent size-tracking. - rss.rs + synops-rss: Fiks truncate_description til å bruke char-indeksering istedenfor byte-indeksering — forhindrer panic på norsk tekst (å, ø, æ). LiveKit kjører i Docker (healthy), token-generering via join_communication, pruning-loop aktiv, RSS-endepunkt returnerer korrekt 404 for ukjent slug. Alle 61 maskinrommet-tester bestått.
465 lines
15 KiB
Rust
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, (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;
|
|
}
|
|
}
|
|
}
|
|
});
|
|
}
|