syncova-backup/packages/backupengine/chunker.go
Jerrit Fritzsche 610719c316
Some checks failed
CI / Backend (Go) (push) Failing after 3m7s
CI / Frontend (React/TypeScript) (push) Successful in 37s
CI / Sicherheitsprüfungen (push) Successful in 44s
Syncova Backups V1
Enterprise-Backup-, Recovery-, Verification-, Security- und
Monitoring-Plattform fuer Proxmox VE, Windows, Linux und Dateisysteme.

Der Leitsatz, der fast jede Entscheidung erklaert: Ein Backup gilt erst als
vertrauenswuerdig, wenn Integritaet geprueft und Wiederherstellbarkeit
nachgewiesen wurde. Deshalb steigt ein Wiederherstellungspunkt erst nach einem
tatsaechlich durchgefuehrten Restore-Test auf "recoverable", und Unbekanntes
geht in keine Bewertung als "gut" ein.

Umfang (Phasen 0-23):

- Repository Engine: inhaltsadressierte Bloecke, atomares Commit-Protokoll,
  Katalogaufbau allein aus den Manifesten — ohne Datenbank
- Backup Engine: inhaltsabhaengiges Chunking, Deduplizierung trotz
  Verschluesselung, zstd, AES-256-GCM, Streaming mit Gegendruck
- Agenten fuer Windows und Linux mit Auftragsabholung (Pull-Modell)
- Proxmox-Provider mit beiden Zugriffswegen auf die Sicherungsarchive
- Scheduler, Recovery Engine mit Pruefpunkt, Verification, Unveraenderlichkeit
- Weboberflaeche, Kennzahlen, Meldungen, Berichte, Security Center,
  Ransomware-Heuristik (meldet, handelt nie)
- Disaster Recovery, Haertung, Leistungsmessung, Chaos Testing
- Eingefrorene Vertraege fuer API, Migrationen, Backup-Format und Repository
- Auslieferungspaket fuer linux/amd64, linux/arm64 und windows/amd64

Nicht enthalten und als solches gekennzeichnet: Kapazitaetsprognose, Backup
Copy, Changed Block Tracking bei Proxmox, erweiterte Attribute und ACLs.

Gebaut, aber nie auf echter Hardware gefahren: der Windows-Dienst, die
systemd-Einheit und der verpflichtende Proxmox-Meilenstein — ob eine
wiederhergestellte VM startet, ist ungeprueft. Einzelheiten in CHANGELOG.md
und docs/release-candidate.md.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 09:10:54 +02:00

299 lines
10 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// Package backupengine implementiert die Backup-Pipeline von Syncova.
//
// Der Ablauf folgt SYNCOVA_IMPLEMENTATION_PLAN.md Phase 4:
//
// Lesen → Chunking → Hash → Deduplizierung → Kompression → Verschlüsselung
// → Schreiben → Manifest → Commit
//
// Sämtliche Stufen arbeiten als Datenstrom mit begrenzten Puffern. Ein Backup
// wird niemals vollständig in den Arbeitsspeicher geladen (PROMPT.md §80).
package backupengine
import (
"errors"
"fmt"
"io"
)
// Chunking-Parameter.
//
// Die Werte bestimmen das Verhältnis zwischen Deduplizierungsgüte und Verwaltungsaufwand:
// kleinere Blöcke finden mehr Übereinstimmungen, erzeugen aber mehr Einträge in
// Manifest und Chunk-Ablage.
const (
// MinimumChunkSize ist die kleinste zulässige Blockgröße.
//
// Ohne Untergrenze könnte eine ungünstige Datenfolge tausende Kleinstblöcke
// erzeugen, deren Verwaltungsaufwand den Nutzen aufzehrt.
MinimumChunkSize = 256 * 1024
// TargetChunkSize ist die angestrebte durchschnittliche Blockgröße.
TargetChunkSize = 1024 * 1024
// MaximumChunkSize ist die größte zulässige Blockgröße.
//
// Die Obergrenze begrenzt zugleich den Speicherbedarf je Arbeiter: mehr als
// diese Menge liegt nie gleichzeitig für einen Block im Speicher.
MaximumChunkSize = 4 * 1024 * 1024
)
// chunkBoundaryMask bestimmt, wie häufig eine Blockgrenze entsteht.
//
// Der Rolling Hash liefert einen gleichverteilten Wert; eine Grenze entsteht,
// wenn seine unteren Bits null sind. Bei 20 gesetzten Bits ergibt das im Mittel
// alle 2^20 Byte (1 MiB) eine Grenze — die angestrebte Zielgröße.
const chunkBoundaryMask uint64 = (1 << 20) - 1
// gearTable ist die Substitutionstabelle des Rolling Hash.
//
// Das Verfahren (Gear Hashing) ordnet jedem Bytewert einen Zufallswert zu und
// führt den Hash über ein Schieberegister fort. Es ist deutlich schneller als
// ein Rabin-Fingerprint und für die Blockfindung gleichwertig — es dient allein
// der Grenzbestimmung, nicht der Sicherheit. Die Chunk-Kennung entsteht
// weiterhin aus SHA-256 über den Blockinhalt.
var gearTable = buildGearTable()
// buildGearTable erzeugt die Substitutionstabelle deterministisch.
//
// Die Tabelle muss über alle Installationen hinweg identisch sein: andernfalls
// fänden zwei Systeme unterschiedliche Blockgrenzen und könnten ihre Backups
// nicht gegenseitig deduplizieren.
func buildGearTable() [256]uint64 {
var substitutionTable [256]uint64
// Ein einfacher, festgelegter Generator (splitmix64) liefert reproduzierbare
// Werte ohne Abhängigkeit von einer Zufallsquelle.
var generatorState uint64 = 0x9E3779B97F4A7C15
for tableIndex := range substitutionTable {
generatorState += 0x9E3779B97F4A7C15
mixedValue := generatorState
mixedValue = (mixedValue ^ (mixedValue >> 30)) * 0xBF58476D1CE4E5B9
mixedValue = (mixedValue ^ (mixedValue >> 27)) * 0x94D049BB133111EB
mixedValue = mixedValue ^ (mixedValue >> 31)
substitutionTable[tableIndex] = mixedValue
}
return substitutionTable
}
// Chunk ist ein von der Quelle gelesener Datenblock.
type Chunk struct {
// Sequence ist die laufende Nummer des Blocks im Datenstrom.
//
// Sie erhält die Reihenfolge, obwohl die Blöcke parallel verarbeitet werden.
Sequence int64
// Offset ist die Position des Blocks im ursprünglichen Datenstrom.
Offset int64
// Data ist der Blockinhalt.
Data []byte
}
// Chunker zerlegt einen Datenstrom in inhaltsabhängige Blöcke.
//
// Die Blockgrenzen ergeben sich aus dem Inhalt, nicht aus festen Abständen.
// Das ist der entscheidende Unterschied: 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, sodass nur die tatsächlich geänderten Blöcke neu sind
// (PROMPT.md §9, §10).
type Chunker struct {
// sourceReader ist die Datenquelle.
sourceReader io.Reader
// readBuffer nimmt die von der Quelle gelesenen Daten auf.
//
// Er ist genau so groß wie ein größtmöglicher Block: mehr muss nie
// gleichzeitig im Speicher liegen.
readBuffer []byte
// bufferedLength ist die Menge gültiger Daten im Puffer.
bufferedLength int
// currentOffset ist die Position im Datenstrom.
currentOffset int64
// sequenceNumber ist die laufende Nummer des nächsten Blocks.
sequenceNumber int64
// reachedEndOfStream meldet, ob die Quelle erschöpft ist.
reachedEndOfStream bool
// pendingChunkLength ist die Länge des zuletzt gelieferten Blocks.
//
// Er bleibt im Puffer stehen, bis der nächste Aufruf ihn räumt.
pendingChunkLength int
// minimumSize ist die kleinste Blockgröße.
minimumSize int
// maximumSize ist die größte Blockgröße.
maximumSize int
// boundaryMask bestimmt die durchschnittliche Blockgröße.
boundaryMask uint64
}
// ChunkerOptions steuern die Blockfindung.
type ChunkerOptions struct {
// MinimumSize ist die kleinste Blockgröße; 0 verwendet den Standardwert.
MinimumSize int
// MaximumSize ist die größte Blockgröße; 0 verwendet den Standardwert.
MaximumSize int
// BoundaryMask bestimmt die durchschnittliche Blockgröße; 0 verwendet den Standardwert.
BoundaryMask uint64
// ExpectedSize ist die bekannte Größe der Quelle; 0 bedeutet unbekannt.
//
// Sie bestimmt allein die Größe des Lesepuffers, nicht die Blockfindung.
// Ohne diese Angabe legt der Chunker den Puffer stets in Höchstblockgröße
// an — vier Megabyte, auch für eine Datei von sechzehn Kilobyte.
//
// Bei vielen kleinen Dateien ist das der beherrschende Aufwand: In der
// Messung der Phase 20 forderte ein Lauf über 4000 Dateien mit zusammen
// 62,5 MiB ganze 16 GiB Speicher an — genau 4000 × 4 MiB — und erreichte
// 104 Dateien je Sekunde bei 1447 Speicherbereinigungen.
ExpectedSize int64
}
// NewChunker erzeugt einen Chunker über einer Datenquelle.
func NewChunker(sourceReader io.Reader, chunkerOptions ChunkerOptions) *Chunker {
minimumSize := chunkerOptions.MinimumSize
if minimumSize <= 0 {
minimumSize = MinimumChunkSize
}
maximumSize := chunkerOptions.MaximumSize
if maximumSize <= 0 {
maximumSize = MaximumChunkSize
}
// Eine Höchstgröße unterhalb der Mindestgröße wäre widersprüchlich.
if maximumSize < minimumSize {
maximumSize = minimumSize
}
boundaryMask := chunkerOptions.BoundaryMask
if boundaryMask == 0 {
boundaryMask = chunkBoundaryMask
}
return &Chunker{
sourceReader: sourceReader,
readBuffer: make([]byte, resolveBufferSize(maximumSize, chunkerOptions.ExpectedSize)),
minimumSize: minimumSize,
maximumSize: maximumSize,
boundaryMask: boundaryMask,
}
}
// Next liefert den nächsten Block.
//
// Am Ende des Datenstroms wird io.EOF geliefert. Der zurückgegebene Block
// verweist auf einen wiederverwendeten Puffer und bleibt genau bis zum nächsten
// Next-Aufruf gültig; der Aufrufer muss ihn vorher kopieren.
func (chunker *Chunker) Next() (Chunk, error) {
// Der zuvor gelieferte Block wird erst jetzt aus dem Puffer geräumt.
//
// Würde das bereits am Ende des vorigen Aufrufs geschehen, überschriebe das
// Nachrücken der Restdaten genau den Bereich, auf den der ausgelieferte
// Block noch zeigt — der Aufrufer erhielte stillschweigend verfälschte Daten.
chunker.consumePendingChunk()
// Der Puffer wird aufgefüllt, solange die Quelle liefert.
if fillError := chunker.fillBuffer(); fillError != nil {
return Chunk{}, fillError
}
if chunker.bufferedLength == 0 {
return Chunk{}, io.EOF
}
boundaryPosition := chunker.findBoundary()
producedChunk := Chunk{
Sequence: chunker.sequenceNumber,
Offset: chunker.currentOffset,
Data: chunker.readBuffer[:boundaryPosition],
}
// Das Nachrücken wird bis zum nächsten Aufruf zurückgestellt.
chunker.pendingChunkLength = boundaryPosition
chunker.currentOffset += int64(boundaryPosition)
chunker.sequenceNumber++
return producedChunk, nil
}
// consumePendingChunk räumt den zuletzt gelieferten Block aus dem Puffer.
func (chunker *Chunker) consumePendingChunk() {
if chunker.pendingChunkLength == 0 {
return
}
remainingLength := chunker.bufferedLength - chunker.pendingChunkLength
copy(chunker.readBuffer, chunker.readBuffer[chunker.pendingChunkLength:chunker.bufferedLength])
chunker.bufferedLength = remainingLength
chunker.pendingChunkLength = 0
}
// fillBuffer liest von der Quelle nach, bis der Puffer voll oder die Quelle erschöpft ist.
func (chunker *Chunker) fillBuffer() error {
for !chunker.reachedEndOfStream && chunker.bufferedLength < len(chunker.readBuffer) {
bytesRead, readError := chunker.sourceReader.Read(chunker.readBuffer[chunker.bufferedLength:])
chunker.bufferedLength += bytesRead
if readError != nil {
if errors.Is(readError, io.EOF) {
chunker.reachedEndOfStream = true
break
}
return fmt.Errorf("die quelle konnte nicht gelesen werden: %w", readError)
}
// Ein Reader darf 0 Byte ohne Fehler liefern; ein erneuter Versuch ist zulässig.
if bytesRead == 0 {
continue
}
}
return nil
}
// findBoundary bestimmt das Ende des nächsten Blocks.
func (chunker *Chunker) findBoundary() int {
// Reicht der Vorrat nicht für einen Mindestblock, bildet der Rest den Block.
if chunker.bufferedLength <= chunker.minimumSize {
return chunker.bufferedLength
}
searchLimit := min(chunker.bufferedLength, chunker.maximumSize)
// Die Suche beginnt erst nach der Mindestgröße: davor wird bewusst keine
// Grenze gesetzt, damit keine Kleinstblöcke entstehen.
var rollingHash uint64
for scanPosition := chunker.minimumSize; scanPosition < searchLimit; scanPosition++ {
// Gear Hashing: Schieberegister plus Substitutionswert des Bytes.
rollingHash = (rollingHash << 1) + gearTable[chunker.readBuffer[scanPosition]]
if rollingHash&chunker.boundaryMask == 0 {
return scanPosition + 1
}
}
// Ohne gefundene Grenze greift die Höchstgröße. Ohne sie könnte eine
// gleichförmige Datenfolge einen unbegrenzt großen Block erzeugen.
return searchLimit
}
// resolveBufferSize bestimmt die Größe des Lesepuffers.
//
// Ist die Quellgröße bekannt und kleiner als die Höchstblockgröße, genügt ein
// Puffer in Quellgröße: Mehr kann ohnehin nicht gelesen werden.
//
// Wächst die Quelle wider Erwarten während des Lesens, entstehen kleinere
// Blöcke als möglich — die Sicherung bleibt richtig, nur dedupliziert sie
// etwas schlechter. Das ist der Preis dafür, nicht für jede kleine Datei vier
// Megabyte anzufordern.
func resolveBufferSize(maximumSize int, expectedSize int64) int {
if expectedSize <= 0 || expectedSize >= int64(maximumSize) {
return maximumSize
}
return int(expectedSize)
}