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

212 lines
7.5 KiB
Go

package scheduler
import (
"errors"
"fmt"
"math"
"math/rand/v2"
"time"
)
// FailureClass ist die Einordnung eines Fehlers (PROMPT.md §139).
//
// Die Einordnung entscheidet über die Wiederholung. Ohne sie gäbe es nur zwei
// gleich schlechte Möglichkeiten: alles zu wiederholen — dann läuft ein
// Auftrag mit falschem Passwort ewig weiter — oder nichts — dann scheitert
// eine Sicherung an einer Sekunde Netzstörung.
type FailureClass string
const (
// FailureTransient ist ein vorübergehender Fehler.
FailureTransient FailureClass = "transient"
// FailurePermanent ist ein dauerhafter Fehler.
FailurePermanent FailureClass = "permanent"
// FailureIntegrity ist ein Integritätsfehler.
FailureIntegrity FailureClass = "integrity"
// FailureAuthentication ist ein Anmeldefehler.
FailureAuthentication FailureClass = "auth"
// FailureRepository ist ein Fehler des Repositorys.
FailureRepository FailureClass = "repository"
// FailureSource ist ein Fehler der Quelle.
FailureSource FailureClass = "source"
// FailureNetwork ist ein Netzfehler.
FailureNetwork FailureClass = "network"
// FailureConfiguration ist ein Konfigurationsfehler.
FailureConfiguration FailureClass = "configuration"
// FailureSecurity ist ein sicherheitsrelevanter Fehler.
FailureSecurity FailureClass = "security"
)
// retryableFailureClasses sind die Klassen, bei denen ein neuer Versuch
// sinnvoll ist.
//
// Bewusst kurz gehalten. Ein Anmeldefehler behebt sich nicht durch Warten; ein
// Integritätsfehler wird durch Wiederholen nicht besser, sondern nur später
// bemerkt. Und ein sicherheitsrelevanter Fehler gehört gemeldet, nicht
// verschluckt.
var retryableFailureClasses = map[FailureClass]bool{
FailureTransient: true,
FailureNetwork: true,
FailureRepository: true,
FailureSource: true,
}
// IsRetryable meldet, ob ein neuer Versuch sinnvoll ist.
func (failureClass FailureClass) IsRetryable() bool {
return retryableFailureClasses[failureClass]
}
// RetryPolicy beschreibt das Wiederholungsverhalten.
type RetryPolicy struct {
// MaximumAttempts ist die Gesamtzahl der Versuche einschließlich des ersten.
//
// 1 bedeutet: keine Wiederholung.
MaximumAttempts int `json:"maximum_attempts"`
// InitialDelay ist die Wartezeit vor dem zweiten Versuch.
InitialDelay time.Duration `json:"initial_delay"`
// MaximumDelay begrenzt die Wartezeit nach oben.
//
// Ohne Deckel wüchse die Wartezeit exponentiell weiter: Beim achten Versuch
// wären es aus 30 Sekunden bereits über eine Stunde, beim zehnten mehr als
// vier. Ein nächtliches Sicherungsfenster wäre längst vorbei.
MaximumDelay time.Duration `json:"maximum_delay"`
// JitterFraction streut die Wartezeit zufällig.
//
// Nötig gegen den Gleichlauf: Fallen zwanzig Aufträge derselben Störung zum
// Opfer, würden sie ohne Streuung alle gleichzeitig erneut anlaufen und die
// eben erst erholte Gegenstelle sofort wieder überlasten.
JitterFraction float64 `json:"jitter_fraction"`
}
// DefaultRetryPolicy ist die Voreinstellung.
//
// Drei Versuche, beginnend bei 30 Sekunden, gedeckelt bei 15 Minuten. Wer nach
// drei Anläufen über eine halbe Stunde nicht durchkommt, hat ein Problem, das
// sich nicht von selbst löst — dann ist eine Meldung nützlicher als ein
// vierter Versuch.
func DefaultRetryPolicy() RetryPolicy {
return RetryPolicy{
MaximumAttempts: 3,
InitialDelay: 30 * time.Second,
MaximumDelay: 15 * time.Minute,
JitterFraction: 0.2,
}
}
// Validate prüft eine Wiederholungsstrategie.
func (retryPolicy *RetryPolicy) Validate() error {
if retryPolicy.MaximumAttempts < 1 {
return errors.New("es muss mindestens ein versuch zugelassen sein")
}
if retryPolicy.MaximumAttempts > 10 {
// Mehr als zehn Versuche verschleiern ein dauerhaftes Problem, statt es
// zu melden.
return fmt.Errorf("mehr als 10 versuche sind nicht zugelassen, angegeben waren %d", retryPolicy.MaximumAttempts)
}
if retryPolicy.InitialDelay < 0 || retryPolicy.MaximumDelay < 0 {
return errors.New("die wartezeiten dürfen nicht negativ sein")
}
if retryPolicy.MaximumDelay > 0 && retryPolicy.MaximumDelay < retryPolicy.InitialDelay {
return errors.New("die obere grenze der wartezeit liegt unter der anfänglichen wartezeit")
}
if retryPolicy.JitterFraction < 0 || retryPolicy.JitterFraction > 1 {
return errors.New("die streuung muss zwischen 0 und 1 liegen")
}
return nil
}
// RetryDecision ist das Urteil über einen weiteren Versuch.
type RetryDecision struct {
// ShouldRetry meldet, ob ein weiterer Versuch stattfindet.
ShouldRetry bool
// Delay ist die Wartezeit bis dahin.
Delay time.Duration
// Reason erklärt das Urteil verständlich.
//
// Auch ein „nein" braucht eine Begründung: Der Betreiber muss unterscheiden
// können zwischen „aufgegeben, weil die Versuche erschöpft sind" und
// „nicht wiederholt, weil das Passwort falsch ist".
Reason string
}
// Decide entscheidet über einen weiteren Versuch.
//
// attemptNumber ist die Nummer des soeben gescheiterten Versuchs, beginnend
// bei 1.
func (retryPolicy *RetryPolicy) Decide(attemptNumber int, failureClass FailureClass) RetryDecision {
if !failureClass.IsRetryable() {
return RetryDecision{
Reason: fmt.Sprintf("Ein Fehler der Art %q behebt sich nicht durch einen weiteren Versuch.", failureClass),
}
}
if attemptNumber >= retryPolicy.MaximumAttempts {
return RetryDecision{
Reason: fmt.Sprintf("Alle %d zugelassenen Versuche sind aufgebraucht.", retryPolicy.MaximumAttempts),
}
}
return RetryDecision{
ShouldRetry: true,
Delay: retryPolicy.delayForAttempt(attemptNumber),
Reason: fmt.Sprintf("Versuch %d von %d scheiterte an einem Fehler der Art %q; ein weiterer Versuch folgt.",
attemptNumber, retryPolicy.MaximumAttempts, failureClass),
}
}
// delayForAttempt berechnet die Wartezeit nach einem Versuch.
func (retryPolicy *RetryPolicy) delayForAttempt(attemptNumber int) time.Duration {
if retryPolicy.InitialDelay <= 0 {
return 0
}
// Exponentiell: 30 s, 60 s, 120 s, 240 s …
//
// Gerechnet wird in Gleitkomma, weil ein Schieben um mehr als 62 Stellen
// überliefe und aus einer langen Wartezeit eine negative machte.
exponentialFactor := math.Pow(2, float64(attemptNumber-1))
computedDelay := time.Duration(float64(retryPolicy.InitialDelay) * exponentialFactor)
if retryPolicy.MaximumDelay > 0 && computedDelay > retryPolicy.MaximumDelay {
computedDelay = retryPolicy.MaximumDelay
}
if retryPolicy.JitterFraction <= 0 {
return computedDelay
}
// Die Streuung wirkt nur nach unten. Nach oben verlängerte sie die
// Wartezeit über die vereinbarte Grenze hinaus — der Deckel wäre dann
// keiner.
jitterRange := float64(computedDelay) * retryPolicy.JitterFraction
appliedJitter := rand.Float64() * jitterRange
return time.Duration(float64(computedDelay) - appliedJitter)
}
// ClassifyError ordnet einen Fehler anhand bekannter Merkmale ein.
//
// Die Einordnung ist eine Hilfe für Aufrufer, die keine eigene liefern. Sie
// fällt im Zweifel auf FailurePermanent zurück — also auf „nicht wiederholen".
// Der umgekehrte Standard wäre gefährlicher: Ein falsch als vorübergehend
// eingeordneter Fehler ließe einen aussichtslosen Auftrag immer wieder
// anlaufen und verdeckte dabei die eigentliche Ursache.
func ClassifyError(occurredError error, knownClassifications map[error]FailureClass) FailureClass {
if occurredError == nil {
return ""
}
for knownError, failureClass := range knownClassifications {
if errors.Is(occurredError, knownError) {
return failureClass
}
}
return FailurePermanent
}