Skip to content

Commit 4fbd9d9

Browse files
balaji-gbalajinvda
andcommitted
fix(ci): address review findings in ci-health
Four issues from review, all real, all verified rather than taken on trust. fetchJobs requested per_page=100 but never asked for page 2, so any run with more than 100 jobs reported partial timings. Wide matrix runs are exactly the ones whose numbers matter, so this silently understated the busiest runs. It now pages until total_count is reached. Negative counts panicked instead of erroring: --runs, --history, --weeks and --prs all reach slice bounds, and `--runs=-1` died with "slice bounds out of range [:-1]". Reproduced before fixing. They are now rejected with a message naming the flag. --durations and --merge-times read no cache data but still fetched it, so they failed whenever cache access failed, for reports that never used the result. The cache calls are now made only for the modes that read them. diagnose divided job medians drawn from the --runs sample by a wall-clock median drawn from the full --history window. Mixing two windows can misstate each cause's share and reorder them. Diagnosis now runs on the sampled window; the full history still feeds the trend charts, where it belongs. Tests cover pagination across two pages, the single-page stop, and rejection of every negative count flag. 40 tests pass. Verified live afterwards: --runs=-1 now prints a clean error, --durations completes without touching the cache, and --why reports over the window it actually sampled. Co-authored-by: Balaji Ganesan <bganesan@nvidia.com>
1 parent 7f05f19 commit 4fbd9d9

4 files changed

Lines changed: 126 additions & 23 deletions

File tree

‎tools/ci-health/analysis_test.go‎

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,10 @@
1616
package main
1717

1818
import (
19+
"flag"
20+
"io"
1921
"math"
22+
"os"
2023
"strings"
2124
"testing"
2225
"time"
@@ -345,3 +348,22 @@ func TestDashboardHasNoExternalReferences(t *testing.T) {
345348
}
346349
}
347350
}
351+
352+
// --runs, --history, --weeks and --prs all reach slice bounds, where a negative
353+
// value panics instead of erroring.
354+
func TestNegativeCountFlagsAreRejected(t *testing.T) {
355+
for _, name := range []string{"runs", "history", "weeks", "prs"} {
356+
t.Run(name, func(t *testing.T) {
357+
flag.CommandLine = flag.NewFlagSet("ci-health", flag.ContinueOnError)
358+
flag.CommandLine.SetOutput(io.Discard)
359+
os.Args = []string{"ci-health", "--" + name + "=-1", "--repo", "o/r"}
360+
err := run()
361+
if err == nil {
362+
t.Fatalf("--%s=-1 was accepted; it panics when used as a slice bound", name)
363+
}
364+
if !strings.Contains(err.Error(), "--"+name+" must be at least 1") {
365+
t.Fatalf("error = %q, want it to name --%s", err, name)
366+
}
367+
})
368+
}
369+
}

‎tools/ci-health/github.go‎

Lines changed: 18 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -178,13 +178,25 @@ func fetchJobs(repo string, runs []Run, workers int) []Job {
178178
defer wg.Done()
179179
sem <- struct{}{}
180180
defer func() { <-sem }()
181-
var body struct {
182-
Jobs []Job `json:"jobs"`
183-
}
184-
if err := getJSON(fmt.Sprintf("actions/runs/%d/jobs?per_page=100", id), repo, &body); err != nil {
185-
return
181+
// Paginate. A run with more than 100 jobs would otherwise report
182+
// partial timings, and the wide matrix rows are exactly the runs
183+
// whose numbers matter most.
184+
var all []Job
185+
for page := 1; ; page++ {
186+
var body struct {
187+
Total int `json:"total_count"`
188+
Jobs []Job `json:"jobs"`
189+
}
190+
path := fmt.Sprintf("actions/runs/%d/jobs?per_page=100&page=%d", id, page)
191+
if err := getJSON(path, repo, &body); err != nil {
192+
return
193+
}
194+
all = append(all, body.Jobs...)
195+
if len(body.Jobs) < 100 || len(all) >= body.Total {
196+
break
197+
}
186198
}
187-
out[i] = result{jobs: body.Jobs}
199+
out[i] = result{jobs: all}
188200
}(i, run.ID)
189201
}
190202
wg.Wait()

‎tools/ci-health/github_test.go‎

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -212,3 +212,55 @@ func TestJobDecodesNullTimestamps(t *testing.T) {
212212
t.Fatal("created_at should have decoded")
213213
}
214214
}
215+
216+
// A run with more than 100 jobs used to report only the first page, and the
217+
// wide matrix runs are exactly the ones whose timings matter most.
218+
func TestFetchJobsPaginatesRunsWithManyJobs(t *testing.T) {
219+
var calls []string
220+
prev := fetch
221+
fetch = func(path, repo string) ([]byte, error) {
222+
calls = append(calls, path)
223+
page := 1
224+
if i := strings.Index(path, "&page="); i >= 0 {
225+
fmt.Sscanf(path[i+len("&page="):], "%d", &page)
226+
}
227+
n := 100
228+
if page == 2 {
229+
n = 40
230+
}
231+
if page > 2 {
232+
n = 0
233+
}
234+
jobs := make([]string, 0, n)
235+
for i := 0; i < n; i++ {
236+
jobs = append(jobs, `{"name":"bazel (x)","run_id":1,"conclusion":"success"}`)
237+
}
238+
return []byte(fmt.Sprintf(`{"total_count":140,"jobs":[%s]}`, strings.Join(jobs, ","))), nil
239+
}
240+
t.Cleanup(func() { fetch = prev })
241+
242+
got := fetchJobs("o/r", []Run{{ID: 1}}, 1)
243+
if len(got) != 140 {
244+
t.Fatalf("got %d jobs, want 140 (page 1 + page 2)", len(got))
245+
}
246+
if len(calls) != 2 {
247+
t.Fatalf("made %d requests, want 2; must stop once total_count is reached", len(calls))
248+
}
249+
}
250+
251+
func TestFetchJobsStopsOnSinglePage(t *testing.T) {
252+
var calls int
253+
prev := fetch
254+
fetch = func(path, repo string) ([]byte, error) {
255+
calls++
256+
return []byte(`{"total_count":1,"jobs":[{"name":"bazel (x)","run_id":1,"conclusion":"success"}]}`), nil
257+
}
258+
t.Cleanup(func() { fetch = prev })
259+
260+
if got := fetchJobs("o/r", []Run{{ID: 1}}, 1); len(got) != 1 {
261+
t.Fatalf("got %d jobs, want 1", len(got))
262+
}
263+
if calls != 1 {
264+
t.Fatalf("made %d requests, want 1; a short page means no more pages", calls)
265+
}
266+
}

‎tools/ci-health/main.go‎

Lines changed: 34 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,16 @@ func parseFlags() *options {
108108
func run() error {
109109
o := parseFlags()
110110

111+
// These reach slice bounds, so a negative value panics rather than erroring.
112+
for _, c := range []struct {
113+
name string
114+
v int
115+
}{{"runs", o.runs}, {"history", o.history}, {"weeks", o.weeks}, {"prs", o.prs}} {
116+
if c.v < 1 {
117+
return fmt.Errorf("--%s must be at least 1, got %d", c.name, c.v)
118+
}
119+
}
120+
111121
workflow := o.workflow
112122
if filepath.Ext(workflow) != ".yml" && filepath.Ext(workflow) != ".yaml" {
113123
workflow += ".yml"
@@ -132,33 +142,40 @@ func run() error {
132142
}
133143
truncated = len(runsList) >= o.history
134144
}
145+
var sampled []Run
135146
if needJobs {
136-
sample := runsList
137-
if len(sample) > o.runs {
138-
sample = sample[:o.runs]
147+
sampled = runsList
148+
if len(sampled) > o.runs {
149+
sampled = sampled[:o.runs]
139150
}
140-
jobs = fetchJobs(o.repo, sample, 8)
151+
jobs = fetchJobs(o.repo, sampled, 8)
141152
}
142153

143-
usage := struct {
144-
Size int64 `json:"active_caches_size_in_bytes"`
145-
Count int `json:"active_caches_count"`
146-
}{}
147-
if err := getJSON("actions/cache/usage", o.repo, &usage); err != nil {
148-
return err
149-
}
150-
caches, err := fetchCaches(o.repo)
151-
if err != nil {
152-
return err
154+
// --durations and --merge-times read no cache data, so they should neither
155+
// pay for these calls nor fail when cache access does.
156+
needCache := needJobs || (!o.why && !o.durations && !o.mergeTimes)
157+
var cache CacheState
158+
if needCache {
159+
usage := struct {
160+
Size int64 `json:"active_caches_size_in_bytes"`
161+
Count int `json:"active_caches_count"`
162+
}{}
163+
if err := getJSON("actions/cache/usage", o.repo, &usage); err != nil {
164+
return err
165+
}
166+
caches, err := fetchCaches(o.repo)
167+
if err != nil {
168+
return err
169+
}
170+
cache = summariseCaches(caches, usage.Size, usage.Count)
153171
}
154-
cache := summariseCaches(caches, usage.Size, usage.Count)
155172

156173
stats := analyseJobs(jobs)
157174
poles, poleRuns := longPoles(jobs)
158175
var causes []Cause
159176
var wall float64
160177
if needJobs {
161-
causes, wall = diagnose(runsList, stats, poles, poleRuns, cache)
178+
causes, wall = diagnose(sampled, stats, poles, poleRuns, cache)
162179
}
163180

164181
if o.dashboard != "" {
@@ -179,7 +196,7 @@ func run() error {
179196
}
180197

181198
if o.why || o.all {
182-
printWhy(causes, wall, runsList)
199+
printWhy(causes, wall, sampled)
183200
}
184201
if o.durations || o.all {
185202
printDurations(runsList, workflow, o.weeks, truncated)

0 commit comments

Comments
 (0)