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:
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user