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>
This commit is contained in:
DonVoo 2026-07-16 21:44:20 +02:00
parent 645ce25848
commit e5aacf9b3f
2 changed files with 172 additions and 11 deletions

143
backup-brief.md Normal file
View file

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

View file

@ -30,15 +30,31 @@ Doku sauber halten — wir haben sie selbst kurz verwechselt.
## Die Idee ## Die Idee
**Die UID ist die einzige Kennung, die ohne Body zu haben ist.** Sie bricht die `UID FETCH <cursor+1>:* (UID)` kostet einen Roundtrip statt 20 GB.
Ringabhaengigkeit. `UID FETCH <cursor+1>:* (UID)` kostet einen Roundtrip statt
20 GB.
Der Preis: UIDs sind Server-Zusagen, keine Wahrheiten. Sie gelten nur, solange ### Die UID ist ein CURSOR, keine Mail-Identitaet
`UIDVALIDITY` konstant bleibt. **Deshalb ist die UIDVALIDITY-Pruefung kein
Beiwerk, sondern das, was die Abkuerzung ueberhaupt erst zulaessig macht.** Das ist die wichtigste Abgrenzung des ganzen Briefes, bitte nicht verwaschen:
Ohne sie tauschen wir einen teuren Beweis gegen eine billige Annahme — bei
diesem Anbieter die schlechteste aller Ideen. - **`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 ## Was zu bauen ist
@ -63,9 +79,11 @@ Ablauf je Ordner:
Loggen, melden, komplett neu einlesen. Der Fall ist selten und teuer — Loggen, melden, komplett neu einlesen. Der Fall ist selten und teuer —
genau darum darf er nicht leise passieren. genau darum darf er nicht leise passieren.
3. Kein Eintrag vorhanden → Volllauf, danach Cursor setzen. 3. Kein Eintrag vorhanden → Volllauf, danach Cursor setzen.
4. Sonst: `UID FETCH (last_uid+1):* (UID)` → nur diese UIDs mit Body holen und 4. Sonst: `UID FETCH (last_uid+1):* (UID)`**nur UIDs, kein Body.** Fuer genau
durch die **unveraenderte** Pipeline schicken: diese UIDs dann den Body holen und durch die **unveraenderte** Pipeline
`AlreadyCopied -> mbox.Append -> dst.Append -> MarkCopied`. 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. 5. Cursor **erst nach erfolgreichem Durchlauf** aller neuen Mails setzen.
Bricht etwas ab, bleibt der alte Stand stehen. Lieber zweimal lesen als Bricht etwas ab, bleibt der alte Stand stehen. Lieber zweimal lesen als
einmal ueberspringen. einmal ueberspringen.