Mail-Graveyard/backup-brief.md
DonVoo e5aacf9b3f Briefe: UID ist Cursor (keine Identitaet), Backup-Weg ohne Stillstand
uid-delta-brief.md:
  UIDVALIDITY+UID waehlt nur KANDIDATEN aus. Body wird weiterhin geladen,
  Identitaet weiterhin aus canonicalMessageBytes berechnet. body_sha256
  bleibt die Wahrheit; die UID spart nur den Download alter Kandidaten.

backup-brief.md (Pos-81):
  DB und mbox sind NICHT symmetrisch abhaengig - mbox ohne DB ist per
  Reindex heilbar, DB ohne mbox ist wertlos. Gefaehrlich ist allein
  neuere DB + aeltere mbox. Daraus die Reihenfolge-Regel: DB zuerst.

  Gemessen: /mnt/DATA ist ext4 ohne LVM -> atomarer Snapshot unmoeglich.
  Aber mbox ist strikt append-only (06-mbox.go:61, kein O_TRUNC/Truncate/
  Rename), und MAX(file_offset+frame_len) aus mbox_index ist die exakte
  Schreib-Wasserlinie - ueber alle 228 mbox-Dateien 0 Abweichungen.
  Daraus: VACUUM INTO + Praefix-Kopie = konsistenter Stand OHNE Stillstand.
  Falle notiert: mbox_index_state.indexed_bytes ist NICHT die Wasserlinie.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 21:44:20 +02:00

6.2 KiB

Codex-Brief — Sicherung des Archivs (mbox + DB als EIN Stand)

Betrifft Pos-81 des Ledger-Vorgangs netcup-svr-mail-graveyard-eklarner-sicherung-2026-07-16.

Die Regel

mbox ist der Inhaltsbeleg, die DB ist Index und Zustandsbuchhaltung. Beide gehoeren in denselben Sicherungszeitpunkt — aber nur die DB ist aus der mbox rekonstruierbar.

Die Abhaengigkeit ist nicht symmetrisch. Das ist der Kern:

Kombination Bewertung
mbox ohne DB heilbar. --reindex baut den Index vollstaendig neu (main.go:26, ReindexArchives, plain und zstd). Wir haben das bei der Index-Drift real benutzt.
DB ohne mbox wertlos. Die DB enthaelt nur Offsets, Hashes und Zustaende — keinen einzigen Mailinhalt.
neuere mbox + aeltere DB grundsaetzlich heilbar per Reindex. Die ungefaehrliche Richtung.
neuere DB + aeltere mbox GEFAEHRLICH. copied markiert eine Mail als gesichert, obwohl ihr Archivdatensatz im Backup fehlt. Ein stiller Verlust, der sich als gruener Haken tarnt.

Daraus folgt eine Reihenfolge-Regel, falls kein gemeinsamer Stand erzeugt werden kann: erst die DB sichern, dann die mbox. Dann ist die mbox im Backup immer die neuere — die heilbare Richtung. Die umgekehrte Reihenfolge produziert exakt den gefaehrlichen Fall.

Was der Host hergibt

Gemessen auf netcup-svr am 2026-07-16:

/mnt/DATA        ext4 auf /dev/vda4, noatime lazytime
LVM              nicht vorhanden
btrfs/ZFS        nicht vorhanden
SQLite           journal_mode=wal, WAL 34 MB unmerged, integrity_check=ok
data/ + backup/  liegen unter EINEM Elternbestand
                 (/mnt/DATA/Containers/Test-Container/Mail-Graveyard-Pod/)

Der atomare Dateisystem-Snapshot — die eigentlich beste Loesung — steht auf diesem Host nicht zur Verfuegung. Ein Wechsel auf LVM/btrfs/ZFS waere die saubere Antwort, ist aber ein Eingriff am laufenden Rettungsbetrieb. Nicht jetzt.

Nebenbefund: Ein blosses cp der .db ohne -wal wuerde die neuesten 34 MB Zustand verlieren. Das ist zufaellig die heilbare Richtung (aeltere DB), aber verlassen darf man sich darauf nicht.

Der Weg, den das Append-Only-Design erlaubt

Zwei gemessene Eigenschaften machen einen dritten Weg moeglich, der keinen Stillstand braucht:

1. Die mbox-Dateien sind strikt append-only.

// 06-mbox.go:61 — der einzige Schreibpfad
f, err := os.OpenFile(path, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0o600)

Kein O_TRUNC, kein Truncate, kein Rename, keine Kompaktierung in der gesamten Datei. Einmal geschriebene Bytes aendern sich nie mehr. Ein Praefix [0, N) ist damit zu jedem spaeteren Zeitpunkt byte-identisch mit dem Stand von damals.

2. Die DB kennt die exakte Schreib-Wasserlinie. MAX(file_offset + frame_len) aus mbox_index ist per Konstruktion eine Grenze auf vollstaendigen Datensaetzen. Geprueft ueber alle 228 mbox-Dateien: 0 Abweichungen zur echten Dateigroesse.

Nicht verwenden: mbox_index_state.indexed_bytes. Das ist eine Lese-/ Viewer-Marke (02-database.go:281 setzt sie beim Reindex auf -1) und laeuft dem Schreibstand um Groessenordnungen hinterher — bei e.klarner/INBOX 4,5 MB gegenueber 9,4 GB Datei. Diese Falle hat mich beim Entwurf einmal erwischt.

Das Verfahren

1. VACUUM INTO '<snap>/mail-graveyard.db'
   -> konsistenter DB-Stand einer LAUFENDEN WAL-Datenbank, ohne Stopp.
      SQLite garantiert das; WAL und -shm werden mit aufgeloest.

2. Aus DIESEM Schnappschuss je Konto/Ordner:
   N = SELECT MAX(file_offset+frame_len) FROM mbox_index WHERE ...

3. Je mbox die ersten N Bytes kopieren (in Ruhe, waehrend das Tool weiterlaeuft).

4. Fertig. Kein Stillstand, kein LVM, kein Stoppen der Container.

Warum das ein Stand ist und kein Widerspruch: N stammt aus dem DB-Schnappschuss selbst. Jeder copied- und jeder mbox_index-Eintrag darin liegt per Konstruktion vollstaendig innerhalb des Praefix. Mails, die waehrend des Kopierens ankommen, liegen jenseits von N und sind in beiden Haelften abwesend. Die gefaehrliche Kombination „neuere DB + aeltere mbox" kann gar nicht entstehen — nicht weil wir schnell sind, sondern weil das Paar konstruktiv einen Zeitpunkt beschreibt.

Sollte doch etwas driften, zeigt es in die heilbare Richtung (Praefix enthaelt mindestens, was die DB kennt → Reindex).

Der Fallback, falls Schritt 1-3 nicht gebaut wird

Deine urspruengliche Variante bleibt gueltig und ist als Handgriff sofort verfuegbar: schreibende Container kurz stoppen, WAL sauber checkpointen (PRAGMA wal_checkpoint(TRUNCATE)), gemeinsamen Kopierstand erzeugen, starten. Nur: ohne Snapshot-Dateisystem muesste hier fuer die vollen 42 GB gestoppt werden — genau das vermeidet der Praefix-Weg.

Was zu bauen ist

--snapshot <zielverzeichnis>    erzeugt DB-Schnappschuss + mbox-Praefixe
--verify-snapshot <verzeichnis> prueft einen Stand isoliert durch

Fallstricke

  1. N NIEMALS per stat von der Datei nehmen. Dann kann die Grenze mitten in eine gerade geschriebene Mail fallen. N kommt aus dem DB-Schnappschuss, sonst ist die ganze Garantie hin.
  2. Reihenfolge ist Teil der Korrektheit: erst VACUUM INTO, dann N daraus lesen. Nicht umgekehrt, nicht aus der Live-DB.
  3. indexed_bytes ist nicht die Wasserlinie (s. o.).
  4. Der Snapshot ist kein Backup. Er liegt auf derselben Platte. Er ist die Vorstufe: von ihm wird raeumlich getrennt und moeglichst verschluesselt weggeschrieben.
  5. Kein stiller Default. Ohne Flag aendert sich nichts.

Abnahme

  1. go test ./... gruen.
  2. --snapshot waehrend eines laufenden Migrationslaufs: Der Stand muss danach in sich stimmen. Das ist der eigentliche Test — ein Snapshot im Leerlauf beweist nichts.
  3. Restore isoliert testen (nicht auf der Produktions-DB):
    • PRAGMA integrity_check = ok
    • --reindex all --rebuild auf dem Restore laeuft durch
    • Indexanzahl stimmt mit SELECT COUNT(*) FROM mbox_index des Schnappschusses ueberein
    • Hashabdeckung: jede copied-Zeile mit body_sha256 <> '' hat einen mbox_index-Eintrag
    • Pending-Stufen: mbox_done/target_done plausibel, keine Zeile mit mbox_done=1 ohne Archivdatensatz
  4. Der Restore-Test gehoert wiederholt gefahren, nicht einmal. Ein Backup, das nie zurueckgespielt wurde, ist eine Behauptung.