Skip to content
This repository was archived by the owner on May 19, 2026. It is now read-only.

Commit 4b24a5f

Browse files
luhenryclaude
authored andcommitted
scheduler: detect job stuck queued while its parent run has completed
A workflow_job sometimes stays status=queued on GitHub forever after its parent workflow run terminates -- e.g. when a sibling fails fast or the run is cancelled before scheduling reaches the job. The scheduler used to keep trying to provision a runner for those jobs indefinitely. sync_jobs now probes the parent run when GitHub still reports the job queued. If the run is completed, mark the job failed with a v1 record that captures the run's conclusion. The probe is gated on JobStuckQueuedMinAge (10 min in internal/constants.go) so the normal freshly-queued window is not charged for an extra GitHub API call every reconcile cycle. Side fixes hit while wiring this up: - internal.FailureInfo gained a Message field so v1 rows ({version:1, message:"..."}) match the legacy on-disk shape templates_test.go has been pinning all along. The two existing v1 callsites in sync_jobs (installation 404, job 404) were stuffing the human message into the typed Reason field; switch them to Message. Document the v1 vs v2 split in the struct doc. - Extend internal.GHJob with the run_id field GitHub already returns, and add GHRun + GitHubClient.GetRunInfo for the new probe. - Wire OnGetRunInfo through FakeGH for tests. https://claude.ai/code/session_01Vda2TpwJnGYRYuw1Xg46Da
1 parent bd3fd87 commit 4b24a5f

14 files changed

Lines changed: 267 additions & 54 deletions

File tree

‎container/cmd/ghfe/staging_proxy_test.go‎

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,7 @@ func stagingPayload() []byte {
5252
"installation": map[string]any{"id": float64(1)},
5353
"repository": map[string]any{
5454
"id": float64(2),
55-
"full_name": "riseproject-dev/riscv-runner-sample",
55+
"full_name": "riseproject-dev/riscv-runner-sample-staging",
5656
"owner": map[string]any{"id": float64(internal.RiseprojectDevOrgID), "type": "Organization", "login": "riseproject-dev"},
5757
},
5858
"workflow_job": map[string]any{
@@ -172,9 +172,9 @@ func TestShouldProxyToStaging_Negatives(t *testing.T) {
172172
ent internal.Entity
173173
repo string
174174
}{
175-
{"staging instance", internal.Config{Prod: false, StagingURL: "https://s"}, riseEntity, "riseproject-dev/riscv-runner-sample"},
176-
{"no staging url", internal.Config{Prod: true}, riseEntity, "riseproject-dev/riscv-runner-sample"},
177-
{"entity not in config", prod, unknownEntity, "riseproject-dev/riscv-runner-sample"},
175+
{"staging instance", internal.Config{Prod: false, StagingURL: "https://s"}, riseEntity, "riseproject-dev/riscv-runner-sample-staging"},
176+
{"no staging url", internal.Config{Prod: true}, riseEntity, "riseproject-dev/riscv-runner-sample-staging"},
177+
{"entity not in config", prod, unknownEntity, "riseproject-dev/riscv-runner-sample-staging"},
178178
{"repo not in entity staging list", prod, riseEntity, "riseproject-dev/something-else"},
179179
}
180180
for _, tc := range cases {
@@ -183,7 +183,7 @@ func TestShouldProxyToStaging_Negatives(t *testing.T) {
183183
}
184184
}
185185

186-
if !shouldProxyToStaging(prod, riseEntity, "riseproject-dev/riscv-runner-sample") {
186+
if !shouldProxyToStaging(prod, riseEntity, "riseproject-dev/riscv-runner-sample-staging") {
187187
t.Error("positive case: prod + sample repo should proxy")
188188
}
189189
}

‎container/cmd/scheduler/demand_match.go‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -113,7 +113,7 @@ func (a *App) tryProvision(ctx context.Context, j internal.Job) bool {
113113
if err := a.provisionRunner(ctx, j, runnerName, labels); err != nil {
114114
slog.Error("Failed to provision runner",
115115
"entity", e, "runner_name", runnerName, "k8s_pool", j.K8sPool, "err", err)
116-
info := internal.FailureInfo{Version: 2, Reason: internal.ReasonPodAllocationFailure}
116+
info := internal.FailureInfoV2{Reason: internal.ReasonPodAllocationFailure}
117117
_ = a.DB.MarkWorkerFailed(ctx, runnerName, "", info, nil)
118118
// Row was created, slot is consumed.
119119
return true

‎container/cmd/scheduler/demand_match_test.go‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -94,8 +94,8 @@ func TestProvisionRunner_FailureMarksWorker(t *testing.T) {
9494
if len(db.MarkFailed) != 1 {
9595
t.Fatalf("expected 1 mark_worker_failed call, got %v", db.MarkFailed)
9696
}
97-
if db.MarkFailed[0].Info.Reason != internal.ReasonPodAllocationFailure {
98-
t.Errorf("failure_info.reason=%q want pod_allocation_failure", db.MarkFailed[0].Info.Reason)
97+
if db.MarkFailed[0].Info.(internal.FailureInfoV2).Reason != internal.ReasonPodAllocationFailure {
98+
t.Errorf("failure_info.reason=%q want pod_allocation_failure", db.MarkFailed[0].Info.(internal.FailureInfoV2).Reason)
9999
}
100100
}
101101

‎container/cmd/scheduler/sync_jobs.go‎

Lines changed: 40 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import (
55
"errors"
66
"fmt"
77
"log/slog"
8+
"time"
89

910
"github.com/riseproject-dev/riscv-runner-app/container/internal"
1011
)
@@ -42,10 +43,9 @@ func (a *App) syncOneJob(ctx context.Context, j internal.Job) error {
4243
if errors.As(err, &apiErr) && apiErr.StatusCode == 404 {
4344
slog.Warn("Installation not found, marking job failed",
4445
"entity", e, "installation_id", j.InstallationID, "job_id", j.JobID)
45-
_, _ = a.DB.MarkJobFailed(ctx, j.JobID, internal.FailureInfo{
46-
Version: 1,
47-
Reason: internal.FailureReason(fmt.Sprintf("installation not found for installation_id=%d entity_type=%s",
48-
j.InstallationID, e.Type)),
46+
_, _ = a.DB.MarkJobFailed(ctx, j.JobID, internal.FailureInfoV1{
47+
Message: fmt.Sprintf("installation not found for installation_id=%d entity_type=%s",
48+
j.InstallationID, e.Type),
4949
})
5050
return nil
5151
}
@@ -59,10 +59,9 @@ func (a *App) syncOneJob(ctx context.Context, j internal.Job) error {
5959
var apiErr *internal.GitHubAPIError
6060
if errors.As(err, &apiErr) && apiErr.StatusCode == 404 {
6161
slog.Warn("Job not found, marking as failed", "entity", e, "job_id", j.JobID)
62-
_, _ = a.DB.MarkJobFailed(ctx, j.JobID, internal.FailureInfo{
63-
Version: 1,
64-
Reason: internal.FailureReason(fmt.Sprintf("job not found for job_id=%d entity=%s entity_id=%d entity_type=%s",
65-
j.JobID, e.Name, e.ID, e.Type)),
62+
_, _ = a.DB.MarkJobFailed(ctx, j.JobID, internal.FailureInfoV1{
63+
Message: fmt.Sprintf("job not found for job_id=%d entity=%s entity_id=%d entity_type=%s",
64+
j.JobID, e.Name, e.ID, e.Type),
6665
})
6766
return nil
6867
}
@@ -86,6 +85,39 @@ func (a *App) syncOneJob(ctx context.Context, j internal.Job) error {
8685
"entity", e, "job_id", j.JobID)
8786
_, _ = a.DB.MarkJobRunning(ctx, j.JobID, ghJob.RunnerName)
8887
}
88+
case "queued":
89+
a.reconcileStuckQueued(ctx, j, ghJob, token)
8990
}
9091
return nil
9192
}
93+
94+
// reconcileStuckQueued detects jobs that GitHub still reports as queued
95+
// even though their parent workflow run has already terminated. This
96+
// happens when a run is cancelled (or a sibling fails fast-fail-style)
97+
// before scheduling reaches the job; the job then sits queued forever
98+
// and the scheduler would otherwise keep trying to provision a runner
99+
// for a job that will never start. We mark the row failed with the
100+
// run's conclusion so the worker slot frees up.
101+
func (a *App) reconcileStuckQueued(ctx context.Context, j internal.Job, ghJob internal.GHJob, token string) {
102+
if ghJob.RunID == 0 || time.Since(j.CreatedAt) < internal.JobStuckQueuedMinAge {
103+
return
104+
}
105+
run, err := a.GH.GetRunInfo(ctx, token, j.RepoFullName, ghJob.RunID)
106+
if err != nil {
107+
slog.Debug("GetRunInfo failed", "entity", j.Entity(), "job_id", j.JobID, "run_id", ghJob.RunID, "err", err)
108+
return
109+
}
110+
if run.Status != "completed" {
111+
return
112+
}
113+
conclusion := "unknown"
114+
if run.Conclusion != nil && *run.Conclusion != "" {
115+
conclusion = *run.Conclusion
116+
}
117+
slog.Warn("GH reconcile: job stuck queued while run is completed; marking failed",
118+
"entity", j.Entity(), "job_id", j.JobID, "run_id", ghJob.RunID, "run_conclusion", conclusion)
119+
_, _ = a.DB.MarkJobFailed(ctx, j.JobID, internal.FailureInfoV1{
120+
Message: fmt.Sprintf("workflow run %d completed (conclusion=%s) while job %d stayed queued",
121+
ghJob.RunID, conclusion, j.JobID),
122+
})
123+
}

‎container/cmd/scheduler/sync_jobs_test.go‎

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,9 @@ package main
22

33
import (
44
"context"
5+
"strings"
56
"testing"
7+
"time"
68

79
"github.com/riseproject-dev/riscv-runner-app/container/internal"
810
)
@@ -164,6 +166,101 @@ func TestSyncOneJob_InProgressFromPendingPromotesToRunning(t *testing.T) {
164166
}
165167
}
166168

169+
// TestSyncOneJob_StuckQueuedMarksFailedWhenRunCompleted covers the case
170+
// where GitHub reports a job still queued even though the parent workflow
171+
// run has terminated. The scheduler should detect this and mark the job
172+
// failed so the slot frees up.
173+
func TestSyncOneJob_StuckQueuedMarksFailedWhenRunCompleted(t *testing.T) {
174+
app, db, gh, _ := schedTestApp()
175+
gh.OnAuthenticateApp = func(int64, int64) (string, error) { return "tok", nil }
176+
gh.OnGetJobInfo = func(string, string, int64) (internal.GHJob, error) {
177+
return internal.GHJob{Status: "queued", RunID: 4242}, nil
178+
}
179+
conc := "failure"
180+
gh.OnGetRunInfo = func(_, _ string, runID int64) (internal.GHRun, error) {
181+
if runID != 4242 {
182+
t.Errorf("GetRunInfo called with runID=%d, want 4242", runID)
183+
}
184+
return internal.GHRun{Status: "completed", Conclusion: &conc}, nil
185+
}
186+
var markedMessage string
187+
db.OnMarkJobFailed = func(_ int64, info internal.FailureInfo) (string, error) {
188+
v1, ok := info.(internal.FailureInfoV1)
189+
if !ok {
190+
t.Fatalf("expected FailureInfoV1, got %T", info)
191+
}
192+
markedMessage = v1.Message
193+
return "pending", nil
194+
}
195+
db.Jobs = []internal.Job{{
196+
JobID: 7, Status: "pending", RepoFullName: "a/r", EntityName: "a",
197+
EntityType: "Organization", InstallationID: 9,
198+
CreatedAt: time.Now().Add(-2 * internal.JobStuckQueuedMinAge),
199+
}}
200+
if err := app.syncJobsState(context.Background()); err != nil {
201+
t.Fatal(err)
202+
}
203+
if markedMessage == "" {
204+
t.Fatal("expected MarkJobFailed to be called")
205+
}
206+
if !strings.Contains(markedMessage, "stayed queued") || !strings.Contains(markedMessage, "failure") {
207+
t.Errorf("message missing expected fragments: %q", markedMessage)
208+
}
209+
}
210+
211+
// TestSyncOneJob_StuckQueuedSkippedWhenJobYoung verifies we do not burn
212+
// GitHub API quota probing the run on freshly-queued jobs.
213+
func TestSyncOneJob_StuckQueuedSkippedWhenJobYoung(t *testing.T) {
214+
app, db, gh, _ := schedTestApp()
215+
gh.OnAuthenticateApp = func(int64, int64) (string, error) { return "tok", nil }
216+
gh.OnGetJobInfo = func(string, string, int64) (internal.GHJob, error) {
217+
return internal.GHJob{Status: "queued", RunID: 1}, nil
218+
}
219+
runProbed := false
220+
gh.OnGetRunInfo = func(string, string, int64) (internal.GHRun, error) {
221+
runProbed = true
222+
return internal.GHRun{}, nil
223+
}
224+
db.Jobs = []internal.Job{{
225+
JobID: 1, Status: "pending", RepoFullName: "a/r", EntityName: "a",
226+
EntityType: "Organization", InstallationID: 9,
227+
CreatedAt: time.Now(), // brand new
228+
}}
229+
if err := app.syncJobsState(context.Background()); err != nil {
230+
t.Fatal(err)
231+
}
232+
if runProbed {
233+
t.Errorf("GetRunInfo should not be called for jobs younger than JobStuckQueuedMinAge")
234+
}
235+
if len(db.MarkFailed) != 0 {
236+
t.Errorf("young queued job must not be marked failed")
237+
}
238+
}
239+
240+
// TestSyncOneJob_StuckQueuedRunStillRunningIsNoop covers the case where
241+
// the run is still in flight — we probe but leave the job alone.
242+
func TestSyncOneJob_StuckQueuedRunStillRunningIsNoop(t *testing.T) {
243+
app, db, gh, _ := schedTestApp()
244+
gh.OnAuthenticateApp = func(int64, int64) (string, error) { return "tok", nil }
245+
gh.OnGetJobInfo = func(string, string, int64) (internal.GHJob, error) {
246+
return internal.GHJob{Status: "queued", RunID: 1}, nil
247+
}
248+
gh.OnGetRunInfo = func(string, string, int64) (internal.GHRun, error) {
249+
return internal.GHRun{Status: "in_progress"}, nil
250+
}
251+
db.Jobs = []internal.Job{{
252+
JobID: 1, Status: "pending", RepoFullName: "a/r", EntityName: "a",
253+
EntityType: "Organization", InstallationID: 9,
254+
CreatedAt: time.Now().Add(-2 * internal.JobStuckQueuedMinAge),
255+
}}
256+
if err := app.syncJobsState(context.Background()); err != nil {
257+
t.Fatal(err)
258+
}
259+
if len(db.MarkFailed) != 0 {
260+
t.Errorf("queued job with still-running parent run must not be marked failed")
261+
}
262+
}
263+
167264
// TestSyncOneJob_InProgressFromRunningIsNoop covers the no-op branch when DB
168265
// already has running.
169266
func TestSyncOneJob_InProgressFromRunningIsNoop(t *testing.T) {

‎container/cmd/scheduler/sync_workers_test.go‎

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -48,8 +48,8 @@ func TestPhase3_OfflineRunnerPastTimeoutFails(t *testing.T) {
4848
if len(db.MarkFailed) != 1 {
4949
t.Fatalf("expected MarkWorkerFailed, got %v", db.MarkFailed)
5050
}
51-
if db.MarkFailed[0].Info.Reason != internal.ReasonRunnerNeverRegistered {
52-
t.Errorf("reason=%q want runner_never_registered", db.MarkFailed[0].Info.Reason)
51+
if db.MarkFailed[0].Info.(internal.FailureInfoV2).Reason != internal.ReasonRunnerNeverRegistered {
52+
t.Errorf("reason=%q want runner_never_registered", db.MarkFailed[0].Info.(internal.FailureInfoV2).Reason)
5353
}
5454
if len(kube.KillCalls) != 1 || kube.KillCalls[0] != w.PodName {
5555
t.Errorf("expected KillPod for %s, got %v", w.PodName, kube.KillCalls)
@@ -75,7 +75,7 @@ func TestPhase3_OnlineIdleRunnerPastTimeoutFails(t *testing.T) {
7575
if err := app.syncWorkersState(context.Background()); err != nil {
7676
t.Fatalf("syncWorkersState: %v", err)
7777
}
78-
if len(db.MarkFailed) != 1 || db.MarkFailed[0].Info.Reason != internal.ReasonRunnerIdle {
78+
if len(db.MarkFailed) != 1 || db.MarkFailed[0].Info.(internal.FailureInfoV2).Reason != internal.ReasonRunnerIdle {
7979
t.Fatalf("expected RunnerIdle failure, got %v", db.MarkFailed)
8080
}
8181
}
@@ -132,7 +132,7 @@ func TestPhase2_FailedPodMarksWorkerFailed(t *testing.T) {
132132
if err := app.syncWorkersState(context.Background()); err != nil {
133133
t.Fatal(err)
134134
}
135-
if len(db.MarkFailed) != 1 || db.MarkFailed[0].Info.Reason != internal.ReasonPodFailed {
135+
if len(db.MarkFailed) != 1 || db.MarkFailed[0].Info.(internal.FailureInfoV2).Reason != internal.ReasonPodFailed {
136136
t.Errorf("expected MarkWorkerFailed(pod_failed), got %v", db.MarkFailed)
137137
}
138138
}
@@ -152,7 +152,7 @@ func TestPhase3_PendingPastTimeoutFailsWithStuckPending(t *testing.T) {
152152
if err := app.syncWorkersState(context.Background()); err != nil {
153153
t.Fatal(err)
154154
}
155-
if len(db.MarkFailed) != 1 || db.MarkFailed[0].Info.Reason != internal.ReasonPodStuckPending {
155+
if len(db.MarkFailed) != 1 || db.MarkFailed[0].Info.(internal.FailureInfoV2).Reason != internal.ReasonPodStuckPending {
156156
t.Errorf("got %v", db.MarkFailed)
157157
}
158158
}

‎container/internal/constants.go‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ const (
3131
RunnerPendingTimeout = 600 * time.Second // runner registered with GH but never picks up a job
3232
PodPendingTimeout = 600 * time.Second // pod stuck Pending (no capacity, image pull, etc.)
3333
PodDeleteGrace = 6 * time.Hour // keep terminal pods around so operators can still kubectl logs them
34+
JobStuckQueuedMinAge = 10 * time.Minute // minimum job age before sync_jobs probes the parent run for a stuck-queued condition
3435

3536
PollInterval = 15 * time.Second
3637

‎container/internal/contract.go‎

Lines changed: 71 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -143,15 +143,65 @@ type InstallationEvent struct {
143143
Payload json.RawMessage `db:"payload" json:"-"`
144144
}
145145

146-
// FailureInfo is the v2 shape written into workers.failure_info / jobs.failure_info.
147-
type FailureInfo struct {
148-
Version int `json:"version"`
149-
Reason FailureReason `json:"reason"`
150-
PodReason string `json:"pod_reason,omitempty"`
151-
PodMessage string `json:"pod_message,omitempty"`
152-
Containers map[string]ContainerInfo `json:"containers,omitempty"`
153-
Events []EventInfo `json:"events,omitempty"`
154-
CollectError string `json:"collect_error,omitempty"`
146+
// FailureInfo is the sealed interface implemented by FailureInfoV1 and
147+
// FailureInfoV2. Two on-disk schemas coexist for workers.failure_info /
148+
// jobs.failure_info, and which one is correct depends on the failure
149+
// mode:
150+
//
151+
// - FailureInfoV1 (non-pod failures): {"version":1,"message":"..."} --
152+
// a free-form human message with no structured fields. Used by the
153+
// scheduler when the failure isn't a k8s pod outcome (installation
154+
// 404, job missing on GitHub, run completed with a queued job, ...).
155+
// - FailureInfoV2 (pod failures): {"version":2, "reason":<enum>,
156+
// "pod_reason":..., "pod_message":..., "containers":..., "events":...}
157+
// populated from CollectPodFailureInfo. Reason is a typed enum value
158+
// (ReasonPodFailed, ReasonPodStuckPending, ...).
159+
//
160+
// DB.Mark{Job,Worker}Failed accept the interface so a caller can pass
161+
// either variant; the on-disk shape is determined by the concrete type.
162+
// Renderers must look at "version" in the parsed JSON first and pick
163+
// the right branch.
164+
type FailureInfo interface {
165+
isFailureInfo()
166+
}
167+
168+
// FailureInfoV1 is the non-pod failure shape: a free-form message and
169+
// nothing else. Marshals as {"version":1, "message":"..."}.
170+
type FailureInfoV1 struct {
171+
Message string
172+
}
173+
174+
// FailureInfoV2 is the structured pod-failure shape produced by
175+
// CollectPodFailureInfo. Marshals as {"version":2, "reason":..., ...}.
176+
type FailureInfoV2 struct {
177+
Reason FailureReason
178+
PodReason string
179+
PodMessage string
180+
Containers map[string]ContainerInfo
181+
Events []EventInfo
182+
CollectError string
183+
}
184+
185+
func (FailureInfoV1) isFailureInfo() {}
186+
func (FailureInfoV2) isFailureInfo() {}
187+
188+
func (f FailureInfoV1) MarshalJSON() ([]byte, error) {
189+
return json.Marshal(struct {
190+
Version int `json:"version"`
191+
Message string `json:"message,omitempty"`
192+
}{1, f.Message})
193+
}
194+
195+
func (f FailureInfoV2) MarshalJSON() ([]byte, error) {
196+
return json.Marshal(struct {
197+
Version int `json:"version"`
198+
Reason FailureReason `json:"reason"`
199+
PodReason string `json:"pod_reason,omitempty"`
200+
PodMessage string `json:"pod_message,omitempty"`
201+
Containers map[string]ContainerInfo `json:"containers,omitempty"`
202+
Events []EventInfo `json:"events,omitempty"`
203+
CollectError string `json:"collect_error,omitempty"`
204+
}{2, f.Reason, f.PodReason, f.PodMessage, f.Containers, f.Events, f.CollectError})
155205
}
156206

157207
// ContainerInfo holds termination details + optional logs for one container.
@@ -255,6 +305,16 @@ type GHJob struct {
255305
Status string `json:"status"` // queued, in_progress, completed
256306
Conclusion *string `json:"conclusion"` // null, success, failure, cancelled, ...
257307
RunnerName string `json:"runner_name"`
308+
RunID int64 `json:"run_id"`
309+
}
310+
311+
// GHRun is the subset of the GitHub workflow-run response we use.
312+
// A run can finish (status=completed, conclusion!=null) while one of
313+
// its jobs stays stuck in status=queued forever — that's the case the
314+
// scheduler reconciles against.
315+
type GHRun struct {
316+
Status string `json:"status"`
317+
Conclusion *string `json:"conclusion"`
258318
}
259319

260320
// Installation is the parsed shape of GET /app/installations/{id}.
@@ -337,6 +397,7 @@ type GitHubClient interface {
337397
DeleteRunnerRepo(ctx context.Context, token, repoFullName string, runnerID int64) error
338398

339399
GetJobInfo(ctx context.Context, token, repoFullName string, jobID int64) (GHJob, error)
400+
GetRunInfo(ctx context.Context, token, repoFullName string, runID int64) (GHRun, error)
340401
}
341402

342403
// GitHubAPIError carries the HTTP status code so callers can distinguish 404.
@@ -364,7 +425,7 @@ type KubeClient interface {
364425
DeletePod(ctx context.Context, podName string) error
365426
KillPod(ctx context.Context, podName string) error
366427
AvailableSlots(ctx context.Context, pool string) (Capacity, error)
367-
CollectPodFailureInfo(ctx context.Context, pod Pod, reason FailureReason) FailureInfo
428+
CollectPodFailureInfo(ctx context.Context, pod Pod, reason FailureReason) FailureInfoV2
368429
}
369430

370431
// --- helpers shared between cmd/* ---

0 commit comments

Comments
 (0)