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

415 lines
13 KiB
Go

package scheduler
import (
"errors"
"testing"
"time"
)
// baseTime ist der Bezugszeitpunkt der Warteschlangentests.
var baseTime = time.Date(2026, time.June, 1, 2, 0, 0, 0, time.UTC)
// TestDispatchRespectsPriority prüft die Reihenfolge.
func TestDispatchRespectsPriority(testInstance *testing.T) {
testQueue := NewQueue(ConcurrencyLimits{})
// Bewusst in umgekehrter Reihenfolge eingereiht: Die Warteschlange darf
// nicht der Einreihung, sondern muss der Dringlichkeit folgen.
for _, testCase := range []struct {
jobID string
priority Priority
}{
{"niedrig", PriorityLow},
{"normal", PriorityNormal},
{"kritisch", PriorityCritical},
{"hoch", PriorityHigh},
} {
if enqueueError := testQueue.Enqueue(QueuedJob{
JobID: testCase.jobID,
Priority: testCase.priority,
ScheduledFor: baseTime,
EnqueuedAt: baseTime,
}); enqueueError != nil {
testInstance.Fatalf("das Einreihen schlug fehl: %v", enqueueError)
}
}
decisions := testQueue.Dispatch(baseTime)
expectedOrder := []string{"kritisch", "hoch", "normal", "niedrig"}
if len(decisions) != len(expectedOrder) {
testInstance.Fatalf("es wurden %d Entscheidungen erwartet, geliefert wurden %d", len(expectedOrder), len(decisions))
}
for decisionIndex, expectedJobID := range expectedOrder {
if decisions[decisionIndex].Job.JobID != expectedJobID {
testInstance.Errorf("an Stelle %d stand %q, erwartet war %q",
decisionIndex, decisions[decisionIndex].Job.JobID, expectedJobID)
}
}
}
// TestAgingPreventsStarvation ist der wichtigste Warteschlangentest.
//
// Ohne Alterung verhungert ein Auftrag niedriger Priorität in einer Anlage mit
// genug dringenden Aufträgen für immer — und niemand bemerkt es, weil er
// technisch „wartet" statt zu scheitern.
func TestAgingPreventsStarvation(testInstance *testing.T) {
testQueue := NewQueue(ConcurrencyLimits{})
// Ein niedrig eingestufter Auftrag wartet seit drei Stunden.
if enqueueError := testQueue.Enqueue(QueuedJob{
JobID: "lange-wartend",
Priority: PriorityLow,
ScheduledFor: baseTime.Add(-3 * time.Hour),
EnqueuedAt: baseTime.Add(-3 * time.Hour),
}); enqueueError != nil {
testInstance.Fatalf("das Einreihen schlug fehl: %v", enqueueError)
}
// Ein normaler Auftrag kommt gerade erst.
if enqueueError := testQueue.Enqueue(QueuedJob{
JobID: "frisch",
Priority: PriorityNormal,
ScheduledFor: baseTime,
EnqueuedAt: baseTime,
}); enqueueError != nil {
testInstance.Fatalf("das Einreihen schlug fehl: %v", enqueueError)
}
decisions := testQueue.Dispatch(baseTime)
if decisions[0].Job.JobID != "lange-wartend" {
testInstance.Fatalf("der lange wartende Auftrag verhungert; zuerst kam %q", decisions[0].Job.JobID)
}
}
// TestAgingDoesNotOvertakeCriticalJobs prüft den Deckel.
//
// Ohne ihn überholte ein tagelang wartender Auftrag jede kritische Sicherung.
func TestAgingDoesNotOvertakeCriticalJobs(testInstance *testing.T) {
testQueue := NewQueue(ConcurrencyLimits{})
// Seit einer Woche wartend, aber niedrig eingestuft.
_ = testQueue.Enqueue(QueuedJob{
JobID: "uralt",
Priority: PriorityLow,
ScheduledFor: baseTime.Add(-7 * 24 * time.Hour),
EnqueuedAt: baseTime.Add(-7 * 24 * time.Hour),
})
_ = testQueue.Enqueue(QueuedJob{
JobID: "kritisch",
Priority: PriorityCritical,
ScheduledFor: baseTime,
EnqueuedAt: baseTime,
})
decisions := testQueue.Dispatch(baseTime)
if decisions[0].Job.JobID != "kritisch" {
testInstance.Fatalf("ein alter Auftrag überholte die kritische Sicherung; zuerst kam %q", decisions[0].Job.JobID)
}
}
// TestDispatchSkipsFutureJobs prüft, dass nichts zu früh läuft.
func TestDispatchSkipsFutureJobs(testInstance *testing.T) {
testQueue := NewQueue(ConcurrencyLimits{})
_ = testQueue.Enqueue(QueuedJob{
JobID: "spaeter",
Priority: PriorityCritical,
ScheduledFor: baseTime.Add(time.Hour),
EnqueuedAt: baseTime,
})
if decisions := testQueue.Dispatch(baseTime); len(decisions) != 0 {
testInstance.Fatalf("ein künftiger Auftrag wurde freigegeben: %+v", decisions)
}
}
// TestGlobalConcurrencyLimitIsEnforced prüft die Gesamtgrenze.
func TestGlobalConcurrencyLimitIsEnforced(testInstance *testing.T) {
testQueue := NewQueue(ConcurrencyLimits{GlobalLimit: 2})
for _, jobIdentifier := range []string{"eins", "zwei", "drei", "vier"} {
_ = testQueue.Enqueue(QueuedJob{
JobID: jobIdentifier,
Priority: PriorityNormal,
ScheduledFor: baseTime,
EnqueuedAt: baseTime,
})
}
decisions := testQueue.Dispatch(baseTime)
var runnableCount int
for _, decision := range decisions {
if decision.CanRun {
runnableCount++
}
}
if runnableCount != 2 {
testInstance.Fatalf("es wurden %d Aufträge freigegeben, zugelassen sind 2", runnableCount)
}
// Die abgelehnten müssen eine Begründung tragen. Ein wartender Auftrag ohne
// Begründung ist für den Betrieb wertlos.
for _, decision := range decisions {
if !decision.CanRun && decision.Reason == "" {
testInstance.Errorf("der Auftrag %q wartet ohne Begründung", decision.Job.JobID)
}
}
}
// TestPerRepositoryLimitIsEnforced prüft die Grenze je Repository.
func TestPerRepositoryLimitIsEnforced(testInstance *testing.T) {
testQueue := NewQueue(ConcurrencyLimits{PerRepositoryLimit: 1})
testQueue.RegisterJobMetadata("job-a", "repo-1", "quelle-a")
testQueue.RegisterJobMetadata("job-b", "repo-1", "quelle-b")
testQueue.RegisterJobMetadata("job-c", "repo-2", "quelle-c")
for _, jobIdentifier := range []string{"job-a", "job-b", "job-c"} {
_ = testQueue.Enqueue(QueuedJob{
JobID: jobIdentifier,
Priority: PriorityNormal,
ScheduledFor: baseTime,
EnqueuedAt: baseTime,
})
}
decisions := testQueue.Dispatch(baseTime)
runnableByJob := make(map[string]bool)
for _, decision := range decisions {
runnableByJob[decision.Job.JobID] = decision.CanRun
}
// Auf repo-1 darf nur einer laufen, auf repo-2 der dritte.
if runnableByJob["job-a"] == runnableByJob["job-b"] {
testInstance.Error("auf demselben Repository liefen zwei Aufträge gleichzeitig")
}
if !runnableByJob["job-c"] {
testInstance.Error("ein Auftrag auf einem anderen Repository wurde unnötig zurückgehalten")
}
}
// TestPerSourceLimitPreventsDoubleBackup prüft die Grenze je Quelle.
func TestPerSourceLimitPreventsDoubleBackup(testInstance *testing.T) {
testQueue := NewQueue(ConcurrencyLimits{PerSourceLimit: 1})
testQueue.RegisterJobMetadata("taeglich", "repo-1", "vm-100")
testQueue.RegisterJobMetadata("stuendlich", "repo-2", "vm-100")
for _, jobIdentifier := range []string{"taeglich", "stuendlich"} {
_ = testQueue.Enqueue(QueuedJob{
JobID: jobIdentifier,
Priority: PriorityNormal,
ScheduledFor: baseTime,
EnqueuedAt: baseTime,
})
}
decisions := testQueue.Dispatch(baseTime)
var runnableCount int
for _, decision := range decisions {
if decision.CanRun {
runnableCount++
}
}
if runnableCount != 1 {
testInstance.Fatalf("dieselbe Quelle wurde %d mal gleichzeitig gesichert", runnableCount)
}
}
// TestDependencyBlocksUntilPredecessorSucceeds prüft Abhängigkeiten.
func TestDependencyBlocksUntilPredecessorSucceeds(testInstance *testing.T) {
testQueue := NewQueue(ConcurrencyLimits{})
_ = testQueue.Enqueue(QueuedJob{
JobID: "anwendung",
Priority: PriorityNormal,
ScheduledFor: baseTime,
EnqueuedAt: baseTime,
DependsOnJobIDs: []string{"datenbank"},
})
// Ohne Vorgeschichte darf nichts laufen.
decisions := testQueue.Dispatch(baseTime)
if decisions[0].CanRun {
testInstance.Fatal("ein abhängiger Auftrag lief, obwohl der Vorgänger nie lief")
}
// Nach einem Erfolg des Vorgängers darf er laufen.
testQueue.MarkFinished("datenbank", OutcomeSucceeded)
decisions = testQueue.Dispatch(baseTime)
if !decisions[0].CanRun {
testInstance.Fatalf("der abhängige Auftrag blieb blockiert: %s", decisions[0].Reason)
}
}
// TestDependencyRejectsPartialFailure ist ein Ehrlichkeitstest.
//
// Ein Teilfehler ist kein Erfolg (PROMPT.md §138). Wer eine Datenbank sichert
// und danach das Anwendungsverzeichnis, will nicht das Verzeichnis zu einer
// halben Datenbank.
func TestDependencyRejectsPartialFailure(testInstance *testing.T) {
testQueue := NewQueue(ConcurrencyLimits{})
_ = testQueue.Enqueue(QueuedJob{
JobID: "anwendung",
Priority: PriorityNormal,
ScheduledFor: baseTime,
EnqueuedAt: baseTime,
DependsOnJobIDs: []string{"datenbank"},
})
testQueue.MarkFinished("datenbank", OutcomePartialFailure)
decisions := testQueue.Dispatch(baseTime)
if decisions[0].CanRun {
testInstance.Fatal("ein abhängiger Auftrag lief nach einem Teilfehler des Vorgängers")
}
if decisions[0].Reason == "" {
testInstance.Error("die Blockade wurde nicht begründet")
}
}
// TestDependencyWaitsWhilePredecessorRuns prüft den laufenden Vorgänger.
func TestDependencyWaitsWhilePredecessorRuns(testInstance *testing.T) {
testQueue := NewQueue(ConcurrencyLimits{})
testQueue.MarkFinished("datenbank", OutcomeSucceeded)
testQueue.MarkRunning("datenbank", baseTime)
_ = testQueue.Enqueue(QueuedJob{
JobID: "anwendung",
ScheduledFor: baseTime,
EnqueuedAt: baseTime,
DependsOnJobIDs: []string{"datenbank"},
})
decisions := testQueue.Dispatch(baseTime)
if decisions[0].CanRun {
testInstance.Fatal("ein abhängiger Auftrag lief, während der Vorgänger noch läuft")
}
}
// TestEnqueueRejectsDuplicates prüft den Schutz gegen Mehrfacheinreihung.
//
// Ohne ihn entstünden bei einem überlasteten System immer mehr Einträge
// desselben Auftrags, bis die Warteschlange den Speicher füllt.
func TestEnqueueRejectsDuplicates(testInstance *testing.T) {
testQueue := NewQueue(ConcurrencyLimits{})
firstJob := QueuedJob{JobID: "taeglich", ScheduledFor: baseTime, EnqueuedAt: baseTime}
if enqueueError := testQueue.Enqueue(firstJob); enqueueError != nil {
testInstance.Fatalf("das erste Einreihen schlug fehl: %v", enqueueError)
}
if enqueueError := testQueue.Enqueue(firstJob); !errors.Is(enqueueError, ErrJobAlreadyQueued) {
testInstance.Fatalf("ein doppeltes Einreihen wurde angenommen: %v", enqueueError)
}
testQueue.MarkRunning("taeglich", baseTime)
if enqueueError := testQueue.Enqueue(firstJob); !errors.Is(enqueueError, ErrJobAlreadyQueued) {
testInstance.Fatalf("ein laufender Auftrag wurde erneut eingereiht: %v", enqueueError)
}
}
// TestDependencyCycleIsDetected prüft die Ringerkennung.
//
// Ein Ring bliebe sonst unbemerkt: Jeder beteiligte Auftrag wartete auf einen
// anderen, keiner liefe je an, und die Oberfläche zeigte lauter „wartende"
// Aufträge ohne erkennbaren Grund.
func TestDependencyCycleIsDetected(testInstance *testing.T) {
cyclicDependencies := map[string][]string{
"a": {"b"},
"b": {"c"},
"c": {"a"},
}
cycleError := ValidateDependencyGraph(cyclicDependencies)
if !errors.Is(cycleError, ErrDependencyCycle) {
testInstance.Fatalf("der Ring wurde nicht erkannt: %v", cycleError)
}
// Die Fehlermeldung muss den Weg zeigen, sonst ist sie nutzlos.
if len(cycleError.Error()) < 20 {
testInstance.Errorf("die Fehlermeldung nennt den Ring nicht: %v", cycleError)
}
}
// TestSelfDependencyIsDetected prüft den Ring der Länge eins.
func TestSelfDependencyIsDetected(testInstance *testing.T) {
if cycleError := ValidateDependencyGraph(map[string][]string{
"a": {"a"},
}); !errors.Is(cycleError, ErrDependencyCycle) {
testInstance.Fatalf("eine Selbstabhängigkeit wurde angenommen: %v", cycleError)
}
}
// TestValidDependencyGraphIsAccepted prüft den Normalfall.
func TestValidDependencyGraphIsAccepted(testInstance *testing.T) {
// Ein Diamant: d hängt von b und c ab, beide von a. Kein Ring.
validDependencies := map[string][]string{
"a": {},
"b": {"a"},
"c": {"a"},
"d": {"b", "c"},
}
if cycleError := ValidateDependencyGraph(validDependencies); cycleError != nil {
testInstance.Fatalf("ein gültiger Abhängigkeitsgraph wurde abgelehnt: %v", cycleError)
}
}
// TestMarkRunningRemovesFromPending prüft den Zustandsübergang.
func TestMarkRunningRemovesFromPending(testInstance *testing.T) {
testQueue := NewQueue(ConcurrencyLimits{})
_ = testQueue.Enqueue(QueuedJob{JobID: "taeglich", ScheduledFor: baseTime, EnqueuedAt: baseTime})
if testQueue.PendingCount() != 1 {
testInstance.Fatalf("es wartet %d Auftrag, erwartet war 1", testQueue.PendingCount())
}
testQueue.MarkRunning("taeglich", baseTime)
if testQueue.PendingCount() != 0 || testQueue.RunningCount() != 1 {
testInstance.Errorf("nach dem Start warten %d und laufen %d Aufträge",
testQueue.PendingCount(), testQueue.RunningCount())
}
testQueue.MarkFinished("taeglich", OutcomeSucceeded)
if testQueue.RunningCount() != 0 {
testInstance.Errorf("nach dem Ende laufen noch %d Aufträge", testQueue.RunningCount())
}
}
// TestUnknownPriorityDefaultsToNormal prüft den Umgang mit Tippfehlern.
//
// Eine unbekannte Stufe darf einen Auftrag nicht an die Spitze befördern.
func TestUnknownPriorityDefaultsToNormal(testInstance *testing.T) {
unknownPriority := Priority("sehr-dringend")
if unknownPriority.IsValid() {
testInstance.Error("eine erfundene Stufe wurde als gültig gemeldet")
}
if unknownPriority.Rank() != PriorityNormal.Rank() {
testInstance.Errorf("eine unbekannte Stufe erhielt den Rang %d statt %d",
unknownPriority.Rank(), PriorityNormal.Rank())
}
}