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