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
+46
View File
@@ -27,6 +27,52 @@ type Job struct {
CreatedAt time.Time
}
// RunningJobActivity is the persisted activity summary used to detect jobs
// whose terminal message was lost. LastActivity is the newer of started_at and
// the most recent persisted log line; ephemeral progress events deliberately
// do not extend it.
type RunningJobActivity struct {
JobID string
HostID string
Kind string
StartedAt time.Time
LastActivity time.Time
}
// ListRunningJobActivity returns every running job with its latest persisted
// activity. Jobs without started_at are excluded because they have not actually
// entered the running state coherently and cannot be aged safely here.
func (s *Store) ListRunningJobActivity(ctx context.Context) ([]RunningJobActivity, error) {
rows, err := s.db.QueryContext(ctx, `
SELECT j.id, j.host_id, j.kind, j.started_at,
COALESCE(MAX(l.ts), j.started_at) AS last_activity
FROM jobs j
LEFT JOIN job_logs l ON l.job_id = j.id
WHERE j.status = 'running' AND j.started_at IS NOT NULL
GROUP BY j.id, j.host_id, j.kind, j.started_at
ORDER BY j.started_at`)
if err != nil {
return nil, fmt.Errorf("store: list running job activity: %w", err)
}
defer func() { _ = rows.Close() }()
var out []RunningJobActivity
for rows.Next() {
var item RunningJobActivity
var started, activity string
if err := rows.Scan(&item.JobID, &item.HostID, &item.Kind, &started, &activity); err != nil {
return nil, fmt.Errorf("store: scan running job activity: %w", err)
}
item.StartedAt, _ = time.Parse(time.RFC3339Nano, started)
item.LastActivity, _ = time.Parse(time.RFC3339Nano, activity)
out = append(out, item)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("store: iterate running job activity: %w", err)
}
return out, nil
}
// CreateJob inserts a queued job. The agent will mark it running
// when it actually starts work. ScheduledID is set when the job
// originates from a cron fire (actor_kind="schedule"); nil for
+42
View File
@@ -7,6 +7,48 @@ import (
"time"
)
func TestListRunningJobActivity(t *testing.T) {
t.Parallel()
s := openTestStore(t)
ctx := context.Background()
hostID := makeSchedHost(t, s)
started := time.Now().UTC().Add(-8 * time.Hour).Truncate(time.Second)
for _, id := range []string{"running-idle", "running-active", "finished"} {
if err := s.CreateJob(ctx, Job{ID: id, HostID: hostID, Kind: "backup", ActorKind: "user", CreatedAt: started}); err != nil {
t.Fatalf("create %s: %v", id, err)
}
if err := s.MarkJobStarted(ctx, id, started); err != nil {
t.Fatalf("start %s: %v", id, err)
}
}
activity := started.Add(7 * time.Hour)
if err := s.AppendJobLog(ctx, "running-active", 1, activity, "stdout", "still working"); err != nil {
t.Fatalf("append log: %v", err)
}
if err := s.MarkJobFinished(ctx, "finished", "succeeded", 0, nil, "", activity); err != nil {
t.Fatalf("finish: %v", err)
}
got, err := s.ListRunningJobActivity(ctx)
if err != nil {
t.Fatalf("list activity: %v", err)
}
if len(got) != 2 {
t.Fatalf("got %d rows, want 2: %+v", len(got), got)
}
byID := make(map[string]RunningJobActivity, len(got))
for _, row := range got {
byID[row.JobID] = row
}
if !byID["running-idle"].LastActivity.Equal(started) {
t.Errorf("idle last activity = %s, want %s", byID["running-idle"].LastActivity, started)
}
if !byID["running-active"].LastActivity.Equal(activity) {
t.Errorf("active last activity = %s, want %s", byID["running-active"].LastActivity, activity)
}
}
func TestLatestJobByKind(t *testing.T) {
t.Parallel()
s := openTestStore(t)