syncova-backup/docs/backup-engine.md
Jerrit Fritzsche d94debac4d
Some checks failed
CI / Backend (Go) (push) Failing after 30s
CI / Frontend (React/TypeScript) (push) Successful in 46s
CI / Sicherheitsprüfungen (push) Successful in 27s
Dokumentation und Aenderungsliste fuer rc9
Die Sicherungsart steht in backup-engine.md, weil dort der Unterschied zwischen
voll und inkrementell erklaert ist — mit der Einordnung, die am haeufigsten
verwechselt wird: Der Platzbedarf steigt bei "immer voll" nicht nennenswert,
die Laufzeit schon.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 17:48:06 +02:00

8.4 KiB

Backup Engine

Dieses Dokument beschreibt den aktuell implementierten Stand nach Phase 4.

Pipeline

Lesen → Chunking → Hash → Deduplizierung → Kompression → Verschlüsselung
      → Schreiben → Manifest → Commit

Die Aufteilung ist dreigeteilt und folgt der Natur der Arbeit:

Stufe Nebenläufigkeit Grund
Leser + Chunker einfach Blockgrenzen hängen von den Vorgängerdaten ab
Arbeiter N-fach hier liegt die Rechenarbeit (Hash, zstd, AES)
Sammler einfach stellt die Reihenfolge über die Positionsnummer wieder her

Backpressure entsteht durch begrenzte Warteschlangen (Standardtiefe 4). Ist die Schlange voll, blockiert der Leser. Ohne diese Grenze läse der Chunker so schnell, wie die Quelle liefert, und füllte den Speicher mit unverarbeiteten Blöcken.

Inhaltsabhängiges Chunking

Blockgrenzen ergeben sich aus dem Inhalt, nicht aus festen Abständen — über einen Gear-Rolling-Hash mit 20-Bit-Maske (Zielgröße 1 MiB, Grenzen 256 KiB bis 4 MiB).

Der Unterschied ist entscheidend: Wird mitten in einer Datei etwas eingefügt, verschiebt eine feste Aufteilung alle folgenden Blöcke und macht die Deduplizierung wirkungslos. Inhaltsabhängige Grenzen wandern mit dem Inhalt mit.

Die Substitutionstabelle wird deterministisch erzeugt (splitmix64) und muss über alle Installationen identisch sein — sonst fänden zwei Systeme unterschiedliche Grenzen und könnten ihre Backups nicht gegenseitig deduplizieren.

Der Rolling Hash dient nur der Grenzfindung. Die Chunk-Kennung entsteht weiterhin aus SHA-256.

Deduplizierung trotz Verschlüsselung

Das ist der heikelste Punkt der Engine, und er hat zwei Fallen:

Falle 1: Die Kennung. Verschlüsselte Daten sind bei jedem Durchgang verschieden. Wäre die Chunk-Kennung der Hash des Geheimtextes, fänden zwei gleiche Ursprungsblöcke nie zusammen. Die Kennung ist deshalb der Hash des Klartextes; abgelegt wird die transformierte Form unter dieser Kennung.

Falle 2: Der Schlüssel. Ein Datenschlüssel je Backup macht Deduplizierung über Backupgrenzen hinweg unmöglich — ein späteres Backup verwiese auf Blöcke, die mit einem fremden Schlüssel verschlüsselt und für es unlesbar wären. Der Schlüssel gehört deshalb zum Repository und liegt unter metadata/data-key.json, verschlüsselt mit dem übergeordneten Schlüssel.

Diese zweite Falle wurde von einem Test aufgedeckt, nicht beim Entwurf erkannt.

Falle 3: Die Nonce. Ein Zähler wäre eindeutig, ergäbe aber für denselben Klartext bei jedem Lauf einen anderen Geheimtext — die Deduplizierung liefe wieder ins Leere. Die Nonce wird deshalb deterministisch abgeleitet:

nonce = HMAC-SHA256(nonceKey, klartext)[:12]
nonceKey = HMAC-SHA256(datenschlüssel, "syncova-chunk-nonce-derivation-v1")

Gleicher Klartext → gleiche Nonce → gleicher Geheimtext → deduplizierbar. Eine Nonce wiederholt sich genau dann, wenn auch der Klartext derselbe ist — dann ist der Geheimtext ohnehin identisch. Der bei GCM gefürchtete Fall (gleiche Nonce, verschiedener Klartext) tritt nicht ein.

Was das preisgibt: dass zwei Blöcke gleich sind. Das verrät die inhaltsadressierte Ablage ohnehin — die Chunk-Kennung ist der Klartext-Hash. Es entsteht kein zusätzlicher Verlust.

Ablageformat eines Blocks

[Nonce 12 B] [Marker 1 B] [Nutzdaten] [GCM-Schild 16 B]

Der Marker unterscheidet komprimiert von unkomprimiert. Ein Block, der sich nicht verkleinern liess, wird unkomprimiert abgelegt — bei Bildern und Archiven kostete Kompression sonst Platz statt zu sparen.

Reihenfolge: erst komprimieren, dann verschlüsseln. Verschlüsselte Daten sind nicht von Zufall zu unterscheiden und liessen sich nicht mehr verkleinern.

Integrität auf drei Ebenen

Ebene Prüft Braucht Schlüssel?
StoredDigest im Manifest abgelegte Form auf dem Datenträger nein
GCM-Authentifizierungsschild Veränderung des Geheimtextes ja
Klartext-Hash gegen Kennung Ergebnis der gesamten Rückgewinnung ja

Die erste Ebene ist der Grund, warum ChunkReference.StoredDigest existiert: Ein Integritätslauf kann verschlüsselte Blöcke prüfen, ohne einen Schlüssel zu besitzen.

Zusätzlich hält jeder Manifesteintrag den Hash seines Gesamtinhalts. Er deckt eine falsche Blockreihenfolge auf, die den Einzelprüfungen entginge.

Gemessene Werte

Auf einem Apple-Laptop (4 Arbeiter, zstd „balanced", AES-256-GCM), mit nicht komprimierbaren Zufallsdaten:

Datensatz Dauer Durchsatz Heap-Spitze Blöcke
128 MiB 0,96 s 134 MiB/s 63 MiB 114
512 MiB 3,40 s 151 MiB/s 75 MiB 429
1024 MiB 6,42 s 160 MiB/s 103 MiB 813
2048 MiB 13,51 s 152 MiB/s 122 MiB 1637

Wiederherstellung: 355 MiB/s, Ergebnis bitgenau identisch zur Quelle.

Zur Einordnung: Die 16-fache Datenmenge führt zur 1,9-fachen Speicherspitze — deutlich sublinear, aber nicht konstant. Der Datensatz wird nachweislich nicht in den Speicher geladen; ein Rest wächst dennoch mit (siehe unten).

Ein früherer Messlauf zeigte 1021-fache Kompression. Diese Zahl war wertlos: die Testdaten waren periodisch erzeugt und damit unrealistisch gut komprimierbar. Belastbar sind nur Messungen mit inkompressiblen Daten.

Sicherungsart je Auftrag

Der Standard ist inkrementell: Liegt ein Elternbackup vor, wird nur Geändertes gelesen. Zwei Abweichungen lassen sich je Auftrag einstellen.

Einstellung Wirkung
incremental Erster Lauf voll, danach inkrementell. Standard
always_full Jeder Lauf liest die gesamte Quelle
full_backup_weekday Zusätzlich an einem festen Wochentag voll

Der Platzbedarf steigt bei „immer voll" nicht nennenswert. Unveränderte Blöcke werden dedupliziert und liegen weiterhin nur einmal im Repository. Was steigt, ist die Laufzeit: Jeder Lauf liest, hasht, komprimiert und verschlüsselt alles neu. Gemessen (Phase 6): 190,7 MiB in 1 815 ms gegen 4,8 MiB in 133 ms bei einer geänderten von 41 Dateien.

Wer das verwechselt, hält „immer voll" für teuer im Speicher und plant seinen Nachtbetrieb falsch.

Wann eine Abweichung sinnvoll ist:

  • Immer voll, wenn das Repository außer Haus geht oder auf einen Datenträger geschrieben wird, der einzeln weggetragen wird.
  • Wöchentlich voll als üblicher Kompromiss: unter der Woche schnell, an einem festen Tag einmal vollständig.

Der Wochentag wird in der Zeitzone des Zeitplans bestimmt. Ohne diese Umrechnung liefe derselbe Auftrag auf zwei Servern an verschiedenen Tagen voll — und ein Betreiber in Berlin bekäme seine Vollsicherung am Donnerstagabend.

Ein Widerspruch — „immer voll" und ein Wochentag — wird von der Datenbank abgelehnt, nicht stillschweigend aufgelöst.

Zur Einordnung: Syncova-Manifeste sind vollständig (Phase 6). Eine Zusatzsicherung trägt die Blockverweise des Elternbackups mit, ein Restore liest genau ein Manifest, und das Löschen eines alten Backups kann ein neueres nicht beschädigen. Der Unterschied liegt also in der Laufzeit, nicht in der Wiederherstellbarkeit.

Grenzen des aktuellen Stands

Das Manifest wird vollständig im Speicher gehalten. Das ist die wichtigste offene Baustelle. Jeder Blockverweis kostet rund 200 Byte:

Backupgröße Blöcke Nur für die Blockverweise
2 GB 1 637 0,3 MiB
100 GB 81 850 16 MiB
1 TB 818 500 156 MiB
10 TB 8 185 000 1,5 GiB

Bei den in PROMPT.md §79 genannten Größenordnungen ist das nicht tragbar. Nötig wäre ein Manifest, das abschnittsweise auf die Platte geschrieben wird, statt am Stück im Speicher zu entstehen.

Weitere offene Punkte:

  • Kein Changed Block Tracking. Jedes Backup liest die Quelle vollständig. Die Deduplizierung verhindert erneutes Schreiben, nicht erneutes Lesen (PROMPT.md §8).
  • Keine Wiederaufnahme. Ein abgebrochener Lauf beginnt von vorn; die bereits abgelegten Blöcke werden zwar wiederverwendet, aber die Quelle wird erneut vollständig gelesen (PROMPT.md §81/§82).
  • Kein Bandbreitenlimit (PROMPT.md §60).
  • Kein Retry mit Backoff innerhalb der Pipeline; ein Fehler bricht den gesamten Lauf ab.
  • Inkrementelle Backups sind noch nicht umgesetzt. Kettenfelder und Elternverweis existieren, aber jedes Backup ist derzeit eine Vollsicherung.