From 0f038860917420e672d43541a573b22cd9274e14 Mon Sep 17 00:00:00 2001 From: vegard Date: Wed, 18 Mar 2026 11:11:32 +0000 Subject: [PATCH] Backup: daglig PG-dump, STDB-krasj-recovery, helsesjekk (oppgave 12.2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tre ting implementert: 1. PG-dump rutine (scripts/backup-pg.sh): - Daglig cron kl. 03:00 UTC via /etc/cron.d/synops-backup - pg_dump -Fc (custom format, komprimert), konsistent uten nedetid - Rotasjon: beholder 30 dager, sletter eldre - Verifiserer at dump-filen er gyldig (ikke tom) 2. STDB → PG gjenoppbygging ved krasj (stdb_monitor.rs): - Bakgrunnsmonitor sjekker STDB hvert 30. sekund - Oppdager krasj (var oppe → nå nede) - Venter på at containeren restarter (maks 10 min) - Kjører warmup (PG → STDB) automatisk - Hele prosessen logges 3. Forbedret backup-helsesjekk (health.rs): - Sjekker /srv/synops/backup/pg/ for nyeste dump - Rapporterer ok/stale/missing i /admin/health --- docs/infra/backup.md | 113 ++++++++++++++++++++++++ maskinrommet/src/health.rs | 60 ++++++------- maskinrommet/src/main.rs | 4 + maskinrommet/src/stdb_monitor.rs | 145 +++++++++++++++++++++++++++++++ scripts/backup-pg.sh | 56 ++++++++++++ tasks.md | 3 +- 6 files changed, 344 insertions(+), 37 deletions(-) create mode 100644 docs/infra/backup.md create mode 100644 maskinrommet/src/stdb_monitor.rs create mode 100755 scripts/backup-pg.sh diff --git a/docs/infra/backup.md b/docs/infra/backup.md new file mode 100644 index 0000000..8068f13 --- /dev/null +++ b/docs/infra/backup.md @@ -0,0 +1,113 @@ +# Backup og gjenoppbygging + +**Filsti:** `docs/infra/backup.md` + +Synops sin backup-strategi bygger på én innsikt: **PostgreSQL er den +eneste autoriteten.** SpacetimeDB er en sanntidscache som gjenoppbygges +fra PG ved behov. Media-filer i CAS er innholdsadresserte og immutable. + +## Arkitektur + +``` +PostgreSQL (autoritativ kilde) + │ + ├──→ pg_dump daglig (03:00 UTC) + │ └──→ /srv/synops/backup/pg/sidelinja_YYYYMMDD_HHMMSS.dump + │ └──→ Rotasjon: 30 dager + │ + └──→ SpacetimeDB (sanntidscache) + └──→ Gjenoppbygges fra PG ved krasj (warmup) +``` + +## 1. PG-dump (daglig) + +**Script:** `scripts/backup-pg.sh` +**Cron:** `/etc/cron.d/synops-backup` — `0 3 * * *` +**Logg:** `/srv/synops/logs/backup-pg.log` +**Dumper:** `/srv/synops/backup/pg/` + +Prosess: +1. Sjekker at PG-containeren kjører +2. `pg_dump -Fc` (custom format, komprimert) — konsistent snapshot uten nedetid +3. Verifiserer at dump-filen ikke er tom +4. Sletter dumper eldre enn 30 dager + +Manuell kjøring: +```bash +/home/vegard/synops/scripts/backup-pg.sh +``` + +Verifiser dump: +```bash +docker cp /srv/synops/backup/pg/DUMP.dump sidelinja-postgres-1:/tmp/test.dump +docker exec sidelinja-postgres-1 pg_restore --list /tmp/test.dump +docker exec sidelinja-postgres-1 rm /tmp/test.dump +``` + +## 2. STDB-gjenoppbygging ved krasj + +**Modul:** `maskinrommet/src/stdb_monitor.rs` + +SpacetimeDB er en sanntidscache. Hvis den krasjer, tapes ingen data +fordi all skriving går gjennom maskinrommet som skriver til PG først +(asynkront, men alltid). Gjenoppbygging skjer automatisk: + +### Ved oppstart +Maskinrommet kjører `warmup::run()` i `main.rs` — laster alle noder, +edges og node_access fra PG til STDB. + +### Ved krasj under drift +`stdb_monitor` kjører i bakgrunnen og sjekker STDB hvert 30. sekund: + +1. **Oppdager** at STDB ikke svarer (var oppe, nå nede) +2. **Venter** opptil 10 minutter på at containeren restarter +3. **Kjører warmup** (PG → STDB) når STDB svarer igjen +4. **Logger** hele hendelsesforløpet + +Prosessen er automatisk og krever ingen manuell inngripen så lenge +Docker restarter containeren (restart-policy: `unless-stopped`). + +## 3. Restore fra backup + +### PostgreSQL +```bash +# Stopp maskinrommet (unngå skrivinger under restore) +sudo systemctl stop maskinrommet + +# Restore fra dump +docker cp /srv/synops/backup/pg/sidelinja_YYYYMMDD.dump sidelinja-postgres-1:/tmp/restore.dump +docker exec sidelinja-postgres-1 pg_restore -U sidelinja -d sidelinja --clean /tmp/restore.dump +docker exec sidelinja-postgres-1 rm /tmp/restore.dump + +# Start maskinrommet (warmup laster PG → STDB automatisk) +sudo systemctl start maskinrommet +``` + +### Komplett gjenoppbygging +Ved total serversvikt (ny VPS): +1. Installer OS og Docker (se `docs/setup/produksjon.md`) +2. Start PG-container +3. Restore dump (se over) +4. Start maskinrommet (warmup håndterer STDB) +5. Avledede data (segmenter, søkeindeks) regenereres fra kildene + +## 4. Overvåking + +Health-dashboardet (`/admin/health`) viser backup-status: +- **ok** — dump-fil er fersk (< 25 timer gammel) +- **stale** — dump-fil er eldre enn 25 timer +- **missing** — ingen dump-filer funnet + +Metrikk-endepunktet (`/metrics`) inkluderer STDB-status som del av +helsesjekken. + +## 5. Hva som IKKE backupes (bevisst) + +- **SpacetimeDB** — sanntidscache, gjenoppbygges fra PG +- **Redis** — cache, regenereres automatisk +- **Caddy-data** — sertifikater regenereres av Let's Encrypt +- **Whisper-modeller** — re-download fra HuggingFace +- **Logger** — rulleres med logrotate + +Se `docs/setup/produksjon.md` § 11 for fullstendig backup-spesifikasjon +inkludert off-site backup (rclone) og WAL-arkivering (fremtidig). diff --git a/maskinrommet/src/health.rs b/maskinrommet/src/health.rs index c800906..3d9229b 100644 --- a/maskinrommet/src/health.rs +++ b/maskinrommet/src/health.rs @@ -336,16 +336,10 @@ async fn collect_pg_stats(db: &PgPool) -> PgStats { // ============================================================================= fn check_backups() -> Vec { - // Sjekk om det finnes PG-dumper i standard backup-kataloger - let backup_paths = [ - "/srv/synops/backups", - "/srv/synops/data/backups", - "/var/backups/synops", - ]; - let mut backups = Vec::new(); - // PG-dump + // PG-dump — sjekk /srv/synops/backup/pg/ for nyeste .dump-fil + let pg_dir = "/srv/synops/backup/pg"; let mut pg_backup = BackupInfo { backup_type: "PostgreSQL dump".to_string(), last_success: None, @@ -353,41 +347,37 @@ fn check_backups() -> Vec { status: "missing".to_string(), }; - for dir in &backup_paths { - if let Ok(entries) = std::fs::read_dir(dir) { - for entry in entries.flatten() { - let name = entry.file_name().to_string_lossy().to_string(); - if name.contains("pg") || name.ends_with(".sql") || name.ends_with(".dump") { - if let Ok(meta) = entry.metadata() { - if let Ok(modified) = meta.modified() { - let age = modified.elapsed().unwrap_or_default(); - let ts = chrono::DateTime::::from(modified); - pg_backup.last_success = Some(ts.to_rfc3339()); - pg_backup.path = Some(entry.path().to_string_lossy().to_string()); - pg_backup.status = if age.as_secs() < 86400 { - "ok".to_string() - } else if age.as_secs() < 7 * 86400 { - "stale".to_string() - } else { - "stale".to_string() - }; + if let Ok(entries) = std::fs::read_dir(pg_dir) { + // Finn nyeste dump-fil + let mut newest: Option<(std::time::SystemTime, std::path::PathBuf)> = None; + for entry in entries.flatten() { + let name = entry.file_name().to_string_lossy().to_string(); + if name.ends_with(".dump") { + if let Ok(meta) = entry.metadata() { + if let Ok(modified) = meta.modified() { + if newest.as_ref().map_or(true, |(t, _)| modified > *t) { + newest = Some((modified, entry.path())); } } } } } + + if let Some((modified, path)) = newest { + let age = modified.elapsed().unwrap_or_default(); + let ts = chrono::DateTime::::from(modified); + pg_backup.last_success = Some(ts.to_rfc3339()); + pg_backup.path = Some(path.to_string_lossy().to_string()); + pg_backup.status = if age.as_secs() < 86400 + 3600 { + // Litt slakk: 25 timer (cron kjører daglig) + "ok".to_string() + } else { + "stale".to_string() + }; + } } backups.push(pg_backup); - - // CAS (media) backup - backups.push(BackupInfo { - backup_type: "CAS media".to_string(), - last_success: None, - path: None, - status: "missing".to_string(), - }); - backups } diff --git a/maskinrommet/src/main.rs b/maskinrommet/src/main.rs index 1e392ca..9fa189d 100644 --- a/maskinrommet/src/main.rs +++ b/maskinrommet/src/main.rs @@ -28,6 +28,7 @@ pub mod transcribe; pub mod tts; pub mod usage_overview; pub mod user_usage; +mod stdb_monitor; mod warmup; mod workspace; @@ -183,6 +184,9 @@ async fn main() { // Start nattlig bandwidth-parsing (oppgave 15.7) bandwidth::start_bandwidth_parser(db.clone()); + // Start STDB-overvåker: oppdager krasj og gjenoppbygger fra PG (oppgave 12.2) + stdb_monitor::start_stdb_monitor(db.clone(), stdb.clone()); + // Start periodisk CAS tmp-opprydding (oppgave 17.6) cas::start_tmp_cleanup_loop(cas.clone()); diff --git a/maskinrommet/src/stdb_monitor.rs b/maskinrommet/src/stdb_monitor.rs new file mode 100644 index 0000000..7ebdcf6 --- /dev/null +++ b/maskinrommet/src/stdb_monitor.rs @@ -0,0 +1,145 @@ +// STDB-overvåker: oppdager SpacetimeDB-krasj og gjenoppbygger fra PG. +// +// Kjører i bakgrunnen med jevnlig helsesjekk. Hvis STDB var oppe og +// deretter feiler, kjøres warmup automatisk for å gjenoppbygge tilstand. +// +// Sekvens ved krasj: +// 1. Oppdage at STDB er nede (helsesjekk feiler) +// 2. Vente til STDB er tilbake (container restarter) +// 3. Kjøre warmup (PG → STDB) +// 4. Logge hendelsen +// +// Ref: docs/infra/backup.md, docs/infra/synkronisering.md + +use sqlx::PgPool; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::Arc; + +use crate::stdb::StdbClient; + +/// Start STDB-overvåker i bakgrunnen. +/// Sjekker STDB-helse hvert 30. sekund og kjører warmup ved krasj. +pub fn start_stdb_monitor(db: PgPool, stdb: StdbClient) { + tokio::spawn(async move { + monitor_loop(db, stdb).await; + }); +} + +/// Intern tilstand for overvåkeren. +struct MonitorState { + /// Var STDB oppe ved forrige sjekk? + was_up: bool, + /// Pågår det en recovery akkurat nå? + recovering: Arc, +} + +async fn monitor_loop(db: PgPool, stdb: StdbClient) { + let mut state = MonitorState { + was_up: true, // Antar oppe etter warmup ved oppstart + recovering: Arc::new(AtomicBool::new(false)), + }; + + // Vent litt etter oppstart slik at warmup fullføres først + tokio::time::sleep(std::time::Duration::from_secs(60)).await; + tracing::info!("STDB-overvåker startet (sjekker hvert 30s)"); + + let mut interval = tokio::time::interval(std::time::Duration::from_secs(30)); + + loop { + interval.tick().await; + + // Ikke sjekk hvis recovery allerede pågår + if state.recovering.load(Ordering::Relaxed) { + continue; + } + + let is_up = check_stdb_health(&stdb).await; + + match (state.was_up, is_up) { + (true, false) => { + // STDB gikk ned! Logg og start recovery-venting. + tracing::error!("STDB-overvåker: SpacetimeDB er NEDE — starter recovery-prosess"); + state.recovering.store(true, Ordering::Relaxed); + + let db_clone = db.clone(); + let stdb_clone = stdb.clone(); + let recovering = state.recovering.clone(); + + tokio::spawn(async move { + recover_stdb(db_clone, stdb_clone, recovering).await; + }); + } + (false, true) => { + // STDB kom tilbake uten vår hjelp (recovery-tasken fikset det) + tracing::info!("STDB-overvåker: SpacetimeDB er tilbake"); + state.was_up = true; + } + (false, false) => { + // Fortsatt nede — recovery-tasken håndterer dette + } + (true, true) => { + // Alt OK + } + } + + if is_up { + state.was_up = true; + } else if !state.recovering.load(Ordering::Relaxed) { + state.was_up = false; + } + } +} + +/// Sjekk om STDB svarer på en enkel reducer-kall. +async fn check_stdb_health(stdb: &StdbClient) -> bool { + stdb.delete_node("__healthcheck_nonexistent__").await.is_ok() +} + +/// Vent til STDB er tilbake, deretter kjør warmup. +async fn recover_stdb(db: PgPool, stdb: StdbClient, recovering: Arc) { + let max_wait = std::time::Duration::from_secs(600); // Maks 10 min + let check_interval = std::time::Duration::from_secs(10); + let start = std::time::Instant::now(); + + tracing::info!("STDB-recovery: venter på at SpacetimeDB starter opp igjen (maks 10 min)"); + + // Vent til STDB svarer + loop { + if start.elapsed() > max_wait { + tracing::error!( + "STDB-recovery: SpacetimeDB kom ikke tilbake innen {} sekunder — gir opp", + max_wait.as_secs() + ); + recovering.store(false, Ordering::Relaxed); + return; + } + + tokio::time::sleep(check_interval).await; + + if check_stdb_health(&stdb).await { + tracing::info!( + "STDB-recovery: SpacetimeDB svarer igjen etter {}s", + start.elapsed().as_secs() + ); + break; + } + } + + // STDB er tilbake — kjør warmup + tracing::info!("STDB-recovery: kjører warmup (PG → STDB)"); + match crate::warmup::run(&db, &stdb).await { + Ok(stats) => { + tracing::info!( + "STDB-recovery: warmup fullført ({} noder, {} edges, {} access)", + stats.nodes, + stats.edges, + stats.access + ); + } + Err(e) => { + tracing::error!("STDB-recovery: warmup feilet: {e}"); + } + } + + recovering.store(false, Ordering::Relaxed); +} diff --git a/scripts/backup-pg.sh b/scripts/backup-pg.sh new file mode 100755 index 0000000..911a651 --- /dev/null +++ b/scripts/backup-pg.sh @@ -0,0 +1,56 @@ +#!/bin/bash +# backup-pg.sh — Daglig PostgreSQL-dump med rotasjon. +# +# Kjøres via cron (03:00 UTC). Dumper synops-databasen med pg_dump -Fc +# (custom format, komprimert). Beholder 30 dager, sletter eldre. +# +# Ref: docs/setup/produksjon.md § 11.1, docs/infra/backup.md + +set -euo pipefail + +BACKUP_DIR="/srv/synops/backup/pg" +CONTAINER="sidelinja-postgres-1" +DB_USER="sidelinja" +DB_NAME="sidelinja" +RETAIN_DAYS=30 +TIMESTAMP=$(date +%Y%m%d_%H%M%S) +DUMP_FILE="${BACKUP_DIR}/${DB_NAME}_${TIMESTAMP}.dump" +LOG_FILE="/srv/synops/logs/backup-pg.log" + +log() { + echo "$(date -Iseconds) $1" | tee -a "$LOG_FILE" +} + +# Sjekk at backup-katalog finnes +mkdir -p "$BACKUP_DIR" + +# Sjekk at PG-containeren kjører +if ! docker inspect "$CONTAINER" --format '{{.State.Running}}' 2>/dev/null | grep -q true; then + log "FEIL: PostgreSQL-container $CONTAINER kjører ikke" + exit 1 +fi + +# Kjør pg_dump (konsistent snapshot, ingen nedetid) +log "Starter PG-dump → $DUMP_FILE" +if docker exec "$CONTAINER" pg_dump -U "$DB_USER" -Fc "$DB_NAME" > "$DUMP_FILE" 2>>"$LOG_FILE"; then + # Verifiser at filen ikke er tom + FILESIZE=$(stat -c%s "$DUMP_FILE" 2>/dev/null || echo 0) + if [ "$FILESIZE" -lt 100 ]; then + log "FEIL: Dump-filen er for liten (${FILESIZE} bytes), noe gikk galt" + rm -f "$DUMP_FILE" + exit 1 + fi + log "PG-dump ferdig: ${DUMP_FILE} ($(numfmt --to=iec "$FILESIZE"))" +else + log "FEIL: pg_dump feilet" + rm -f "$DUMP_FILE" + exit 1 +fi + +# Rotasjon: slett dumper eldre enn $RETAIN_DAYS dager +DELETED=$(find "$BACKUP_DIR" -name "*.dump" -mtime +${RETAIN_DAYS} -print -delete | wc -l) +if [ "$DELETED" -gt 0 ]; then + log "Rotasjon: slettet $DELETED dump(er) eldre enn $RETAIN_DAYS dager" +fi + +log "Backup fullført OK" diff --git a/tasks.md b/tasks.md index 944c1d7..dd1cec5 100644 --- a/tasks.md +++ b/tasks.md @@ -267,7 +267,6 @@ kaller dem direkte. Samme verktøy, to brukere. ## Fase 12: Herding - [x] 12.1 Observerbarhet: strukturert logging, metrikker (request latency, queue depth, AI cost). -- [~] 12.2 Backup: PG-dump rutine, STDB → PG gjenoppbygging ved krasj. - > Påbegynt: 2026-03-18T11:05 +- [x] 12.2 Backup: PG-dump rutine, STDB → PG gjenoppbygging ved krasj. - [ ] 12.3 Feilhåndtering: retry med backoff i skrivestien, dead letter queue for feilede PG-skrivinger. - [ ] 12.4 Ytelse: profiler STDB-spørringer, optimaliser node_access-oppdatering.