package jobs import ( "context" "errors" "io" "log/slog" "sync" "testing" "time" "github.com/google/uuid" "github.com/jackc/pgx/v5/pgxpool" "github.com/syncova/syncova/packages/scheduler" ) // recordingExecutor merkt sich die ausgeführten Läufe. // // Er ersetzt die Backup Engine. Ohne diese Trennung liesse sich das // Zusammenspiel von Zeitplan, Wiederholung und Abhängigkeit nur mit einem // echten Repository prüfen — also praktisch gar nicht. type recordingExecutor struct { // mutex schützt den Zustand. mutex sync.Mutex // executedJobs sind die Namen der ausgeführten Aufträge. executedJobs []string // executionCount zählt die Aufrufe. executionCount int // resultToReturn ist das gemeldete Ergebnis. resultToReturn ExecutionResult // errorToReturn ist der gemeldete Fehler. errorToReturn error // blockUntil hält die Ausführung an, bis der Kanal schließt. blockUntil chan struct{} // contextObserved hält den Fehler des Kontexts nach der Ausführung. contextObserved error } // Execute erfüllt die Executor-Schnittstelle. func (executor *recordingExecutor) Execute(executionContext context.Context, executionRequest ExecutionRequest) (ExecutionResult, error) { executor.mutex.Lock() executor.executionCount++ executor.executedJobs = append(executor.executedJobs, executionRequest.Job.Name) blockChannel := executor.blockUntil resultToReturn := executor.resultToReturn errorToReturn := executor.errorToReturn executor.mutex.Unlock() if blockChannel != nil { select { case <-blockChannel: case <-executionContext.Done(): executor.mutex.Lock() executor.contextObserved = executionContext.Err() executor.mutex.Unlock() return ExecutionResult{}, executionContext.Err() } } return resultToReturn, errorToReturn } // count liefert die Zahl der Aufrufe. func (executor *recordingExecutor) count() int { executor.mutex.Lock() defer executor.mutex.Unlock() return executor.executionCount } // newTestLoop baut eine Schleife mit kurzem Takt. func newTestLoop(testInstance *testing.T, connectionPool *pgxpool.Pool, executor Executor) *Loop { testInstance.Helper() testLogger := slog.New(slog.NewJSONHandler(io.Discard, nil)) builtLoop, loopError := NewLoop(NewPostgresStore(connectionPool), executor, LoopOptions{ InstanceName: "test-" + uuid.NewString()[:8], TickInterval: 50 * time.Millisecond, HeartbeatInterval: 100 * time.Millisecond, StaleRunTimeout: time.Second, ShutdownGracePeriod: 3 * time.Second, MaximumConcurrentRuns: 4, }, testLogger) if loopError != nil { testInstance.Fatalf("die Schleife ließ sich nicht bauen: %v", loopError) } return builtLoop } // runLoopUntil führt die Schleife aus, bis die Bedingung erfüllt ist. func runLoopUntil(testInstance *testing.T, executionLoop *Loop, condition func() bool, timeout time.Duration) { testInstance.Helper() loopContext, cancelLoop := context.WithCancel(context.Background()) loopDone := make(chan struct{}) go func() { defer close(loopDone) _ = executionLoop.Run(loopContext) }() deadline := time.Now().Add(timeout) for time.Now().Before(deadline) { if condition() { break } time.Sleep(20 * time.Millisecond) } cancelLoop() select { case <-loopDone: case <-time.After(5 * time.Second): testInstance.Fatal("die Schleife endete nicht innerhalb der Frist") } } // TestLoopExecutesDueJob prüft den Grundfall. func TestLoopExecutesDueJob(testInstance *testing.T) { connectionPool := connectTestDatabase(testInstance) testStore := NewPostgresStore(connectionPool) repositoryID := createTestRepository(testInstance, connectionPool) dueTime := time.Now().UTC().Add(-time.Minute) dueJob := buildStoredJob(repositoryID) dueJob.Schedule = scheduler.Schedule{ScheduleType: scheduler.ScheduleTypeHourly} dueJob.NextRunAt = &dueTime createdJobID, createError := testStore.CreateJob(context.Background(), dueJob) if createError != nil { testInstance.Fatalf("der Auftrag ließ sich nicht anlegen: %v", createError) } cleanupJob(testInstance, connectionPool, createdJobID) testExecutor := &recordingExecutor{ resultToReturn: ExecutionResult{BytesProcessed: 1024, FilesProcessed: 7}, } executionLoop := newTestLoop(testInstance, connectionPool, testExecutor) runLoopUntil(testInstance, executionLoop, func() bool { return testExecutor.count() > 0 }, 5*time.Second) if testExecutor.count() == 0 { testInstance.Fatal("der fällige Auftrag wurde nicht ausgeführt") } finishedRuns, _, listError := testStore.ListRuns(context.Background(), createdJobID, 1, 10) if listError != nil { testInstance.Fatalf("die Läufe ließen sich nicht lesen: %v", listError) } if len(finishedRuns) == 0 { testInstance.Fatal("es wurde kein Lauf angelegt") } if finishedRuns[0].Status != RunStatusSucceeded { testInstance.Errorf("der Lauf endete mit %q: %s", finishedRuns[0].Status, finishedRuns[0].ErrorMessage) } if finishedRuns[0].BytesProcessed != 1024 || finishedRuns[0].FilesProcessed != 7 { testInstance.Errorf("die Kennzahlen kamen nicht an: %d Byte, %d Objekte", finishedRuns[0].BytesProcessed, finishedRuns[0].FilesProcessed) } // Der nächste Zeitpunkt muss fortgeschrieben sein, sonst liefe der Auftrag // im nächsten Durchgang erneut. updatedJob, readError := testStore.GetJob(context.Background(), createdJobID) if readError != nil { testInstance.Fatalf("der Auftrag ließ sich nicht lesen: %v", readError) } if updatedJob.NextRunAt == nil || !updatedJob.NextRunAt.After(time.Now().UTC()) { testInstance.Errorf("der nächste Zeitpunkt wurde nicht fortgeschrieben: %v", updatedJob.NextRunAt) } if updatedJob.LastOutcome != scheduler.OutcomeSucceeded { testInstance.Errorf("der Ausgang wurde am Auftrag als %q vermerkt", updatedJob.LastOutcome) } } // TestLoopTreatsSkippedFilesAsPartialFailure ist der wichtigste Ehrlichkeitstest. // // Der Executor meldet **keinen** Fehler — nur übergangene Objekte. Der Lauf darf // trotzdem nicht als Erfolg dastehen (PROMPT.md §138). func TestLoopTreatsSkippedFilesAsPartialFailure(testInstance *testing.T) { connectionPool := connectTestDatabase(testInstance) testStore := NewPostgresStore(connectionPool) repositoryID := createTestRepository(testInstance, connectionPool) dueTime := time.Now().UTC().Add(-time.Minute) dueJob := buildStoredJob(repositoryID) dueJob.Schedule = scheduler.Schedule{ScheduleType: scheduler.ScheduleTypeHourly} dueJob.NextRunAt = &dueTime createdJobID, _ := testStore.CreateJob(context.Background(), dueJob) cleanupJob(testInstance, connectionPool, createdJobID) testExecutor := &recordingExecutor{ resultToReturn: ExecutionResult{ BytesProcessed: 2048, FilesProcessed: 40, FilesSkipped: 3, SkipReasons: []string{"Der Zugriff wurde verweigert."}, }, } executionLoop := newTestLoop(testInstance, connectionPool, testExecutor) runLoopUntil(testInstance, executionLoop, func() bool { return testExecutor.count() > 0 }, 5*time.Second) finishedRuns, _, _ := testStore.ListRuns(context.Background(), createdJobID, 1, 10) if len(finishedRuns) == 0 { testInstance.Fatal("es wurde kein Lauf angelegt") } if finishedRuns[0].Status != RunStatusPartialFailure { testInstance.Fatalf("ein Lauf mit 3 übergangenen Objekten wurde als %q gewertet", finishedRuns[0].Status) } // Die Begründung muss mitkommen: „3 Objekte übergangen" ist keine Auskunft. if finishedRuns[0].ErrorMessage == "" { testInstance.Error("der Teilfehler wurde nicht begründet") } } // TestPartialFailureBlocksDependentJob prüft die Abhängigkeit. // // Wer eine Datenbank sichert und danach das Anwendungsverzeichnis, will nicht // das Verzeichnis zu einer halben Datenbank. func TestPartialFailureBlocksDependentJob(testInstance *testing.T) { connectionPool := connectTestDatabase(testInstance) testStore := NewPostgresStore(connectionPool) repositoryID := createTestRepository(testInstance, connectionPool) // Der vorausgesetzte Auftrag endete mit einem Teilfehler. predecessorJob := buildStoredJob(repositoryID) predecessorJob.Schedule = scheduler.Schedule{ScheduleType: scheduler.ScheduleTypeManual} predecessorID, _ := testStore.CreateJob(context.Background(), predecessorJob) cleanupJob(testInstance, connectionPool, predecessorID) if _, execError := connectionPool.Exec(context.Background(), `UPDATE backup_jobs SET last_outcome = 'partial_failure' WHERE id = $1`, predecessorID); execError != nil { testInstance.Fatalf("der Ausgang ließ sich nicht setzen: %v", execError) } dueTime := time.Now().UTC().Add(-time.Minute) dependentJob := buildStoredJob(repositoryID) dependentJob.Schedule = scheduler.Schedule{ScheduleType: scheduler.ScheduleTypeHourly} dependentJob.NextRunAt = &dueTime dependentJob.DependsOnJobIDs = []uuid.UUID{predecessorID} dependentID, createError := testStore.CreateJob(context.Background(), dependentJob) if createError != nil { testInstance.Fatalf("der abhängige Auftrag ließ sich nicht anlegen: %v", createError) } cleanupJob(testInstance, connectionPool, dependentID) testExecutor := &recordingExecutor{} executionLoop := newTestLoop(testInstance, connectionPool, testExecutor) runLoopUntil(testInstance, executionLoop, func() bool { runs, _, _ := testStore.ListRuns(context.Background(), dependentID, 1, 10) return len(runs) > 0 && runs[0].IsFinished() }, 5*time.Second) if testExecutor.count() > 0 { testInstance.Fatal("der abhängige Auftrag lief, obwohl der Vorgänger einen Teilfehler meldete") } blockedRuns, _, _ := testStore.ListRuns(context.Background(), dependentID, 1, 10) if len(blockedRuns) == 0 { testInstance.Fatal("es wurde kein Lauf angelegt") } if blockedRuns[0].ErrorCode != "DEPENDENCY_NOT_SATISFIED" { testInstance.Errorf("die Blockade wurde als %q vermerkt", blockedRuns[0].ErrorCode) } } // TestLoopSchedulesRetryOnTransientFailure prüft die Wiederholung. func TestLoopSchedulesRetryOnTransientFailure(testInstance *testing.T) { connectionPool := connectTestDatabase(testInstance) testStore := NewPostgresStore(connectionPool) repositoryID := createTestRepository(testInstance, connectionPool) dueTime := time.Now().UTC().Add(-time.Minute) failingJob := buildStoredJob(repositoryID) failingJob.Schedule = scheduler.Schedule{ScheduleType: scheduler.ScheduleTypeHourly} failingJob.NextRunAt = &dueTime failingJob.RetryPolicy = scheduler.RetryPolicy{ MaximumAttempts: 3, InitialDelay: time.Hour, // weit in der Zukunft, damit der Test nicht wartet MaximumDelay: 2 * time.Hour, } createdJobID, _ := testStore.CreateJob(context.Background(), failingJob) cleanupJob(testInstance, connectionPool, createdJobID) testExecutor := &recordingExecutor{ errorToReturn: &ExecutionError{ Code: "REPOSITORY_UNAVAILABLE", Message: "Das Repository war nicht erreichbar.", FailureClass: scheduler.FailureTransient, }, } executionLoop := newTestLoop(testInstance, connectionPool, testExecutor) runLoopUntil(testInstance, executionLoop, func() bool { runs, _, _ := testStore.ListRuns(context.Background(), createdJobID, 1, 10) return len(runs) >= 2 }, 5*time.Second) allRuns, _, _ := testStore.ListRuns(context.Background(), createdJobID, 1, 10) if len(allRuns) < 2 { testInstance.Fatalf("es wurde keine Wiederholung eingereiht; %d Läufe vorhanden", len(allRuns)) } // Der neueste Lauf ist der Wiederholungsversuch. retryRun := allRuns[0] if retryRun.Trigger != TriggerRetry { testInstance.Errorf("der neue Lauf trägt den Auslöser %q", retryRun.Trigger) } if retryRun.AttemptNumber != 2 { testInstance.Errorf("der Wiederholungsversuch trägt die Nummer %d", retryRun.AttemptNumber) } // Er darf nicht sofort laufen, sondern erst nach der Wartezeit. if retryRun.ScheduledFor == nil || !retryRun.ScheduledFor.After(time.Now().UTC().Add(30*time.Minute)) { testInstance.Errorf("die Wartezeit wurde nicht eingehalten: geplant für %v", retryRun.ScheduledFor) } } // TestLoopDoesNotRetryPermanentFailure prüft den dauerhaften Fehler. // // Ein Anmeldefehler behebt sich nicht durch Warten. func TestLoopDoesNotRetryPermanentFailure(testInstance *testing.T) { connectionPool := connectTestDatabase(testInstance) testStore := NewPostgresStore(connectionPool) repositoryID := createTestRepository(testInstance, connectionPool) dueTime := time.Now().UTC().Add(-time.Minute) failingJob := buildStoredJob(repositoryID) failingJob.Schedule = scheduler.Schedule{ScheduleType: scheduler.ScheduleTypeHourly} failingJob.NextRunAt = &dueTime createdJobID, _ := testStore.CreateJob(context.Background(), failingJob) cleanupJob(testInstance, connectionPool, createdJobID) testExecutor := &recordingExecutor{ errorToReturn: &ExecutionError{ Code: "AUTHENTICATION_FAILED", Message: "Die Anmeldedaten wurden abgelehnt.", FailureClass: scheduler.FailureAuthentication, }, } executionLoop := newTestLoop(testInstance, connectionPool, testExecutor) runLoopUntil(testInstance, executionLoop, func() bool { runs, _, _ := testStore.ListRuns(context.Background(), createdJobID, 1, 10) return len(runs) > 0 && runs[0].IsFinished() }, 3*time.Second) // Kurz warten, damit eine fälschliche Wiederholung Zeit hätte zu erscheinen. time.Sleep(300 * time.Millisecond) allRuns, _, _ := testStore.ListRuns(context.Background(), createdJobID, 1, 10) for _, singleRun := range allRuns { if singleRun.Trigger == TriggerRetry { testInstance.Fatal("ein Anmeldefehler wurde wiederholt, obwohl er sich nicht behebt") } } if len(allRuns) == 0 || allRuns[0].Status != RunStatusFailed { testInstance.Fatalf("der Lauf endete nicht als gescheitert: %+v", allRuns) } } // TestReclaimStaleRunsFreesBlockedJob ist der wichtigste Selbstheilungstest. // // Stirbt ein Control-Server mitten im Lauf, bleibt dessen Zeile auf „running" // stehen. Der Teilindex lässt dann keinen weiteren Lauf zu — die Sicherung // fiele **dauerhaft** aus, ohne dass jemand einen Fehler sähe. func TestReclaimStaleRunsFreesBlockedJob(testInstance *testing.T) { connectionPool := connectTestDatabase(testInstance) testStore := NewPostgresStore(connectionPool) repositoryID := createTestRepository(testInstance, connectionPool) blockedJob := buildStoredJob(repositoryID) blockedJob.Schedule = scheduler.Schedule{ScheduleType: scheduler.ScheduleTypeHourly} createdJobID, _ := testStore.CreateJob(context.Background(), blockedJob) cleanupJob(testInstance, connectionPool, createdJobID) // Ein Lauf, dessen Server vor zwei Stunden verschwand. if _, execError := connectionPool.Exec(context.Background(), ` INSERT INTO backup_job_runs (job_id, status, correlation_id, scheduler_instance, started_at, heartbeat_at) VALUES ($1, 'running', gen_random_uuid(), 'toter-server', now() - interval '2 hours', now() - interval '2 hours')`, createdJobID); execError != nil { testInstance.Fatalf("der verwaiste Lauf ließ sich nicht anlegen: %v", execError) } // Vor der Freigabe blockiert er jeden neuen Lauf. if _, blockedError := testStore.CreateManualRun(context.Background(), createdJobID, nil); !errors.Is(blockedError, ErrRunAlreadyActive) { testInstance.Fatalf("der verwaiste Lauf blockierte nicht wie erwartet: %v", blockedError) } reclaimedCount, reclaimError := testStore.ReclaimStaleRuns(context.Background(), time.Minute) if reclaimError != nil { testInstance.Fatalf("die Freigabe schlug fehl: %v", reclaimError) } if reclaimedCount != 1 { testInstance.Fatalf("es wurden %d Läufe freigegeben, erwartet war 1", reclaimedCount) } // Danach ist der Auftrag wieder ausführbar. if _, freeError := testStore.CreateManualRun(context.Background(), createdJobID, nil); freeError != nil { testInstance.Fatalf("der Auftrag blieb nach der Freigabe blockiert: %v", freeError) } // Der Abbruch muss als Ereignis erhalten bleiben, nicht gelöscht werden. allRuns, _, _ := testStore.ListRuns(context.Background(), createdJobID, 1, 10) var foundReclaimed bool for _, singleRun := range allRuns { if singleRun.ErrorCode == "SCHEDULER_LOST" { foundReclaimed = true if singleRun.FailureClass != scheduler.FailureTransient { testInstance.Errorf("der verwaiste Lauf wurde als %q eingeordnet; eine Wiederholung wäre damit ausgeschlossen", singleRun.FailureClass) } } } if !foundReclaimed { testInstance.Error("der freigegebene Lauf wurde nicht als solcher vermerkt") } } // TestReclaimSparesLiveRuns prüft, dass laufende Vorgänge unberührt bleiben. // // Eine zu enge Frist gäbe einen noch laufenden Lauf frei — und derselbe Auftrag // liefe zweimal. func TestReclaimSparesLiveRuns(testInstance *testing.T) { connectionPool := connectTestDatabase(testInstance) testStore := NewPostgresStore(connectionPool) repositoryID := createTestRepository(testInstance, connectionPool) liveJob := buildStoredJob(repositoryID) liveJob.Schedule = scheduler.Schedule{ScheduleType: scheduler.ScheduleTypeManual} createdJobID, _ := testStore.CreateJob(context.Background(), liveJob) cleanupJob(testInstance, connectionPool, createdJobID) // Ein Lauf, der sich gerade eben gemeldet hat. if _, execError := connectionPool.Exec(context.Background(), ` INSERT INTO backup_job_runs (job_id, status, correlation_id, scheduler_instance, started_at, heartbeat_at) VALUES ($1, 'running', gen_random_uuid(), 'lebender-server', now(), now())`, createdJobID); execError != nil { testInstance.Fatalf("der Lauf ließ sich nicht anlegen: %v", execError) } reclaimedCount, reclaimError := testStore.ReclaimStaleRuns(context.Background(), time.Minute) if reclaimError != nil { testInstance.Fatalf("die Freigabe schlug fehl: %v", reclaimError) } if reclaimedCount != 0 { testInstance.Fatalf("ein lebender Lauf wurde freigegeben (%d); derselbe Auftrag liefe damit zweimal", reclaimedCount) } } // TestLoopShutdownCancelsRunningExecution prüft das geordnete Beenden. // // Ein Prozess, der endet, während noch ein Lauf schreibt, hinterliesse einen // Lauf auf „running" und blockierte den Auftrag bis zum Ablauf der Frist. func TestLoopShutdownCancelsRunningExecution(testInstance *testing.T) { connectionPool := connectTestDatabase(testInstance) testStore := NewPostgresStore(connectionPool) repositoryID := createTestRepository(testInstance, connectionPool) dueTime := time.Now().UTC().Add(-time.Minute) longJob := buildStoredJob(repositoryID) longJob.Schedule = scheduler.Schedule{ScheduleType: scheduler.ScheduleTypeHourly} longJob.NextRunAt = &dueTime createdJobID, _ := testStore.CreateJob(context.Background(), longJob) cleanupJob(testInstance, connectionPool, createdJobID) // Der Executor blockiert, bis der Kontext abgebrochen wird. testExecutor := &recordingExecutor{blockUntil: make(chan struct{})} executionLoop := newTestLoop(testInstance, connectionPool, testExecutor) loopContext, cancelLoop := context.WithCancel(context.Background()) loopDone := make(chan error, 1) go func() { loopDone <- executionLoop.Run(loopContext) }() // Warten, bis der Lauf begonnen hat. startDeadline := time.Now().Add(5 * time.Second) for time.Now().Before(startDeadline) && testExecutor.count() == 0 { time.Sleep(20 * time.Millisecond) } if testExecutor.count() == 0 { cancelLoop() testInstance.Fatal("der Lauf begann nicht") } cancelLoop() select { case shutdownError := <-loopDone: if shutdownError != nil { testInstance.Errorf("das Beenden meldete einen Fehler: %v", shutdownError) } case <-time.After(6 * time.Second): testInstance.Fatal("die Schleife endete nicht") } // Der entscheidende Punkt: Kein Lauf bleibt auf „running" stehen. remainingRuns, _, _ := testStore.ListRuns(context.Background(), createdJobID, 1, 10) for _, singleRun := range remainingRuns { if !singleRun.IsFinished() { testInstance.Fatalf("nach dem Beenden steht ein Lauf noch auf %q; der Auftrag bleibt blockiert", singleRun.Status) } if singleRun.Status != RunStatusCancelled { testInstance.Errorf("der abgebrochene Lauf wurde als %q vermerkt", singleRun.Status) } } } // TestLoopSkipsMissedRunsWithoutBackfill prüft das Nachholverhalten. // // War der Server drei Tage aus, liegt der geplante Zeitpunkt drei Tage zurück. // Der Auftrag läuft **einmal**, nicht dreimal: Drei Sicherungen desselben // Bestands hintereinander kosten Zeit und Platz, ohne einen einzigen // zusätzlichen Wiederherstellungspunkt zu schaffen. func TestLoopSkipsMissedRunsWithoutBackfill(testInstance *testing.T) { connectionPool := connectTestDatabase(testInstance) testStore := NewPostgresStore(connectionPool) repositoryID := createTestRepository(testInstance, connectionPool) // Der Zeitpunkt liegt drei Tage zurück; der Plan läuft täglich. longAgo := time.Now().UTC().Add(-72 * time.Hour) staleJob := buildStoredJob(repositoryID) staleJob.Schedule = scheduler.Schedule{ScheduleType: scheduler.ScheduleTypeDaily, Hour: 2} staleJob.NextRunAt = &longAgo createdJobID, _ := testStore.CreateJob(context.Background(), staleJob) cleanupJob(testInstance, connectionPool, createdJobID) testExecutor := &recordingExecutor{} executionLoop := newTestLoop(testInstance, connectionPool, testExecutor) runLoopUntil(testInstance, executionLoop, func() bool { return testExecutor.count() > 0 }, 5*time.Second) // Kurz warten, damit weitere Läufe Zeit hätten zu erscheinen. time.Sleep(300 * time.Millisecond) if testExecutor.count() != 1 { testInstance.Fatalf("der Auftrag lief %d mal; versäumte Läufe wurden nachgeholt", testExecutor.count()) } // Der nächste Zeitpunkt muss in der Zukunft liegen. updatedJob, _ := testStore.GetJob(context.Background(), createdJobID) if updatedJob.NextRunAt == nil || !updatedJob.NextRunAt.After(time.Now().UTC()) { testInstance.Fatalf("der nächste Zeitpunkt liegt nicht in der Zukunft: %v", updatedJob.NextRunAt) } } // TestNotImplementedExecutorFailsLoudly prüft den Standard ohne Ausführung. // // Ein Executor, der stillschweigend Erfolg meldete, wäre das gefährlichste // Fake-Feature der Anlage: Die Oberfläche zeigte grüne Läufe, und im Repository // läge nichts. func TestNotImplementedExecutorFailsLoudly(testInstance *testing.T) { notImplementedExecutor := &NotImplementedExecutor{} _, executionError := notImplementedExecutor.Execute(context.Background(), ExecutionRequest{}) if executionError == nil { testInstance.Fatal("ein nicht eingerichteter Executor meldete Erfolg") } var typedError *ExecutionError if !errors.As(executionError, &typedError) { testInstance.Fatalf("der Fehler war nicht eingeordnet: %v", executionError) } // Ein Konfigurationsfehler wird nicht wiederholt — richtig so: Er behebt // sich nicht durch Warten. if typedError.FailureClass.IsRetryable() { testInstance.Error("ein fehlender Executor wurde als wiederholbar eingeordnet") } } // TestLoopOptionsRejectShortStaleTimeout prüft die Einstellungsprüfung. // // Eine Frist unterhalb des Meldeabstands gäbe laufende Läufe frei. Der Fehler // wäre im Betrieb kaum zu finden: Ein Auftrag liefe gelegentlich doppelt, ohne // erkennbares Muster. func TestLoopOptionsRejectShortStaleTimeout(testInstance *testing.T) { testLogger := slog.New(slog.NewJSONHandler(io.Discard, nil)) _, loopError := NewLoop(nil, nil, LoopOptions{ HeartbeatInterval: time.Minute, StaleRunTimeout: 30 * time.Second, }, testLogger) if loopError == nil { testInstance.Fatal("eine zu kurze Frist für verwaiste Läufe wurde angenommen") } }