Backup: daglig PG-dump, STDB-krasj-recovery, helsesjekk (oppgave 12.2)

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
This commit is contained in:
vegard 2026-03-18 11:11:32 +00:00
parent 4d7852f466
commit 0f03886091
6 changed files with 344 additions and 37 deletions

113
docs/infra/backup.md Normal file
View file

@ -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).

View file

@ -336,16 +336,10 @@ async fn collect_pg_stats(db: &PgPool) -> PgStats {
// =============================================================================
fn check_backups() -> Vec<BackupInfo> {
// 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<BackupInfo> {
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::<chrono::Utc>::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::<chrono::Utc>::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
}

View file

@ -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());

View file

@ -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<AtomicBool>,
}
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<AtomicBool>) {
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);
}

56
scripts/backup-pg.sh Executable file
View file

@ -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"

View file

@ -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.