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".
|
||||
|
||||
Reference in New Issue
Block a user