syncova-backup/packages/scheduler/retry_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

190 lines
6.6 KiB
Go

package scheduler
import (
"errors"
"testing"
"time"
)
// TestRetryOnlyForTransientFailures ist der wichtigste Wiederholungstest.
//
// Ein Anmeldefehler behebt sich nicht durch Warten; ein Integritätsfehler wird
// durch Wiederholen nicht besser, sondern nur später bemerkt.
func TestRetryOnlyForTransientFailures(testInstance *testing.T) {
retryPolicy := DefaultRetryPolicy()
retryableClasses := []FailureClass{FailureTransient, FailureNetwork, FailureRepository, FailureSource}
for _, failureClass := range retryableClasses {
decision := retryPolicy.Decide(1, failureClass)
if !decision.ShouldRetry {
testInstance.Errorf("ein Fehler der Art %q wurde nicht wiederholt: %s", failureClass, decision.Reason)
}
}
permanentClasses := []FailureClass{
FailureAuthentication, FailureIntegrity, FailureConfiguration, FailureSecurity, FailurePermanent,
}
for _, failureClass := range permanentClasses {
decision := retryPolicy.Decide(1, failureClass)
if decision.ShouldRetry {
testInstance.Errorf("ein Fehler der Art %q wurde wiederholt, obwohl er sich nicht behebt", failureClass)
}
// Auch ein „nein" braucht eine Begründung: Der Betreiber muss zwischen
// „aufgegeben" und „nicht wiederholbar" unterscheiden können.
if decision.Reason == "" {
testInstance.Errorf("die Ablehnung bei %q wurde nicht begründet", failureClass)
}
}
}
// TestRetryStopsAfterMaximumAttempts prüft die Obergrenze.
func TestRetryStopsAfterMaximumAttempts(testInstance *testing.T) {
retryPolicy := RetryPolicy{MaximumAttempts: 3, InitialDelay: time.Second, MaximumDelay: time.Minute}
for attemptNumber := 1; attemptNumber < 3; attemptNumber++ {
if decision := retryPolicy.Decide(attemptNumber, FailureTransient); !decision.ShouldRetry {
testInstance.Errorf("nach Versuch %d wurde nicht wiederholt: %s", attemptNumber, decision.Reason)
}
}
finalDecision := retryPolicy.Decide(3, FailureTransient)
if finalDecision.ShouldRetry {
testInstance.Fatal("nach dem letzten zugelassenen Versuch wurde erneut wiederholt")
}
}
// TestBackoffGrowsExponentiallyAndIsCapped prüft die Wartezeiten.
//
// Ohne Deckel wüchse die Wartezeit weiter: Beim achten Versuch wären es aus
// 30 Sekunden über eine Stunde, beim zehnten mehr als vier — ein nächtliches
// Sicherungsfenster wäre längst vorbei.
func TestBackoffGrowsExponentiallyAndIsCapped(testInstance *testing.T) {
// Ohne Streuung, damit die Werte genau prüfbar sind.
retryPolicy := RetryPolicy{
MaximumAttempts: 10,
InitialDelay: 30 * time.Second,
MaximumDelay: 15 * time.Minute,
}
expectedDelays := map[int]time.Duration{
1: 30 * time.Second,
2: 60 * time.Second,
3: 120 * time.Second,
4: 240 * time.Second,
5: 480 * time.Second,
// Ab hier greift der Deckel: 960 s wären mehr als 15 Minuten.
6: 15 * time.Minute,
9: 15 * time.Minute,
}
for attemptNumber, expectedDelay := range expectedDelays {
decision := retryPolicy.Decide(attemptNumber, FailureTransient)
if decision.Delay != expectedDelay {
testInstance.Errorf("nach Versuch %d wurde %s gewartet, erwartet waren %s",
attemptNumber, decision.Delay, expectedDelay)
}
}
}
// TestJitterOnlyShortensDelay prüft die Streuung.
//
// Nach oben zu streuen verlängerte die Wartezeit über die vereinbarte Grenze
// hinaus — der Deckel wäre dann keiner.
func TestJitterOnlyShortensDelay(testInstance *testing.T) {
retryPolicy := RetryPolicy{
MaximumAttempts: 5,
InitialDelay: time.Minute,
MaximumDelay: time.Minute,
JitterFraction: 0.5,
}
// Mehrere Durchläufe, weil die Streuung zufällig ist.
for runIndex := 0; runIndex < 200; runIndex++ {
decision := retryPolicy.Decide(1, FailureTransient)
if decision.Delay > time.Minute {
testInstance.Fatalf("die Streuung verlängerte die Wartezeit auf %s über die Grenze von 1m", decision.Delay)
}
if decision.Delay < 30*time.Second {
testInstance.Fatalf("die Streuung verkürzte die Wartezeit auf %s, mehr als die zugelassenen 50 %%", decision.Delay)
}
}
}
// TestBackoffSurvivesLargeAttemptNumbers prüft den Überlauf.
//
// Mit einer Schiebeoperation liefe die Wartezeit bei hohen Versuchsnummern
// über und würde negativ — aus einer langen Wartezeit würde gar keine.
func TestBackoffSurvivesLargeAttemptNumbers(testInstance *testing.T) {
retryPolicy := RetryPolicy{
MaximumAttempts: 10,
InitialDelay: time.Hour,
MaximumDelay: 2 * time.Hour,
}
decision := retryPolicy.Decide(9, FailureTransient)
if decision.Delay <= 0 {
testInstance.Fatalf("bei Versuch 9 ergab sich eine Wartezeit von %s", decision.Delay)
}
if decision.Delay > 2*time.Hour {
testInstance.Fatalf("bei Versuch 9 wurde der Deckel überschritten: %s", decision.Delay)
}
}
// TestRetryPolicyValidation prüft die Eingabeprüfung.
func TestRetryPolicyValidation(testInstance *testing.T) {
defaultPolicy := DefaultRetryPolicy()
if validationError := defaultPolicy.Validate(); validationError != nil {
testInstance.Fatalf("die Voreinstellung wurde abgelehnt: %v", validationError)
}
brokenPolicies := map[string]RetryPolicy{
"kein Versuch": {MaximumAttempts: 0},
"zu viele Versuche": {MaximumAttempts: 50},
"negative Wartezeit": {MaximumAttempts: 3, InitialDelay: -time.Second},
"Deckel unter Anfang": {
MaximumAttempts: 3, InitialDelay: time.Minute, MaximumDelay: time.Second,
},
"Streuung außerhalb": {MaximumAttempts: 3, JitterFraction: 1.5},
}
for caseName, brokenPolicy := range brokenPolicies {
if validationError := brokenPolicy.Validate(); validationError == nil {
testInstance.Errorf("%s: die Strategie wurde angenommen", caseName)
}
}
}
// TestClassifyErrorDefaultsToPermanent prüft den sicheren Rückfall.
//
// Ein falsch als vorübergehend eingeordneter Fehler liesse einen aussichtslosen
// Auftrag immer wieder anlaufen und verdeckte dabei die eigentliche Ursache.
func TestClassifyErrorDefaultsToPermanent(testInstance *testing.T) {
knownNetworkError := errors.New("verbindung abgebrochen")
classifications := map[error]FailureClass{
knownNetworkError: FailureNetwork,
}
if classifiedClass := ClassifyError(knownNetworkError, classifications); classifiedClass != FailureNetwork {
testInstance.Errorf("ein bekannter Fehler wurde als %q eingeordnet", classifiedClass)
}
unknownError := errors.New("etwas ganz anderes")
if classifiedClass := ClassifyError(unknownError, classifications); classifiedClass != FailurePermanent {
testInstance.Fatalf("ein unbekannter Fehler wurde als %q eingeordnet statt als dauerhaft", classifiedClass)
}
if ClassifyError(nil, classifications) != "" {
testInstance.Error("ein fehlender Fehler wurde eingeordnet")
}
}