Skip to content

Commit 36a455c

Browse files
authored
Add runner-fleet-metrics workflow (registered runner counts -> ClickHouse) (#8403)
Track how many self-hosted runners are registered with the `pytorch` org over time, bucketed by label (macOS + B200), for capacity trending in Grafana. Every 30 min the workflow counts `total`/`online`/`busy` per label, uploads gzipped JSONEachRow to `s3://gha-artifacts/runner_fleet_count/` (assuming the shared `arn:aws:iam::308535385114:role/arc` via OIDC — same role `_linux-build.yml` uses), and `clickhouse-replicator-s3` ingests it into `misc.runner_fleet_count`. No ClickHouse creds in CI. **Changes:** table schema, collector script, cron workflow (protected env), and `runner_fleet_count_adapter` + `SUPPORTED_PATHS`/`OBJECT_CONVERTER` entries in the replicator (needs redeploy). **Requires:** companion AWS wiring meta-pytorch/pytorch-gha-infra#1364; a `runner-fleet-metrics` Environment (restricted to `main`) with secret `RUNNER_LIST_GH_TOKEN` (org self-hosted-runners:read). 🤖 Generated with [Claude Code](https://claude.com/claude-code)
1 parent 3c603af commit 36a455c

4 files changed

Lines changed: 262 additions & 0 deletions

File tree

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
name: Runner fleet metrics
2+
3+
# Every 30 minutes, count the self-hosted runners registered with the pytorch
4+
# org (macOS + B200 by default), then upload the counts to S3. The
5+
# clickhouse-replicator-s3 lambda ingests them into misc.runner_fleet_count,
6+
# which is graphed in Grafana via the ClickHouse datasource.
7+
#
8+
# No ClickHouse credentials live here: the workflow only authenticates to AWS
9+
# (OIDC) to write the object; the replicator holds the CH creds.
10+
11+
on:
12+
schedule:
13+
- cron: "*/30 * * * *"
14+
workflow_dispatch:
15+
inputs:
16+
dry_run:
17+
description: "Collect and print counts without uploading to S3"
18+
type: boolean
19+
default: false
20+
21+
concurrency:
22+
group: runner-fleet-metrics
23+
cancel-in-progress: false
24+
25+
permissions:
26+
contents: read
27+
id-token: write # required for AWS OIDC role assumption
28+
29+
jobs:
30+
collect:
31+
runs-on: ubuntu-latest
32+
# Protected environment restricted to the main branch; holds the runner-list
33+
# token.
34+
environment: runner-fleet-metrics
35+
env:
36+
RUNNER_ORG: pytorch
37+
S3_BUCKET: gha-artifacts
38+
S3_PREFIX: runner_fleet_count
39+
AWS_REGION: us-east-1
40+
steps:
41+
- uses: actions/checkout@v4
42+
43+
- uses: actions/setup-python@v5
44+
with:
45+
python-version: "3.11"
46+
47+
- run: python -m pip install --no-cache-dir requests
48+
49+
- name: Collect runner counts
50+
env:
51+
# GitHub reserves the "GITHUB_" prefix for secret *names*, so the
52+
# runner-list token is stored as RUNNER_LIST_GH_TOKEN and exposed to
53+
# the script as GITHUB_TOKEN. Needs org self-hosted-runners:read.
54+
GITHUB_TOKEN: ${{ secrets.RUNNER_LIST_GH_TOKEN }}
55+
TRACKED_LABEL_PREFIXES: "macos,linux.dgx.b200"
56+
OUTPUT_FILE: runner_fleet_count.jsonl
57+
run: python tools/runner_metrics/collect_runner_fleet.py
58+
59+
- name: Configure AWS credentials
60+
if: ${{ !inputs.dry_run }}
61+
# Shared ARC role: trusts repo:pytorch/test-infra:* and can write
62+
# gha-artifacts (same role _linux-build.yml uses for artifact upload).
63+
uses: aws-actions/configure-aws-credentials@v4
64+
with:
65+
role-to-assume: arn:aws:iam::308535385114:role/arc
66+
aws-region: ${{ env.AWS_REGION }}
67+
68+
- name: Upload to S3 for ClickHouse ingestion
69+
if: ${{ !inputs.dry_run }}
70+
run: |
71+
set -euo pipefail
72+
ts="$(date -u +%Y%m%dT%H%M%SZ)"
73+
gzip -c runner_fleet_count.jsonl > runner_fleet_count.json.gz
74+
# NB: do NOT set --content-encoding gzip. ClickHouse's s3() reader
75+
# decompresses via the explicit compression arg in the replicator; a
76+
# gzip Content-Encoding header makes it mis-read the object and ingest
77+
# 0 rows silently (no error). Store plain gzip bytes instead.
78+
aws s3 cp runner_fleet_count.json.gz \
79+
"s3://${S3_BUCKET}/${S3_PREFIX}/${RUNNER_ORG}/${ts}.json.gz"

aws/lambda/clickhouse-replicator-s3/lambda_function.py

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -709,6 +709,24 @@ def autorevert_advisor_verdicts_adapter(table, bucket, key):
709709
general_adapter(table, bucket, key, schema, ["none"], "JSONEachRow")
710710

711711

712+
def runner_fleet_count_adapter(table, bucket, key):
713+
# Column order must match clickhouse_db_schema/misc.runner_fleet_count/
714+
# schema.sql (minus `_meta`, which general_adapter appends via `SELECT *,
715+
# (bucket, key)`). JSONEachRow maps by field name.
716+
# NB: no timezone literal here -- general_adapter wraps this schema in single
717+
# quotes, so an inner 'UTC' would break the s3() SQL. Timestamps are UTC
718+
# wall-clock strings; the table column carries the UTC tz.
719+
schema = """
720+
`time_stamp` DateTime64(0),
721+
`org` String,
722+
`label` String,
723+
`total_count` UInt32,
724+
`online_count` UInt32,
725+
`busy_count` UInt32
726+
"""
727+
general_adapter(table, bucket, key, schema, ["gzip", "none"], "JSONEachRow")
728+
729+
712730
SUPPORTED_PATHS = {
713731
"merges": "default.merges",
714732
"queue_times_historical": "default.queue_times_historical",
@@ -734,6 +752,7 @@ def autorevert_advisor_verdicts_adapter(table, bucket, key):
734752
# fbossci-cloudwatch-metrics bucket
735753
"ghci-related": "infra_metrics.cloudwatch_metrics",
736754
"test_jsons_while_running": "tests.all_test_runs",
755+
"runner_fleet_count": "misc.runner_fleet_count",
737756
}
738757

739758
OBJECT_CONVERTER = {
@@ -760,6 +779,7 @@ def autorevert_advisor_verdicts_adapter(table, bucket, key):
760779
"misc.claude_code_usage": claude_code_usage_adapter,
761780
"misc.autorevert_advisor_verdicts": autorevert_advisor_verdicts_adapter,
762781
"infra_metrics.cloudwatch_metrics": cloudwatch_metrics_adapter,
782+
"misc.runner_fleet_count": runner_fleet_count_adapter,
763783
}
764784

765785

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
-- Point-in-time counts of self-hosted runners registered with a GitHub org,
2+
-- bucketed by runner label. One row per (sample, label). Populated by the S3
3+
-- replicator (clickhouse-replicator-s3): the runner-fleet-metrics workflow
4+
-- uploads gzipped JSONEachRow to s3://gha-artifacts/runner_fleet_count/..., the
5+
-- replicator does `INSERT INTO misc.runner_fleet_count SELECT *, (bucket, key)
6+
-- AS _meta FROM s3(...)`. Graphed in Grafana via the ClickHouse datasource.
7+
--
8+
-- IMPORTANT: the general_adapter insert is positional (`SELECT *, _meta`), so
9+
-- the column order here MUST match the adapter's schema string exactly, with
10+
-- `_meta` last. Do not add DEFAULT/normal columns in between -- they would
11+
-- shift the positional mapping and break ingestion.
12+
CREATE TABLE misc.runner_fleet_count
13+
(
14+
`time_stamp` DateTime64(0, 'UTC'), -- when the fleet was sampled (UTC)
15+
`org` String, -- e.g. pytorch
16+
`label` String, -- e.g. linux.dgx.b200, macos-m1-stable
17+
`total_count` UInt32, -- runners carrying this label
18+
`online_count` UInt32, -- subset with status = online
19+
`busy_count` UInt32, -- subset currently running a job
20+
`_meta` Tuple(bucket String, key String) -- S3 provenance added by replicator
21+
)
22+
ENGINE = SharedMergeTree('/clickhouse/tables/{uuid}/{shard}', '{replica}')
23+
PARTITION BY toYYYYMM(time_stamp)
24+
ORDER BY (label, org, time_stamp)
25+
TTL toDate(time_stamp) + toIntervalYear(2)
26+
SETTINGS index_granularity = 8192
Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,137 @@
1+
#!/usr/bin/env python3
2+
"""Sample how many self-hosted runners are registered with a GitHub org,
3+
bucketed by label, and write the counts as JSONEachRow for S3 -> ClickHouse
4+
ingestion (misc.runner_fleet_count via clickhouse-replicator-s3).
5+
6+
A runner is counted under every tracked label it carries, so a host with both
7+
``macos-m1-14`` and ``macos-m1-stable`` contributes to both series -- these are
8+
distinct scheduling labels, which is what we want to trend.
9+
10+
The output is newline-delimited JSON (one object per line); each object's keys
11+
match the ClickHouse column names exactly (JSONEachRow maps by name). The
12+
workflow gzips this file and uploads it under the runner_fleet_count/ prefix.
13+
14+
Environment:
15+
GITHUB_TOKEN token with org "self-hosted runners: read"
16+
(fine-grained) or classic ``admin:org`` read scope.
17+
RUNNER_ORG org to query (default: pytorch).
18+
TRACKED_LABEL_PREFIXES comma-separated label prefixes to record
19+
(default: "macos,linux.dgx.b200").
20+
OUTPUT_FILE path to write JSONEachRow to (default:
21+
runner_fleet_count.jsonl).
22+
"""
23+
24+
from __future__ import annotations
25+
26+
import json
27+
import os
28+
import sys
29+
from collections import defaultdict
30+
from datetime import datetime, timezone
31+
32+
import requests
33+
34+
35+
GITHUB_API = "https://api.github.com"
36+
37+
38+
def list_org_runners(org: str, token: str) -> list[dict]:
39+
"""Return every self-hosted runner registered with ``org`` (all pages)."""
40+
session = requests.Session()
41+
session.headers.update(
42+
{
43+
"Authorization": f"Bearer {token}",
44+
"Accept": "application/vnd.github+json",
45+
"X-GitHub-Api-Version": "2022-11-28",
46+
}
47+
)
48+
runners: list[dict] = []
49+
page = 1
50+
while True:
51+
resp = session.get(
52+
f"{GITHUB_API}/orgs/{org}/actions/runners",
53+
params={"per_page": 100, "page": page},
54+
timeout=30,
55+
)
56+
resp.raise_for_status()
57+
payload = resp.json()
58+
batch = payload.get("runners", [])
59+
runners.extend(batch)
60+
if not batch or len(runners) >= payload.get("total_count", 0):
61+
break
62+
page += 1
63+
return runners
64+
65+
66+
def bucket_by_label(
67+
runners: list[dict], prefixes: list[str]
68+
) -> dict[str, dict[str, int]]:
69+
counts: dict[str, dict[str, int]] = defaultdict(
70+
lambda: {"total": 0, "online": 0, "busy": 0}
71+
)
72+
for runner in runners:
73+
is_online = str(runner.get("status", "")).lower() == "online"
74+
is_busy = bool(runner.get("busy"))
75+
for name in {label.get("name", "") for label in runner.get("labels", [])}:
76+
if not any(name.startswith(p) for p in prefixes):
77+
continue
78+
counts[name]["total"] += 1
79+
counts[name]["online"] += int(is_online)
80+
counts[name]["busy"] += int(is_busy)
81+
return counts
82+
83+
84+
def main() -> int:
85+
token = os.environ.get("GITHUB_TOKEN")
86+
if not token:
87+
print("GITHUB_TOKEN is required", file=sys.stderr)
88+
return 1
89+
org = os.environ.get("RUNNER_ORG", "pytorch")
90+
prefixes = [
91+
p.strip()
92+
for p in os.environ.get("TRACKED_LABEL_PREFIXES", "macos,linux.dgx.b200").split(
93+
","
94+
)
95+
if p.strip()
96+
]
97+
output_file = os.environ.get("OUTPUT_FILE", "runner_fleet_count.jsonl")
98+
99+
runners = list_org_runners(org, token)
100+
counts = bucket_by_label(runners, prefixes)
101+
102+
# ClickHouse DateTime64(0, 'UTC') parses this "YYYY-MM-DD HH:MM:SS" form.
103+
sample_ts = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S")
104+
105+
lines = []
106+
for label, c in sorted(counts.items()):
107+
row = {
108+
"time_stamp": sample_ts,
109+
"org": org,
110+
"label": label,
111+
"total_count": c["total"],
112+
"online_count": c["online"],
113+
"busy_count": c["busy"],
114+
}
115+
lines.append(json.dumps(row))
116+
print(
117+
f"{org} {label}: total={c['total']} online={c['online']} busy={c['busy']}"
118+
)
119+
120+
if not lines:
121+
# An empty match set is a real (all-zero) observation, but we cannot
122+
# synthesize per-label zero rows without knowing every expected label.
123+
# Fail loudly rather than silently uploading an empty object.
124+
print(
125+
f"ERROR: no runners matched prefixes {prefixes} in org {org}.",
126+
file=sys.stderr,
127+
)
128+
return 2
129+
130+
with open(output_file, "w") as f:
131+
f.write("\n".join(lines) + "\n")
132+
print(f"Wrote {len(lines)} rows to {output_file}")
133+
return 0
134+
135+
136+
if __name__ == "__main__":
137+
raise SystemExit(main())

0 commit comments

Comments
 (0)