Detect and alert on stuck jobs
CI / Test (rest) (pull_request) Successful in 39s
CI / Test (store) (pull_request) Successful in 40s
CI / Lint (pull_request) Failing after 11s
CI / Build (linux/amd64) (pull_request) Successful in 8s
CI / Build (linux/arm64) (pull_request) Successful in 7s
CI / Build (windows/amd64) (pull_request) Successful in 25s
CI / Test (server-http) (pull_request) Successful in 1m39s
e2e / Playwright vs docker-compose (pull_request) Successful in 1m16s

This commit is contained in:
2026-08-22 10:52:13 +01:00
parent 8ddd3456e1
commit 2110c7dab4
8 changed files with 249 additions and 2 deletions
+60
View File
@@ -3,6 +3,7 @@ package alert
import (
"context"
"path/filepath"
"strings"
"testing"
"time"
@@ -69,6 +70,65 @@ func TestEngineBackupFailedRaisesThenResolves(t *testing.T) {
}
}
func TestEngineStuckJobRaisesDeduplicatesAndResolves(t *testing.T) {
t.Parallel()
eng, st, hostID := setupEngine(t)
ctx := context.Background()
now := time.Now().UTC().Truncate(time.Second)
started := now.Add(-7 * time.Hour)
if err := st.CreateJob(ctx, store.Job{ID: "stuck-job", HostID: hostID, Kind: "prune", ActorKind: "user", CreatedAt: started}); err != nil {
t.Fatalf("create job: %v", err)
}
if err := st.MarkJobStarted(ctx, "stuck-job", started); err != nil {
t.Fatalf("start job: %v", err)
}
eng.evaluateStuckJobs(ctx, now)
eng.evaluateStuckJobs(ctx, now.Add(time.Minute))
open, err := st.ListAlerts(ctx, store.AlertFilter{Status: "open", HostID: hostID})
if err != nil {
t.Fatalf("list alerts: %v", err)
}
if len(open) != 1 || open[0].Kind != KindJobStuck || open[0].DedupKey != "stuck-job" {
t.Fatalf("expected one deduplicated stuck alert, got %+v", open)
}
if !strings.Contains(open[0].Message, started.Format(time.RFC3339)) {
t.Errorf("alert does not identify start/activity time: %q", open[0].Message)
}
if err := st.MarkJobFinished(ctx, "stuck-job", "succeeded", 0, nil, "", now); err != nil {
t.Fatalf("finish job: %v", err)
}
eng.evaluateStuckJobs(ctx, now.Add(2*time.Minute))
open, _ = st.ListAlerts(ctx, store.AlertFilter{Status: "open", HostID: hostID})
if len(open) != 0 {
t.Fatalf("expected terminal job alert resolved, got %+v", open)
}
}
func TestEngineStuckJobUsesLatestLogActivity(t *testing.T) {
t.Parallel()
eng, st, hostID := setupEngine(t)
ctx := context.Background()
now := time.Now().UTC().Truncate(time.Second)
started := now.Add(-7 * time.Hour)
if err := st.CreateJob(ctx, store.Job{ID: "active-job", HostID: hostID, Kind: "forget", ActorKind: "user", CreatedAt: started}); err != nil {
t.Fatalf("create job: %v", err)
}
if err := st.MarkJobStarted(ctx, "active-job", started); err != nil {
t.Fatalf("start job: %v", err)
}
if err := st.AppendJobLog(ctx, "active-job", 1, now.Add(-time.Hour), "stdout", "active"); err != nil {
t.Fatalf("append log: %v", err)
}
eng.evaluateStuckJobs(ctx, now)
open, _ := st.ListAlerts(ctx, store.AlertFilter{Status: "open", HostID: hostID})
if len(open) != 0 {
t.Fatalf("recent activity should suppress stuck alert, got %+v", open)
}
}
func TestEngineCheckFailedSeverityCritical(t *testing.T) {
t.Parallel()
eng, st, hostID := setupEngine(t)