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:
parent
645ce25848
commit
e5aacf9b3f
2 changed files with 172 additions and 11 deletions
143
backup-brief.md
Normal file
143
backup-brief.md
Normal 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.
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue