// 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) }