syncova-backup/packages/backupengine/bandwidth_test.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

335 lines
12 KiB
Go

package backupengine
import (
"bytes"
"context"
"errors"
"io"
"log/slog"
"testing"
"time"
"github.com/syncova/syncova/packages/platform/ratelimit"
"github.com/syncova/syncova/packages/repository"
)
// newBandwidthTestEngine baut eine Engine auf einem frischen Repository.
func newBandwidthTestEngine(testInstance *testing.T) *Engine {
testInstance.Helper()
testLogger := slog.New(slog.NewJSONHandler(io.Discard, nil))
testRepository, createError := repository.Create(context.Background(), testInstance.TempDir(),
repository.CreateOptions{Name: "Bandbreite", CreatedByVersion: "test"}, testLogger)
if createError != nil {
testInstance.Fatalf("das Repository ließ sich nicht anlegen: %v", createError)
}
testInstance.Cleanup(func() { _ = testRepository.Close() })
return NewEngine(testRepository, nil, testLogger)
}
// buildIncompressiblePayload erzeugt kaum komprimierbare Testdaten.
//
// Komprimierbare Daten wären für eine Durchsatzmessung wertlos: Die Grenze
// wirkt auf das Lesen, die Kompression schrumpft danach — und das Ergebnis
// hinge an der Art der Testdaten statt an der Grenze.
func buildIncompressiblePayload(byteCount int) []byte {
generatedData := make([]byte, byteCount)
var generatorState uint64 = 0x9E3779B97F4A7C15
for byteIndex := range generatedData {
generatorState ^= generatorState << 13
generatorState ^= generatorState >> 7
generatorState ^= generatorState << 17
generatedData[byteIndex] = byte(generatorState)
}
return generatedData
}
// TestBandwidthLimitSlowsDownBackup ist der Wirksamkeitsnachweis.
//
// Ohne diesen Test bliebe offen, ob die Grenze überhaupt im Datenpfad liegt —
// eine Einstellung, die gespeichert und geprüft wird, aber nichts bewirkt, ist
// die unangenehmste Art von Fake-Feature: Sie sieht aus wie Schutz.
func TestBandwidthLimitSlowsDownBackup(testInstance *testing.T) {
if testing.Short() {
testInstance.Skip("die Durchsatzmessung braucht echte Zeit")
}
// 2 MiB bei 1 MiB/s müssen mindestens etwa zwei Sekunden dauern.
const payloadBytes = 2 << 20
const bytesPerSecond int64 = 1 << 20
sourceData := buildIncompressiblePayload(payloadBytes)
limitedEngine := newBandwidthTestEngine(testInstance)
bandwidthLimiter, limiterError := ratelimit.NewLimiter(bytesPerSecond)
if limiterError != nil {
testInstance.Fatalf("der Begrenzer ließ sich nicht bauen: %v", limiterError)
}
startTime := time.Now()
backupResult, backupError := limitedEngine.Backup(context.Background(), BackupOptions{
BackupID: "begrenzt",
BackupType: repository.BackupTypeFull,
Source: repository.SourceInformation{SourceType: "filesystem", SourceID: "test"},
CompressionLevel: CompressionOff,
BandwidthLimiter: bandwidthLimiter,
}, []BackupSource{
{Path: "daten.bin", EntryType: "file", Reader: bytes.NewReader(sourceData)},
})
if backupError != nil {
testInstance.Fatalf("die begrenzte Sicherung schlug fehl: %v", backupError)
}
limitedDuration := time.Since(startTime)
if backupResult.Progress.BytesProcessed != payloadBytes {
testInstance.Errorf("es wurden %d Byte verarbeitet, erwartet waren %d",
backupResult.Progress.BytesProcessed, payloadBytes)
}
// Der Eimer ist anfangs voll (eine Sekunde Vorrat), also kostet nur die
// zweite Hälfte Wartezeit. Erwartet wird deshalb mindestens eine Sekunde.
const minimumExpectedDuration = time.Second
if limitedDuration < minimumExpectedDuration {
testInstance.Fatalf("die Sicherung von %d Byte bei %d Byte/s dauerte nur %s; die Grenze liegt nicht im Datenpfad",
payloadBytes, bytesPerSecond, limitedDuration)
}
measuredThroughput := float64(payloadBytes) / limitedDuration.Seconds()
// Der gemessene Durchsatz darf die Grenze nicht deutlich überschreiten.
// Der Eimer erlaubt kurzfristig mehr; das Doppelte ist die Obergrenze
// dessen, was durch den anfänglichen Vorrat erklärbar ist.
if measuredThroughput > float64(bytesPerSecond)*2 {
testInstance.Errorf("gemessen wurden %.0f Byte/s bei einer Grenze von %d Byte/s",
measuredThroughput, bytesPerSecond)
}
testInstance.Logf("begrenzt: %d Byte in %s (%.2f MiB/s bei einer Grenze von %.0f MiB/s)",
payloadBytes, limitedDuration.Round(time.Millisecond),
measuredThroughput/(1<<20), float64(bytesPerSecond)/(1<<20))
}
// TestBackupWithoutLimitIsFast prüft den Vergleichsfall.
//
// Ohne ihn belegte der vorige Test nur, dass die Sicherung langsam ist — nicht,
// dass die Grenze der Grund dafür war.
func TestBackupWithoutLimitIsFast(testInstance *testing.T) {
if testing.Short() {
testInstance.Skip("die Durchsatzmessung braucht echte Zeit")
}
const payloadBytes = 2 << 20
sourceData := buildIncompressiblePayload(payloadBytes)
unlimitedEngine := newBandwidthTestEngine(testInstance)
startTime := time.Now()
if _, backupError := unlimitedEngine.Backup(context.Background(), BackupOptions{
BackupID: "unbegrenzt",
BackupType: repository.BackupTypeFull,
Source: repository.SourceInformation{SourceType: "filesystem", SourceID: "test"},
CompressionLevel: CompressionOff,
}, []BackupSource{
{Path: "daten.bin", EntryType: "file", Reader: bytes.NewReader(sourceData)},
}); backupError != nil {
testInstance.Fatalf("die unbegrenzte Sicherung schlug fehl: %v", backupError)
}
unlimitedDuration := time.Since(startTime)
// Dieselbe Menge muss ohne Grenze deutlich schneller durchlaufen.
if unlimitedDuration > 500*time.Millisecond {
testInstance.Errorf("die unbegrenzte Sicherung von %d Byte dauerte %s; der Vergleich trägt nicht",
payloadBytes, unlimitedDuration)
}
testInstance.Logf("unbegrenzt: %d Byte in %s", payloadBytes, unlimitedDuration.Round(time.Millisecond))
}
// TestNilLimiterMeansUnlimited prüft den fehlenden Begrenzer.
//
// Ein nil-Begrenzer darf die Sicherung weder anhalten noch abstürzen lassen.
func TestNilLimiterMeansUnlimited(testInstance *testing.T) {
nilLimiterEngine := newBandwidthTestEngine(testInstance)
sourceData := buildIncompressiblePayload(64 << 10)
backupResult, backupError := nilLimiterEngine.Backup(context.Background(), BackupOptions{
BackupID: "ohne-begrenzer",
BackupType: repository.BackupTypeFull,
Source: repository.SourceInformation{SourceType: "filesystem", SourceID: "test"},
CompressionLevel: CompressionOff,
BandwidthLimiter: nil,
}, []BackupSource{
{Path: "daten.bin", EntryType: "file", Reader: bytes.NewReader(sourceData)},
})
if backupError != nil {
testInstance.Fatalf("die Sicherung ohne Begrenzer schlug fehl: %v", backupError)
}
if backupResult.Progress.BytesProcessed != int64(len(sourceData)) {
testInstance.Errorf("es wurden %d Byte verarbeitet, erwartet waren %d",
backupResult.Progress.BytesProcessed, len(sourceData))
}
}
// TestBandwidthLimitPreservesData ist der wichtigste Test dieser Datei.
//
// Eine Bandbreitengrenze, die den Inhalt antastet, wäre die heimtückischste Art,
// ein Backup zu beschädigen: Es liefe erfolgreich durch, und der Schaden fiele
// erst bei der Wiederherstellung auf.
func TestBandwidthLimitPreservesData(testInstance *testing.T) {
limitedEngine := newBandwidthTestEngine(testInstance)
// Klein genug, damit der Test nicht wartet — es geht um die Daten, nicht
// um die Zeit.
sourceData := buildIncompressiblePayload(128 << 10)
bandwidthLimiter, _ := ratelimit.NewLimiter(8 << 20)
if _, backupError := limitedEngine.Backup(context.Background(), BackupOptions{
BackupID: "unversehrt",
BackupType: repository.BackupTypeFull,
Source: repository.SourceInformation{SourceType: "filesystem", SourceID: "test"},
CompressionLevel: CompressionOff,
BandwidthLimiter: bandwidthLimiter,
}, []BackupSource{
{Path: "daten.bin", EntryType: "file", Reader: bytes.NewReader(sourceData)},
}); backupError != nil {
testInstance.Fatalf("die Sicherung schlug fehl: %v", backupError)
}
var restoredData bytes.Buffer
if _, restoreError := limitedEngine.Restore(context.Background(), RestoreOptions{
BackupID: "unversehrt",
Path: "daten.bin",
}, &restoredData); restoreError != nil {
testInstance.Fatalf("die Wiederherstellung schlug fehl: %v", restoreError)
}
if !bytes.Equal(restoredData.Bytes(), sourceData) {
testInstance.Fatal("die begrenzte Sicherung veränderte die Daten")
}
}
// TestBandwidthLimitRespectsCancellation prüft den Abbruch während der Wartezeit.
//
// Ohne ihn hinge ein abgebrochener Sicherungslauf noch minutenlang in einer
// Wartezeit, die niemand mehr braucht — und das Herunterfahren des Dienstes
// hinge daran.
func TestBandwidthLimitRespectsCancellation(testInstance *testing.T) {
limitedEngine := newBandwidthTestEngine(testInstance)
// Eine große Menge bei kleiner Rate: Der Lauf käme ohne Abbruch nicht
// innerhalb der Testlaufzeit durch.
sourceData := buildIncompressiblePayload(16 << 20)
bandwidthLimiter, _ := ratelimit.NewLimiter(1 << 20)
backupContext, cancelBackup := context.WithCancel(context.Background())
// Der Abbruch erfolgt, während der Begrenzer wartet.
go func() {
time.Sleep(300 * time.Millisecond)
cancelBackup()
}()
startTime := time.Now()
_, backupError := limitedEngine.Backup(backupContext, BackupOptions{
BackupID: "abgebrochen",
BackupType: repository.BackupTypeFull,
Source: repository.SourceInformation{SourceType: "filesystem", SourceID: "test"},
CompressionLevel: CompressionOff,
BandwidthLimiter: bandwidthLimiter,
}, []BackupSource{
{Path: "daten.bin", EntryType: "file", Reader: bytes.NewReader(sourceData)},
})
elapsedDuration := time.Since(startTime)
if backupError == nil {
testInstance.Fatal("der abgebrochene Lauf meldete Erfolg")
}
if !errors.Is(backupError, context.Canceled) {
testInstance.Errorf("der Abbruch wurde nicht als solcher gemeldet: %v", backupError)
}
// Ohne Beachtung des Abbruchs dauerte der Lauf 16 Sekunden.
if elapsedDuration > 5*time.Second {
testInstance.Fatalf("der abgebrochene Lauf dauerte %s; die Wartezeit beachtet den Abbruch nicht", elapsedDuration)
}
}
// TestOneLimiterServesAllSources prüft die gemeinsame Nutzung.
//
// Je Quelle einen eigenen Begrenzer zu führen ergäbe bei einem Auftrag mit drei
// Verzeichnissen das Dreifache der vereinbarten Rate — genau der Fehler, der
// eine Bandbreitengrenze wirkungslos macht.
func TestOneLimiterServesAllSources(testInstance *testing.T) {
if testing.Short() {
testInstance.Skip("die Durchsatzmessung braucht echte Zeit")
}
const bytesPerSource = 1 << 20
const sourceCount = 3
const bytesPerSecond int64 = 1 << 20
limitedEngine := newBandwidthTestEngine(testInstance)
bandwidthLimiter, _ := ratelimit.NewLimiter(bytesPerSecond)
backupSources := make([]BackupSource, 0, sourceCount)
for sourceIndex := 0; sourceIndex < sourceCount; sourceIndex++ {
// Je Quelle eigene Daten, damit die Deduplizierung sie nicht
// zusammenfallen lässt und die Messung verfälscht.
sourceData := buildIncompressiblePayload(bytesPerSource)
sourceData[0] = byte(sourceIndex)
backupSources = append(backupSources, BackupSource{
Path: "quelle-" + string(rune('a'+sourceIndex)) + ".bin",
EntryType: "file",
Reader: bytes.NewReader(sourceData),
})
}
startTime := time.Now()
if _, backupError := limitedEngine.Backup(context.Background(), BackupOptions{
BackupID: "drei-quellen",
BackupType: repository.BackupTypeFull,
Source: repository.SourceInformation{SourceType: "filesystem", SourceID: "test"},
CompressionLevel: CompressionOff,
BandwidthLimiter: bandwidthLimiter,
}, backupSources); backupError != nil {
testInstance.Fatalf("die Sicherung schlug fehl: %v", backupError)
}
totalDuration := time.Since(startTime)
// Drei MiB bei einem MiB/s, abzüglich des anfänglichen Vorrats von einem
// MiB: mindestens etwa zwei Sekunden. Mit einem Begrenzer je Quelle liefe
// jede Quelle sofort durch und die Gesamtdauer läge nahe null.
const minimumExpectedDuration = 1500 * time.Millisecond
if totalDuration < minimumExpectedDuration {
testInstance.Fatalf("drei Quellen mit je %d Byte liefen in %s durch; jede Quelle hatte offenbar ihren eigenen Begrenzer",
bytesPerSource, totalDuration)
}
testInstance.Logf("drei Quellen mit je 1 MiB bei 1 MiB/s: %s", totalDuration.Round(time.Millisecond))
}