-
Notifications
You must be signed in to change notification settings - Fork 233
docs: add /ci-queue-times command for per-cluster CI queue latency / 新增 /ci-queue-times 命令:按集群查看 CI 排队延迟 #2270
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
JordanNanos
wants to merge
4
commits into
main
Choose a base branch
from
claude/ci-queue-times-command
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
Show all changes
4 commits
Select commit
Hold shift + click to select a range
784fa10
docs(commands): add /ci-queue-times command for per-cluster CI queue …
JordanNanos 93a63c5
docs(commands): rework /ci-queue-times to a live snapshot of active +…
JordanNanos 63ed2ac
docs(commands): add runners online/busy columns to /ci-queue-times su…
JordanNanos a355cdd
docs(commands): drive pool membership from configs/runners.yaml, pagi…
JordanNanos File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,132 @@ | ||
| --- | ||
| description: Show CI cluster pressure right now — per-pool runners online/busy vs jobs active/queued, plus the live queue with wait-so-far | ||
| argument-hint: [pool-filter-regex] # optional, e.g. "h200" or "b200|mi355x" to restrict output | ||
| --- | ||
|
|
||
| GitHub has no queue-metrics API for self-hosted runners, so this derives cluster | ||
| pressure from the Actions API: a job's **wait so far = now − `created_at`** (queued | ||
| jobs) and **runtime so far = now − `started_at`** (active jobs). Pool membership | ||
| comes from the repo's own `configs/runners.yaml` (the authoritative pool → | ||
| runner-name mapping), so run this **from the repo root**. | ||
|
|
||
| `$ARGUMENTS` (optional) is a regex restricting which pools are shown in Steps 2–4 | ||
| (e.g. `h200`). Run all steps and report the tables as-is. | ||
|
|
||
| **Key facts for interpreting the numbers:** | ||
|
|
||
| - A self-hosted runner agent executes **exactly one job at a time**, so per pool | ||
| `jobs_active` should always be ≤ `runners_busy`. Parallelism comes from multiple | ||
| registered agents per pool (`h100-dgxc-slurm_00` … `_19`). | ||
| - Pools overlap **by design** in `configs/runners.yaml` (e.g. `h100-multinode` ⊂ | ||
| `h100`, and the same `b200-dgxc_*` machines serve `b200`, `b200-dsv4`, and | ||
| `b200-dgxc` jobs), so one physical runner can count toward several pool rows. | ||
| - A multi-node SLURM job still occupies exactly one agent (the orchestrator) — the | ||
| nodes it allocates inside SLURM are invisible here. | ||
|
|
||
| ## Step 1 — Take one snapshot of jobs (active + queued) and runners | ||
|
|
||
| A run can be `in_progress` while its matrix jobs are still `queued` waiting for | ||
| runners (the sweep fan-out), so scan both statuses. Every list call uses | ||
| `--paginate` — without it the runs lists are silently capped at the first page. | ||
|
|
||
| ```bash | ||
| SNAP=$(mktemp /tmp/ci_snapshot.XXXXXX.ndjson) | ||
| RUNNERS=$(mktemp /tmp/ci_runners.XXXXXX.ndjson) | ||
| POOLS=$(mktemp /tmp/ci_pools.XXXXXX.tsv) | ||
|
|
||
| # Pool → runner-name membership, from the repo's own config (bare pool keys and | ||
| # cluster:* keys are normalized to the same bucket: cluster:b200-dgxc → b200-dgxc). | ||
| awk '/^ [A-Za-z0-9:-]+:$/ { k=$1; sub(":$","",k); sub("^cluster:","",k); pool=k; next } | ||
| /^ - / { print pool "\t" $2 }' configs/runners.yaml > "$POOLS" | ||
|
|
||
| # Jobs, bucketed by the pool label they requested (demand side). | ||
| { gh api "repos/SemiAnalysisAI/InferenceX/actions/runs?status=queued&per_page=100" --paginate --jq '.workflow_runs[].id' | ||
| gh api "repos/SemiAnalysisAI/InferenceX/actions/runs?status=in_progress&per_page=100" --paginate --jq '.workflow_runs[].id' | ||
| } | sort -u | while read -r RUN; do | ||
| gh api "repos/SemiAnalysisAI/InferenceX/actions/runs/$RUN/jobs?per_page=100" --paginate \ | ||
| --jq '.jobs[] | select(.status=="in_progress" or .status=="queued") | ||
| | {c: ([.labels[] | select(test("^(cluster:)?(b200|b300|h100|h200|gb200|gb300|mi300x|mi325x|mi355x)")) | sub("^cluster:";"") | sub("_\\d+$";"")] | first // "other"), | ||
| status, name, created_at, started_at, run_id} | ||
| | select(.c != "other")' | ||
| done > "$SNAP" | ||
|
|
||
| # Runners by name (capacity side) — names match configs/runners.yaml entries. | ||
| gh api "repos/SemiAnalysisAI/InferenceX/actions/runners?per_page=100" --paginate \ | ||
| --jq '.runners[] | {name, status, busy}' > "$RUNNERS" | ||
| ``` | ||
|
|
||
| ## Step 2 — Per-pool summary: runners online/busy vs jobs active/queued | ||
|
|
||
| Joins demand (job labels) against capacity (`configs/runners.yaml` membership): | ||
|
|
||
| ```bash | ||
| jq -n -r --slurpfile jobs "$SNAP" --slurpfile runners "$RUNNERS" --rawfile pools "$POOLS" ' | ||
| ($pools | split("\n") | map(select(contains("\t")) | split("\t")) | ||
| | group_by(.[1]) | map({key: .[0][1], value: [.[].[0]]}) | from_entries) as $name2pools | ||
| | ($jobs | group_by(.c) | map({key: .[0].c, value: { | ||
| active: ([.[] | select(.status=="in_progress")] | length), | ||
| queued: ([.[] | select(.status=="queued")] | length)}}) | from_entries) as $j | ||
| | ([ $runners[] as $r | ($name2pools[$r.name] // [])[] as $p | {c: $p, status: $r.status, busy: $r.busy} ] | ||
| | group_by(.c) | map({key: .[0].c, value: { | ||
| online: ([.[] | select(.status=="online")] | length), | ||
| busy: ([.[] | select(.busy==true)] | length)}}) | from_entries) as $r | ||
| | [([$j, $r] | map(keys) | add | unique | .[]) as $c | ||
| | {c: $c, on: ($r[$c].online // 0), busy: ($r[$c].busy // 0), | ||
| active: ($j[$c].active // 0), queued: ($j[$c].queued // 0)}] | ||
| | map(select(.on + .busy + .active + .queued > 0)) | ||
| | sort_by(-.queued, -.active) | ||
| | .[] | [.c, (.on|tostring), (.busy|tostring), (.active|tostring), (.queued|tostring)] | @tsv' \ | ||
| | (printf "pool\trunners_online\trunners_busy\tjobs_active\tjobs_queued\n"; cat) | column -t -s$'\t' | ||
| ``` | ||
|
|
||
| How to read it (one job per runner agent, so these patterns are diagnostic): | ||
|
|
||
| | pattern | meaning | | ||
| |---|---| | ||
| | `busy == active`, `queued == 0` | healthy, pool has spare capacity if `busy < online` | | ||
| | `busy == online`, `queued > 0` | pool saturated — jobs waiting for a free runner (normal queue) | | ||
| | `online == 0`, `queued > 0` | **pool is down** — no live runners; investigate the fleet, not the workflow | | ||
| | `busy > active` | the pool's runners are busy serving **another pool's** jobs (shared membership in `configs/runners.yaml`, e.g. `b200` vs `b200-dgxc`), stuck "busy" after a cancellation, or busy with another repo's jobs (runners may be org-scoped) | | ||
| | `active > busy` | impossible in steady state — treat as snapshot skew between the API calls | | ||
| | pool absent entirely | no online runners and no jobs — either idle-and-offline, or (if jobs *do* target it) the pool is not defined in `configs/runners.yaml` | | ||
|
|
||
| ## Step 3 — Active jobs (what is running, and for how long) | ||
|
|
||
| ```bash | ||
| jq -s -r '[.[] | select(.status=="in_progress")] | sort_by(.c, .started_at) | .[] | ||
| | [.c, (((now - (.started_at|fromdateiso8601))/60 | floor | tostring) + " min"), (.name[0:58]), (.run_id|tostring)] | @tsv' "$SNAP" \ | ||
| | column -t -s$'\t' | ||
| ``` | ||
|
|
||
| ## Step 4 — Queued jobs (what is waiting, and for how long) | ||
|
|
||
| ```bash | ||
| jq -s -r '[.[] | select(.status=="queued")] | sort_by(.c, .created_at) | .[] | ||
| | [.c, (((now - (.created_at|fromdateiso8601))/60 | floor | tostring) + " min"), (.name[0:58]), (.run_id|tostring)] | @tsv' "$SNAP" \ | ||
| | column -t -s$'\t' | ||
| ``` | ||
|
|
||
| To apply `$ARGUMENTS`, pipe Steps 2–4 through `grep -E "<pattern>"` (keep the | ||
| header line of Step 2). | ||
|
|
||
| ## Caveats | ||
|
|
||
| - **Demand vs capacity bucketing:** jobs are bucketed by the label they *request* | ||
| (single-node jobs use pool labels like `b200`, multi-node jobs use `cluster:*` | ||
| labels — the `sub()` normalization merges them). Runners are bucketed strictly by | ||
| `configs/runners.yaml` membership, so bogus rows from per-runner name labels | ||
| (e.g. `h100-dgxc-slurm`) cannot appear. | ||
| - **`gh api --jq` does not accept jq `--arg`** — extra arguments are parsed as API | ||
| parameters. Interpolate values into the `--jq` string via the shell instead. | ||
| - **Re-run artifacts:** re-run jobs can report `started_at` from an earlier | ||
| attempt, inflating runtime in Step 3. Cross-check an outlier against the job's | ||
| page before believing it. | ||
| - **SLURM second queue:** for SLURM-backed pools (`*-dgxc`, `*-nv`), once the | ||
| GitHub job starts there is a second queue inside SLURM that GitHub-side numbers | ||
| do not capture. Check it on the cluster with `squeue -u <runner-user>` (see | ||
| `.claude/skills/debug-runs/SKILL.md`). | ||
| - Queue time measured this way excludes time spent behind the sweep canary gate: | ||
| matrix jobs are only *created* after the gate passes, so their `created_at` marks | ||
| genuine runner-wait start. | ||
| - Snapshot skew: jobs and runners are fetched a few seconds apart; a job that | ||
| starts/ends in between can briefly violate `active <= busy`. | ||
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🟡 Step 3's runner-availability query fans out over ALL matching normalized labels (
unique | .[]) instead of picking one canonical pool label per runner, so a single runner with multiple qualifying labels (pool,cluster:*alias, multi-node subset, and its own per-runner name label) gets counted in 3–4 different pool rows in the output table. This inflates total/online/busy counts and fabricates bogus rows (e.g.h100-dgxc-slurm) that don't correspond to any real pool; fix by picking a single canonical label per runner (e.g.first // empty, as Step 2 already does).Extended reasoning...
The bug: Step 3's jq pipeline computes, per runner, the set of all normalized labels that match the broad prefix regex
^(cluster:)?(b200|b300|h100|h200|gb200|gb300|mi300x|mi325x|mi355x), then doesunique | .[]— emitting one{c, status, busy}record for every surviving label rather than choosing a single canonical pool label. The subsequentgroup_by(.c)then treats each of those records as an independent runner-in-a-pool, so one physical runner ends up counted in several different "pool" rows of the final table.Why this actually happens:
configs/runners.yamlshows real runners are registered with multiple simultaneous labels — a pool label (h100), a multi-node subset label for some runners (h100-multinode), a cluster alias (cluster:h100-dgxc), and the runner's own unique name label (h100-dgxc-slurm_16). All of these pass the regex above because it's a substring/prefix test, not a full match. After the twosub()normalizations (sub("^cluster:";""),sub("_\\d+$";"")), these collapse to four distinct strings for the same runner:h100,h100-dgxc,h100-dgxc-slurm, andh100-multinode. Two of those (h100-dgxcandh100-dgxc-slurm) are not real pool names at all — they're artifacts of stripping thecluster:prefix and the runner's numeric suffix.Step-by-step proof (using runner
h100-dgxc-slurm_16, which isonlineand carries labelsh100,h100-multinode,cluster:h100-dgxc,h100-dgxc-slurm_16perconfigs/runners.yaml):.labels[].nameyields:h100,h100-multinode,cluster:h100-dgxc,h100-dgxc-slurm_16(plus default labels likeself-hostedwhich fail the regex and are dropped).select(test(...))keeps all four SKU-prefixed labels.sub("^cluster:";"")turnscluster:h100-dgxcintoh100-dgxc.sub("_\\d+$";"")turnsh100-dgxc-slurm_16intoh100-dgxc-slurm.uniquenow holds 4 distinct strings:["h100", "h100-dgxc", "h100-dgxc-slurm", "h100-multinode"]..[] | {c: ., status: $r.status, busy: $r.busy}emits 4 separate records for this single physical runner.jq -s | group_by(.c), this one runner contributes+1to thetotal/onlinecount in FOUR rows of the output table:h100,h100-dgxc,h100-dgxc-slurm, andh100-multinode— two of which (h100-dgxc,h100-dgxc-slurm) are not real pools inconfigs/runners.yamlat all.I reproduced this exact behavior by piping the runner's label set through the Step 3 jq expression, confirming it yields 4 rows for one runner.
Contrast with the rest of the file: Step 1 (line 24) explicitly filters out per-runner name labels via
select(test("...|_\\d+$") | not). Step 2 (line 42) picks a single canonical label per job via| first // empty. Step 3 is the outlier — it neither filters out the per-runner name label nor reduces to one label, so it's the only one of the three queries that multi-counts. This also directly contradicts the file's own Caveats section (lines 71-75), which states thesub()calls exist to "normalize" label shapes, not to multiply-count a runner across several pool buckets.Impact: Step 3 is described as "the why behind queue times" — its whole purpose is giving an operator accurate total/online/busy counts per pool to diagnose queue pressure. As written, it will, on every invocation, inflate counts for any runner carrying more than one qualifying label (which per
configs/runners.yamlis most runners — anything with acluster:*alias or multi-node subset label) and fabricate nonexistent pool rows likeh100-dgxc-slurm. That's actively misleading for its stated diagnostic purpose.Fix: Change
unique | .[]to pick one canonical label per runner, e.g. mirror Step 2's| first // empty, or add the same per-runner-name-label exclusion Step 1 uses (select(test("_\\d+$") | not)) before takingfirst.There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Fixed in a355cdd — runner bucketing now comes from configs/runners.yaml pool membership (matched by runner name) instead of label fan-out: one runner counts once per configured pool, bogus rows like h100-dgxc-slurm are gone, and all configured pools now appear. / 已在 a355cdd 修复:runner 侧的池归属改为以 configs/runners.yaml 的成员配置为准(按 runner 名称匹配),不再按标签扇出 —— 同一 runner 只按配置计入对应池,h100-dgxc-slurm 之类的伪池行已消除,所有已配置的池都会显示。