From e5aacf9b3fc79924882d073c8ed2b4e386a08ad7 Mon Sep 17 00:00:00 2001 From: DonVoo Date: Thu, 16 Jul 2026 21:44:20 +0200 Subject: [PATCH] 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 --- backup-brief.md | 143 +++++++++++++++++++++++++++++++++++++++++++++ uid-delta-brief.md | 40 +++++++++---- 2 files changed, 172 insertions(+), 11 deletions(-) create mode 100644 backup-brief.md diff --git a/backup-brief.md b/backup-brief.md new file mode 100644 index 0000000..a728fc0 --- /dev/null +++ b/backup-brief.md @@ -0,0 +1,143 @@ +# 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.** +```go +// 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 '/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 erzeugt DB-Schnappschuss + mbox-Praefixe +--verify-snapshot 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. diff --git a/uid-delta-brief.md b/uid-delta-brief.md index 7ebd640..48fb515 100644 --- a/uid-delta-brief.md +++ b/uid-delta-brief.md @@ -30,15 +30,31 @@ Doku sauber halten — wir haben sie selbst kurz verwechselt. ## Die Idee -**Die UID ist die einzige Kennung, die ohne Body zu haben ist.** Sie bricht die -Ringabhaengigkeit. `UID FETCH :* (UID)` kostet einen Roundtrip statt -20 GB. +`UID FETCH :* (UID)` kostet einen Roundtrip statt 20 GB. -Der Preis: UIDs sind Server-Zusagen, keine Wahrheiten. Sie gelten nur, solange -`UIDVALIDITY` konstant bleibt. **Deshalb ist die UIDVALIDITY-Pruefung kein -Beiwerk, sondern das, was die Abkuerzung ueberhaupt erst zulaessig macht.** -Ohne sie tauschen wir einen teuren Beweis gegen eine billige Annahme — bei -diesem Anbieter die schlechteste aller Ideen. +### Die UID ist ein CURSOR, keine Mail-Identitaet + +Das ist die wichtigste Abgrenzung des ganzen Briefes, bitte nicht verwaschen: + +- **`UIDVALIDITY + UID` entscheidet nur, WELCHE Mail neu untersucht werden + muss.** Das ist eine billige Vorauswahl von Kandidaten. +- **Erst fuer diese neuen UIDs wird der Body geladen, und daraus weiterhin die + kanonische Identitaet `(normalisierte Message-ID, body_sha256 ueber + canonicalMessageBytes)` berechnet.** +- **`body_sha256` bleibt die Wahrheit.** Die UID ersetzt sie an keiner Stelle. + Sie vermeidet ausschliesslich den erneuten Download alter Kandidaten. + +Der Watcher schwaecht die Identitaetsgarantie also **nicht** ab — er spart nur +den Weg zu Kandidaten, die wir ohnehin verwerfen wuerden. Wer die UID zur +Identitaet befoerdert, hat den Brief falsch gelesen: UIDs sind +Server-Zusagen, keine Wahrheiten. + +### Warum die UIDVALIDITY-Pruefung nicht optional ist + +Die Zusage gilt nur, solange `UIDVALIDITY` konstant bleibt. **Deshalb ist die +UIDVALIDITY-Pruefung kein Beiwerk, sondern das, was die Abkuerzung ueberhaupt +erst zulaessig macht.** Faellt sie weg, tauschen wir einen teuren Beweis gegen +eine billige Annahme — bei diesem Anbieter die schlechteste aller Ideen. ## Was zu bauen ist @@ -63,9 +79,11 @@ Ablauf je Ordner: Loggen, melden, komplett neu einlesen. Der Fall ist selten und teuer — genau darum darf er nicht leise passieren. 3. Kein Eintrag vorhanden → Volllauf, danach Cursor setzen. -4. Sonst: `UID FETCH (last_uid+1):* (UID)` → nur diese UIDs mit Body holen und - durch die **unveraenderte** Pipeline schicken: - `AlreadyCopied -> mbox.Append -> dst.Append -> MarkCopied`. +4. Sonst: `UID FETCH (last_uid+1):* (UID)` → **nur UIDs, kein Body.** Fuer genau + diese UIDs dann den Body holen und durch die **unveraenderte** Pipeline + schicken: `AlreadyCopied -> mbox.Append -> dst.Append -> MarkCopied`. + Die Identitaet wird dort wie bisher aus dem Body berechnet — der Watcher + liefert Kandidaten, keine Identitaeten. 5. Cursor **erst nach erfolgreichem Durchlauf** aller neuen Mails setzen. Bricht etwas ab, bleibt der alte Stand stehen. Lieber zweimal lesen als einmal ueberspringen.