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
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:
@@ -28,6 +28,11 @@ import (
|
||||
// are evaluated — always-on hosts' stale_schedule stays a no-op.
|
||||
const staleBackupThreshold = 7 * 24 * time.Hour
|
||||
|
||||
const (
|
||||
defaultStuckJobThreshold = 6 * time.Hour
|
||||
longStuckJobThreshold = 24 * time.Hour
|
||||
)
|
||||
|
||||
// JobFinishedEvent carries everything the engine needs to evaluate
|
||||
// the failed-X rules. Pushed via Engine.NotifyJobFinished from the
|
||||
// MarkJobFinished site.
|
||||
@@ -53,6 +58,7 @@ type Engine struct {
|
||||
// we raise. Configurable for tests; default 15m.
|
||||
agentOfflineFloor time.Duration
|
||||
tickPeriod time.Duration
|
||||
stuckThresholds map[string]time.Duration
|
||||
|
||||
closeOnce sync.Once
|
||||
done chan struct{}
|
||||
@@ -69,7 +75,12 @@ func NewEngine(st *store.Store, hub *notification.Hub) *Engine {
|
||||
hostUp: make(chan string, 32),
|
||||
agentOfflineFloor: 15 * time.Minute,
|
||||
tickPeriod: 60 * time.Second,
|
||||
done: make(chan struct{}),
|
||||
stuckThresholds: map[string]time.Duration{
|
||||
"backup": longStuckJobThreshold,
|
||||
"restore": longStuckJobThreshold,
|
||||
"check": longStuckJobThreshold,
|
||||
},
|
||||
done: make(chan struct{}),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -125,6 +136,10 @@ func (e *Engine) NotifyHostOnline(hostID string) {
|
||||
}
|
||||
|
||||
func (e *Engine) handleJobFinished(ctx context.Context, ev JobFinishedEvent) {
|
||||
// A late terminal message is authoritative and clears any stuck alert for
|
||||
// this exact job regardless of its outcome or kind.
|
||||
e.resolveAndNotify(ctx, ev.HostID, KindJobStuck, ev.JobID, ev.When)
|
||||
|
||||
// Determine which kind/severity pair this job maps to. Jobs not
|
||||
// listed here (init, unlock, restore, diff) produce no alerts in v1.
|
||||
var kind, severity string
|
||||
@@ -210,6 +225,7 @@ func (e *Engine) tick(ctx context.Context, now time.Time) {
|
||||
if _, err := e.store.CleanupExpiredOIDCState(ctx, now.Add(-5*time.Minute)); err != nil {
|
||||
slog.Warn("alert: cleanup expired oidc state", "err", err)
|
||||
}
|
||||
e.evaluateStuckJobs(ctx, now)
|
||||
|
||||
hosts, err := e.store.ListHosts(ctx)
|
||||
if err != nil {
|
||||
@@ -257,6 +273,46 @@ func (e *Engine) tick(ctx context.Context, now time.Time) {
|
||||
}
|
||||
}
|
||||
|
||||
func (e *Engine) evaluateStuckJobs(ctx context.Context, now time.Time) {
|
||||
running, err := e.store.ListRunningJobActivity(ctx)
|
||||
if err != nil {
|
||||
slog.Warn("alert: tick list running jobs", "err", err)
|
||||
return
|
||||
}
|
||||
active := make(map[string]store.RunningJobActivity, len(running))
|
||||
for _, job := range running {
|
||||
active[job.JobID] = job
|
||||
threshold := defaultStuckJobThreshold
|
||||
if configured, ok := e.stuckThresholds[job.Kind]; ok {
|
||||
threshold = configured
|
||||
}
|
||||
age := now.Sub(job.LastActivity)
|
||||
if age < threshold {
|
||||
continue
|
||||
}
|
||||
e.raiseAndNotify(ctx, job.HostID, KindJobStuck, job.JobID, "warning",
|
||||
fmt.Sprintf("%s job %s is stuck: started %s, last activity %s (%s ago; threshold %s)",
|
||||
job.Kind, job.JobID, job.StartedAt.Format(time.RFC3339),
|
||||
job.LastActivity.Format(time.RFC3339), roundDur(age), threshold), now)
|
||||
}
|
||||
|
||||
// Self-heal alerts when another server path made a job terminal without
|
||||
// emitting JobFinishedEvent, or after a restart missed the event.
|
||||
alerts, err := e.store.ListAlerts(ctx, store.AlertFilter{Status: "open"})
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
acked, _ := e.store.ListAlerts(ctx, store.AlertFilter{Status: "acknowledged"})
|
||||
for _, item := range append(alerts, acked...) {
|
||||
if item.Kind != KindJobStuck || item.HostID == nil {
|
||||
continue
|
||||
}
|
||||
if _, ok := active[item.DedupKey]; !ok {
|
||||
e.resolveAndNotify(ctx, *item.HostID, KindJobStuck, item.DedupKey, now)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// roundDur returns a human-readable duration string, rounding to the
|
||||
// nearest minute. Durations under a minute are reported as "less than
|
||||
// a minute".
|
||||
|
||||
@@ -36,6 +36,10 @@ const (
|
||||
// KindAgentOffline is raised when a host's last_seen_at is older
|
||||
// than the 15-minute floor and resolved when the host reconnects.
|
||||
KindAgentOffline = "agent_offline"
|
||||
|
||||
// KindJobStuck is raised per job when a running job has no persisted
|
||||
// activity beyond its kind-specific threshold. The job ID is the dedup key.
|
||||
KindJobStuck = "job_stuck"
|
||||
)
|
||||
|
||||
// raiseAndNotify is the standard raise pattern: store.RaiseOrTouch
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user