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