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>
This commit is contained in:
parent
70fb5eb141
commit
6d916d9860
6 changed files with 552 additions and 2 deletions
|
|
@ -99,6 +99,65 @@ impl CasStore {
|
||||||
hex::encode(hasher.finalize())
|
hex::encode(hasher.finalize())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Slett en fil fra CAS. Returnerer antall bytes frigitt, eller 0 hvis filen ikke fantes.
|
||||||
|
pub async fn delete(&self, hash: &str) -> std::io::Result<u64> {
|
||||||
|
let path = self.path_for(hash);
|
||||||
|
match tokio::fs::metadata(&path).await {
|
||||||
|
Ok(meta) => {
|
||||||
|
let size = meta.len();
|
||||||
|
tokio::fs::remove_file(&path).await?;
|
||||||
|
tracing::info!(hash = %hash, size = size, "Slettet fil fra CAS");
|
||||||
|
Ok(size)
|
||||||
|
}
|
||||||
|
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(0),
|
||||||
|
Err(e) => Err(e),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Beregn total diskbruk for CAS-katalogen (bytes).
|
||||||
|
/// Bruker `du -sb` for ytelse — traverserer filsystemet direkte.
|
||||||
|
pub async fn disk_usage_bytes(&self) -> std::io::Result<u64> {
|
||||||
|
let output = tokio::process::Command::new("du")
|
||||||
|
.args(["-sb", self.root.to_str().unwrap_or(".")])
|
||||||
|
.output()
|
||||||
|
.await?;
|
||||||
|
if !output.status.success() {
|
||||||
|
return Err(std::io::Error::new(
|
||||||
|
std::io::ErrorKind::Other,
|
||||||
|
"du-kommando feilet",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||||
|
let bytes: u64 = stdout
|
||||||
|
.split_whitespace()
|
||||||
|
.next()
|
||||||
|
.and_then(|s| s.parse().ok())
|
||||||
|
.unwrap_or(0);
|
||||||
|
Ok(bytes)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sjekk diskbruk-prosent for partisjonen CAS ligger på.
|
||||||
|
pub async fn disk_usage_percent(&self) -> std::io::Result<f64> {
|
||||||
|
let output = tokio::process::Command::new("df")
|
||||||
|
.args(["--output=pcent", self.root.to_str().unwrap_or("/")])
|
||||||
|
.output()
|
||||||
|
.await?;
|
||||||
|
if !output.status.success() {
|
||||||
|
return Err(std::io::Error::new(
|
||||||
|
std::io::ErrorKind::Other,
|
||||||
|
"df-kommando feilet",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||||
|
// Output: "Use%\n 42%\n"
|
||||||
|
let pct: f64 = stdout
|
||||||
|
.lines()
|
||||||
|
.nth(1)
|
||||||
|
.and_then(|line| line.trim().trim_end_matches('%').parse().ok())
|
||||||
|
.unwrap_or(0.0);
|
||||||
|
Ok(pct)
|
||||||
|
}
|
||||||
|
|
||||||
/// Rot-katalogen for CAS.
|
/// Rot-katalogen for CAS.
|
||||||
pub fn root(&self) -> &Path {
|
pub fn root(&self) -> &Path {
|
||||||
&self.root
|
&self.root
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ pub mod cas;
|
||||||
mod intentions;
|
mod intentions;
|
||||||
pub mod jobs;
|
pub mod jobs;
|
||||||
pub mod livekit;
|
pub mod livekit;
|
||||||
|
pub mod pruning;
|
||||||
mod queries;
|
mod queries;
|
||||||
mod serving;
|
mod serving;
|
||||||
mod stdb;
|
mod stdb;
|
||||||
|
|
@ -130,6 +131,9 @@ async fn main() {
|
||||||
// Start jobbkø-worker i bakgrunnen
|
// Start jobbkø-worker i bakgrunnen
|
||||||
jobs::start_worker(db.clone(), stdb.clone(), cas.clone());
|
jobs::start_worker(db.clone(), stdb.clone(), cas.clone());
|
||||||
|
|
||||||
|
// Start periodisk CAS-pruning i bakgrunnen
|
||||||
|
pruning::start_pruning_loop(db.clone(), cas.clone());
|
||||||
|
|
||||||
let state = AppState { db, jwks, stdb, cas };
|
let state = AppState { db, jwks, stdb, cas };
|
||||||
|
|
||||||
// Ruter: /health er offentlig, /me krever gyldig JWT
|
// Ruter: /health er offentlig, /me krever gyldig JWT
|
||||||
|
|
|
||||||
465
maskinrommet/src/pruning.rs
Normal file
465
maskinrommet/src/pruning.rs
Normal file
|
|
@ -0,0 +1,465 @@
|
||||||
|
//! 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -61,6 +61,13 @@ pub async fn get_cas_file(
|
||||||
StatusCode::INTERNAL_SERVER_ERROR
|
StatusCode::INTERNAL_SERVER_ERROR
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
|
// Oppdater last_accessed_at asynkront (fire-and-forget for ytelse)
|
||||||
|
let db_clone = state.db.clone();
|
||||||
|
let hash_clone = hash.clone();
|
||||||
|
tokio::spawn(async move {
|
||||||
|
crate::pruning::touch_access(&db_clone, &hash_clone).await;
|
||||||
|
});
|
||||||
|
|
||||||
let stream = ReaderStream::new(file);
|
let stream = ReaderStream::new(file);
|
||||||
let body = Body::from_stream(stream);
|
let body = Body::from_stream(stream);
|
||||||
|
|
||||||
|
|
|
||||||
16
migrations/010_pruning.sql
Normal file
16
migrations/010_pruning.sql
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
-- 010_pruning.sql
|
||||||
|
-- Støtte for pruning-logikk: TTL per modalitet, signaler som forlenger levetid.
|
||||||
|
-- Ref: docs/retninger/maskinrommet.md § CAS og intelligent pruning
|
||||||
|
|
||||||
|
BEGIN;
|
||||||
|
|
||||||
|
-- last_accessed_at: sporer siste gang en CAS-fil ble servert.
|
||||||
|
-- Brukes som signal for å forlenge TTL — "noen bruker denne filen aktivt".
|
||||||
|
ALTER TABLE nodes ADD COLUMN IF NOT EXISTS last_accessed_at TIMESTAMPTZ;
|
||||||
|
|
||||||
|
-- Indeks for pruning-spørringer: finn media-noder sortert etter alder/tilgang.
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_nodes_media_access
|
||||||
|
ON nodes (node_kind, last_accessed_at, created_at)
|
||||||
|
WHERE node_kind = 'media';
|
||||||
|
|
||||||
|
COMMIT;
|
||||||
3
tasks.md
3
tasks.md
|
|
@ -125,8 +125,7 @@ Uavhengige faser kan fortsatt plukkes.
|
||||||
|
|
||||||
- [x] 11.1 LiveKit oppsett: Docker-container for WebRTC. Ref: `docs/setup/produksjon.md`.
|
- [x] 11.1 LiveKit oppsett: Docker-container for WebRTC. Ref: `docs/setup/produksjon.md`.
|
||||||
- [x] 11.2 Sanntidslyd: kommunikasjonsnode med live-status → LiveKit-rom for deltakere.
|
- [x] 11.2 Sanntidslyd: kommunikasjonsnode med live-status → LiveKit-rom for deltakere.
|
||||||
- [~] 11.3 Pruning-logikk: TTL per modalitet, signaler som forlenger levetid, disk-nødventil.
|
- [x] 11.3 Pruning-logikk: TTL per modalitet, signaler som forlenger levetid, disk-nødventil.
|
||||||
> Påbegynt: 2026-03-17T23:55
|
|
||||||
- [ ] 11.4 Podcast-RSS: samlings-node med publiserings-edges → generert RSS-feed.
|
- [ ] 11.4 Podcast-RSS: samlings-node med publiserings-edges → generert RSS-feed.
|
||||||
|
|
||||||
## Fase 13: Trait-system
|
## Fase 13: Trait-system
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue