[CI] Replace the Lark queue-digest card with a daily queue-timeline chart (#38380)

This commit is contained in:
Liangsheng Yin
2026-09-07 18:28:34 -07:00
committed by GitHub
parent c72cae201e
commit b8a81f055d
5 changed files with 258 additions and 188 deletions
+3 -26
View File
@@ -9,14 +9,13 @@ on:
branches: [main]
schedule:
- cron: '*/15 * * * *' # runner-health
- cron: '30 */8 * * *' # queue-digest
workflow_dispatch:
inputs:
task:
description: 'Which report to run'
required: true
type: choice
options: [ci-status, runner-health, queue-digest]
options: [ci-status, runner-health]
run_id:
description: 'Workflow run id (ci-status only)'
required: false
@@ -94,33 +93,11 @@ jobs:
LARK_WEBHOOK: ${{ secrets.LARK_WEBHOOK }}
run: |
python scripts/ci_monitor/lark_notify.py $DRY_RUN_FLAG runner-health \
--state-file runner-health-state.json
--state-file runner-health-state.json \
--remind-hours 6
- name: Save runner-health state
if: ${{ !inputs.dry_run }}
uses: actions/cache/save@v4
with:
path: runner-health-state.json
key: lark-runner-health-${{ github.run_id }}
queue-digest:
if: >-
github.repository == 'sgl-project/sglang' && (
(github.event_name == 'schedule' && github.event.schedule == '30 */8 * * *') ||
(github.event_name == 'workflow_dispatch' && inputs.task == 'queue-digest')
)
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- uses: actions/checkout@v4
with:
sparse-checkout: scripts/ci_monitor
- uses: actions/setup-python@v5
with:
python-version: '3.12'
- name: Post queue digest
env:
# PAT: an 8h window is ~400-500 API calls, too close to GITHUB_TOKEN's 1000/h
GITHUB_TOKEN: ${{ secrets.GH_PAT_FOR_NIGHTLY_CI_DATA }}
LARK_WEBHOOK: ${{ secrets.LARK_WEBHOOK }}
run: |
python scripts/ci_monitor/lark_notify.py $DRY_RUN_FLAG queue-digest --hours 8 --only-if-slow
+15 -1
View File
@@ -68,4 +68,18 @@ jobs:
python scripts/ci/utils/runner_utilization_report.py \
--repo ${{ github.repository }} \
--hours ${{ (github.event_name == 'pull_request' && '0.34') || inputs.hours || '24' }} \
${{ inputs.filter && format('--filter {0}', inputs.filter) || '' }}
${{ inputs.filter && format('--filter {0}', inputs.filter) || '' }} \
--queue-series-out queue-series.json
# The card rides on the scan above rather than running its own: a 24h
# window is ~5000 API calls, the whole hourly budget of ci-lark-notify's PAT.
- name: Post queue timeline card
if: >-
github.repository == 'sgl-project/sglang' &&
github.event_name != 'pull_request'
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
LARK_WEBHOOK: ${{ secrets.LARK_WEBHOOK }}
run: |
python scripts/ci_monitor/lark_notify.py queue-timeline \
--series-file queue-series.json
@@ -463,6 +463,85 @@ _NON_GPU_WORKFLOW_HINTS = (
)
def percentile(values: list[float], p: float) -> float | None:
if not values:
return None
ordered = sorted(values)
return ordered[int(round((len(ordered) - 1) * p))]
def queue_timeline(
jobs: list[dict],
window_start: datetime,
window_end: datetime,
bucket_minutes: int = 60,
) -> list[dict]:
"""Per-bucket queue backlog and wait p90, for the Lark timeline card.
`backlog` is the sweep-line counter calculate_concurrency_metrics reduces
to a single peak, kept per bucket; `p90_wait_min` covers only the jobs
picked up inside the bucket, so a bucket can show a deep backlog and no
p90 at all.
"""
# Buckets start on the hour so the x-axis labels mean what they say. The
# partly-covered hour window_start lands in is dropped rather than reported
# short; on a 24h window it would also repeat the last bucket's label.
bucket = timedelta(minutes=bucket_minutes)
origin = window_start.replace(minute=0, second=0, microsecond=0)
if origin < window_start:
origin += bucket
starts = []
t = origin
while t < window_end:
starts.append(t)
t += bucket
if not starts:
return []
peaks = [0] * len(starts)
def bucket_index(when: datetime) -> int:
"""Bucket `when` falls in. Callers drop anything before origin."""
offset = (when - origin).total_seconds() // (bucket_minutes * 60)
return min(len(starts) - 1, int(offset))
events = []
for job in jobs:
created_at, queue_end = job.get("created_at"), job.get("queue_end")
if not created_at or not queue_end or created_at >= queue_end:
continue
if queue_end < window_start or created_at > window_end:
continue
events.append((max(created_at, window_start), 1))
events.append((min(queue_end, window_end), -1))
# Level between two events applies to every bucket the gap spans, so a
# backlog that persists without any event still shows in later buckets.
events.sort(key=lambda e: (e[0], e[1] == 1))
current, prev_time = 0, window_start
for event_time, delta in events + [(window_end, 0)]:
if current > 0 and event_time > prev_time and event_time >= origin:
first = bucket_index(max(prev_time, origin))
for i in range(first, bucket_index(event_time) + 1):
peaks[i] = max(peaks[i], current)
current += delta
prev_time = event_time
waits: list[list[float]] = [[] for _ in starts]
for job in jobs:
start = job.get("start")
if start is None or start < origin or start > window_end:
continue
waits[bucket_index(start)].append(job["queue_time"])
return [
{
"start": starts[i].strftime("%Y-%m-%dT%H:%M:%SZ"),
"backlog": peaks[i],
"started": len(waits[i]),
"p90_wait_min": (percentile(waits[i], 0.9) or 0.0) / 60,
}
for i in range(len(starts))
]
def _likely_no_gpu_jobs(workflow_name: str) -> bool:
"""Heuristic: skip per-run job-fetch for workflows that don't dispatch
to self-hosted GPU runners. The GH API rate limit is the bottleneck on
@@ -781,6 +860,7 @@ def calculate_utilization(
"saturation_pct": conc["saturation_pct"],
"peak_queue": conc["peak_queue"],
"status_counts": status_counts,
"queue_timeline": queue_timeline(jobs, window_start, window_end),
}
)
@@ -957,6 +1037,11 @@ def main():
"--filter", type=str, help="Filter runner labels (e.g., '5090', 'h200')"
)
parser.add_argument("--output", type=str, help="Output file (default: stdout)")
parser.add_argument(
"--queue-series-out",
type=str,
help="Write the per-label hourly queue series as JSON (for the Lark card)",
)
args = parser.parse_args()
results, fetch_failure_pct, longest_waits, coverage_hours = calculate_utilization(
@@ -970,6 +1055,17 @@ def main():
coverage_hours=coverage_hours,
)
if args.queue_series_out:
# Every bucket carries its own start, so the window needs no separate
# field -- and cannot drift from the one calculate_utilization used.
series = {
"bucket_minutes": 60,
"labels": {r["label"]: r["queue_timeline"] for r in results},
}
with open(args.queue_series_out, "w") as f:
json.dump(series, f)
print(f"Queue series written to {args.queue_series_out}")
if args.output:
with open(args.output, "w") as f:
f.write(report)
+2 -2
View File
@@ -7,8 +7,8 @@ Scripts used by [.github/workflows/ci-failure-monitor.yml](../../.github/workflo
1. **Failures Analyzer** (`ci_failures_analysis.py`): Tracks consecutive failures, identifies flaky jobs, and monitors runner health across PR Test / Nightly workflows (Nvidia, AMD, Intel, XPU, NPU).
2. **Lark Notifier** (`lark_notify.py`): Posts CUDA CI health cards to a Lark group through an incoming webhook (`LARK_WEBHOOK` secret). Stdlib only. Three subcommands:
- `ci-status --run-id N`: one card per finished scheduled run of the Nvidia nightly / weekly / scheduled pr-test. The first attempt lists its failed jobs; a rerun (attempt N > 1) is compared with attempt N-1 of the same run (fixed by rerun / still failing). Triggered by `workflow_run`.
- `runner-health --state-file F`: per-pool online / offline counts for the primary CUDA labels (`N-gpu-h100|h200|h20|5090|b200|b300|gb200|gb300|a10`). Posts only on degraded / recovered transitions plus an hourly reminder while degraded; state is carried between runs via `actions/cache`. Needs an admin PAT to list runners.
- `queue-digest --hours 8 --only-if-slow`: per-pool queue time p50 / p90 / max over the window plus currently queued jobs, posted only when some pool's p90 exceeds `--slow-minutes` (default 30). Links to the latest Runner Utilization Report run.
- `runner-health --state-file F`: per-pool online / offline counts for the primary CUDA labels (`N-gpu-h100|h200|h20|5090|b200|b300|gb200|gb300`). Posts only on degraded / recovered transitions plus a reminder every `--remind-hours` (6h in CI) while degraded; state is carried between runs via `actions/cache`. Needs an admin PAT to list runners.
- `queue-timeline --series-file F`: one card a day charting the hourly queue backlog and wait p90 across the CUDA pools, naming the pool behind each peak. The series comes from `runner_utilization_report.py --queue-series-out`, so this runs inside runner-utilization.yml rather than here -- a 24h scan of its own would cost a PAT's whole hourly API budget.
All subcommands accept `--dry-run` to print the card JSON instead of posting.
+142 -159
View File
@@ -27,20 +27,17 @@ GITHUB_API = "https://api.github.com"
UTILIZATION_WORKFLOW = "runner-utilization.yml"
# Primary pool labels only; aliases (1-gpu-runner, 8-gpu-h200-deepep, ...) are
# excluded so every runner is counted under exactly one label.
CUDA_LABEL_RE = re.compile(r"^\d+-gpu-(h100|h200|h20|5090|b200|b300|gb200|gb300|a10)$")
# Workflows whose jobs run on the CUDA pools; used for queue-digest.
CUDA_WORKFLOW_FILES = [
"pr-test.yml",
"pr-test-extra.yml",
"nightly-test-nvidia.yml",
"weekly-test-nvidia.yml",
]
# excluded so every runner is counted under exactly one label. a10 is left out
# as well: it serves no per-commit test, so its transitions are noise.
CUDA_LABEL_RE = re.compile(r"^\d+-gpu-(h100|h200|h20|5090|b200|b300|gb200|gb300)$")
FAILED_CONCLUSIONS = {"failure", "timed_out", "startup_failure", "action_required"}
# Aggregator jobs fail whenever any other job fails; listing them is noise.
AGGREGATOR_JOB_RE = re.compile(r"^(check-all-jobs|pr-test-finish)$")
# Jobs from a called workflow arrive prefixed ("call-pr-test-extra / <name>"),
# so the aggregator name is matched on the last segment.
AGGREGATOR_JOB_RE = re.compile(
r"^(?:.+ / )?(check-all-jobs|pr-test-finish|pr-test-extra-finish)$"
)
MAX_LISTED_JOBS = 15
@@ -162,25 +159,6 @@ def kv_columns(pairs: list) -> dict:
}
def table(columns: list, rows: list, page_size: int = 12) -> dict:
# columns: (key, display_name, data_type); rows: {key: value}
return {
"tag": "table",
"page_size": page_size,
"row_height": "low",
"header_style": {
"text_align": "left",
"bold": True,
"background_style": "grey",
},
"columns": [
{"name": k, "display_name": name, "data_type": dtype, "width": "auto"}
for k, name, dtype in columns
],
"rows": rows,
}
def button(text: str, url: str) -> dict:
return {
"tag": "button",
@@ -190,6 +168,17 @@ def button(text: str, url: str) -> dict:
}
def chart(spec: dict, aspect_ratio: str = "16:9") -> dict:
# The spec is VChart JSON rendered by the Lark client, so no image upload
# (hence no Lark app credentials) is involved. Needs Lark client 7.1+.
return {
"tag": "chart",
"aspect_ratio": aspect_ratio,
"color_theme": "brand",
"chart_spec": spec,
}
HR = {"tag": "hr"}
@@ -241,6 +230,13 @@ def fmt_local(dt: Optional[datetime]) -> str:
return dt.astimezone(LOCAL_TZ).strftime("%Y-%m-%d %I:%M %p %Z")
def fmt_local_hour(dt: Optional[datetime]) -> str:
"""Timeline bucket label: the local hour alone, e.g. "9am"."""
if dt is None:
return "-"
return dt.astimezone(LOCAL_TZ).strftime("%I%p").lstrip("0").lower()
def plural(n: int, word: str) -> str:
return f"{n} {word}" if n == 1 else f"{n} {word}s"
@@ -256,14 +252,6 @@ def fmt_duration(seconds: Optional[float]) -> str:
return f"{seconds // 3600}h{(seconds % 3600) // 60:02d}m"
def percentile(values: list, p: float) -> Optional[float]:
if not values:
return None
ordered = sorted(values)
idx = int(round((len(ordered) - 1) * p))
return ordered[idx]
def primary_cuda_label(labels: list) -> Optional[str]:
for name in labels:
if CUDA_LABEL_RE.match(name):
@@ -550,134 +538,135 @@ def cmd_runner_health(args: argparse.Namespace, gh: GitHub) -> None:
# --------------------------------------------------------------------------
# queue-digest
# queue-timeline
# --------------------------------------------------------------------------
def job_queue_seconds(job: dict, now: datetime) -> Optional[float]:
created = parse_time(job.get("created_at"))
if created is None:
return None
# a still-queued job reports a placeholder started_at; measure against now
if job.get("status") == "queued":
return (now - created).total_seconds()
started = parse_time(job.get("started_at"))
if started is None or started < created:
return None
return (started - created).total_seconds()
def merge_timeline(series: dict) -> list:
"""Fold the per-label series into one CUDA-wide series, bucket by bucket.
def summarize_queue(jobs: list, now: datetime) -> dict:
per_label: dict = {}
for job in jobs:
pool_label = primary_cuda_label(job.get("labels") or [])
if pool_label is None:
Backlog sums across pools; the wait takes the max of the per-pool p90s,
since averaging would let idle pools mask the one pool that is stuck. The
merge costs the answer to "which pool?", so each bucket keeps the label
behind the deepest backlog and the longest wait -- routinely not the same.
"""
buckets: dict = {}
for label, rows in series.get("labels", {}).items():
if not CUDA_LABEL_RE.match(label):
continue
q = job_queue_seconds(job, now)
if q is None:
continue
entry = per_label.setdefault(pool_label, {"waits": [], "queued_now": []})
if job.get("status") == "queued":
entry["queued_now"].append(q)
else:
entry["waits"].append(q)
result = {}
for pool_label, e in per_label.items():
result[pool_label] = {
"n": len(e["waits"]),
"p50": percentile(e["waits"], 0.5),
"p90": percentile(e["waits"], 0.9),
"max": max(e["waits"]) if e["waits"] else None,
"queued_now": len(e["queued_now"]),
"oldest_queued": max(e["queued_now"]) if e["queued_now"] else None,
}
return result
for row in rows:
b = buckets.setdefault(
row["start"],
{
"backlog": 0,
"started": 0,
"p90": 0.0,
"p90_pool": "-",
"top_pool": "-",
"top_backlog": 0,
},
)
b["backlog"] += row["backlog"]
b["started"] += row["started"]
if row["p90_wait_min"] > b["p90"]:
b["p90"], b["p90_pool"] = row["p90_wait_min"], label
if row["backlog"] > b["top_backlog"]:
b["top_backlog"], b["top_pool"] = row["backlog"], label
return [dict(start=k, **v) for k, v in sorted(buckets.items())]
def slow_pools(stats: dict, slow_minutes: float) -> set:
return {k for k, s in stats.items() if (s["p90"] or 0) >= slow_minutes * 60}
QUEUE_COLUMNS = [
("pool", "Pool", "lark_md"),
("jobs", "Jobs", "text"),
("p50", "p50", "text"),
("p90", "p90", "text"),
("max", "Max", "text"),
("queued", "Queued now", "text"),
("oldest", "Oldest wait", "text"),
]
def render_queue_digest(
stats: dict, hours: float, slow_minutes: float, now: datetime, report_url: str
) -> dict:
slow = slow_pools(stats, slow_minutes)
ordered = sorted(stats.items(), key=lambda kv: -(kv[1]["p90"] or 0))
rows = []
for pool_label, s in ordered:
is_slow = pool_label in slow
rows.append(
def timeline_chart_spec(rows: list) -> dict:
hours = [fmt_local_hour(parse_time(r["start"])) for r in rows]
return {
"type": "common",
"data": [
{
"pool": f"**{pool_label}** (!)" if is_slow else pool_label,
"jobs": str(s["n"]),
"p50": fmt_duration(s["p50"]),
"p90": fmt_duration(s["p90"]),
"max": fmt_duration(s["max"]),
"queued": str(s["queued_now"]) if s["queued_now"] else "-",
"oldest": fmt_duration(s["oldest_queued"]) if s["queued_now"] else "-",
}
)
title = f"CUDA queue time, last {int(hours)}h"
if slow:
title += f" - p90 over {int(slow_minutes)}m on some pools"
window = f"{fmt_local(now - timedelta(hours=hours))} to {fmt_local(now)}"
"id": "backlog",
"values": [
{"hour": h, "value": r["backlog"]} for h, r in zip(hours, rows)
],
},
{
"id": "wait",
"values": [
{"hour": h, "value": round(r["p90"], 1)}
for h, r in zip(hours, rows)
],
},
],
"series": [
{
"type": "bar",
"id": "backlog",
"dataIndex": 0,
"xField": "hour",
"yField": "value",
"name": "Jobs waiting (peak)",
},
{
"type": "line",
"id": "wait",
"dataIndex": 1,
"xField": "hour",
"yField": "value",
"name": "p90 wait (min)",
},
],
"axes": [
{"orient": "left", "seriesIndex": [0], "title": {"visible": False}},
{"orient": "right", "seriesId": ["wait"], "grid": {"visible": False}},
{"orient": "bottom", "type": "band", "label": {"visible": True}},
],
"legends": {"visible": True, "orient": "bottom"},
}
def render_queue_timeline(rows: list, report_url: str) -> dict:
peak = max(rows, key=lambda r: r["backlog"])
slowest = max(rows, key=lambda r: r["p90"])
span = f"{fmt_local(parse_time(rows[0]['start']))} to {fmt_local(parse_time(rows[-1]['start']))}"
peak_hour = fmt_local_hour(parse_time(peak["start"]))
slowest_hour = fmt_local_hour(parse_time(slowest["start"]))
elements = [
md(f"{grey('Window')} {window}\n{grey('(!)')} p90 over {int(slow_minutes)}m"),
table(QUEUE_COLUMNS, rows) if rows else md("_No CUDA jobs in this window._"),
md(f"{grey('Window')} {span} {grey('(bucket: 1h)')}"),
kv_columns(
[
("Jobs started", str(sum(r["started"] for r in rows))),
("Peak backlog", f"{peak['backlog']} jobs"),
("Worst p90 wait", fmt_duration(slowest["p90"] * 60)),
]
),
chart(timeline_chart_spec(rows)),
md(
f"{grey('Peak backlog')} {peak_hour}, mostly "
f"**{peak['top_pool']}** ({peak['top_backlog']})\n"
f"{grey('Worst wait')} {slowest_hour}, **{slowest['p90_pool']}**"
),
]
return build_card(
title,
"orange" if slow else "blue",
"CUDA queue over the day",
"blue",
elements,
[("View utilization report", report_url)],
)
def fetch_window_jobs(
gh: GitHub, hours: float, workflow_files: list, workers: int
) -> list:
since = datetime.now(timezone.utc) - timedelta(hours=hours)
runs: list = []
for wf in workflow_files:
runs.extend(
gh.workflow_runs(
wf,
{"created": ">=" + since.strftime("%Y-%m-%dT%H:%M:%SZ")},
max_pages=10,
)
)
print(f"{len(runs)} runs in window across {len(workflow_files)} workflows")
with ThreadPoolExecutor(max_workers=workers) as pool:
job_lists = list(pool.map(lambda r: gh.run_jobs(r["id"]), runs))
jobs = [j for jl in job_lists for j in jl]
print(f"{len(jobs)} jobs fetched")
return jobs
def cmd_queue_digest(args: argparse.Namespace, gh: GitHub) -> None:
now = datetime.now(timezone.utc)
jobs = fetch_window_jobs(gh, args.hours, args.workflows.split(","), args.workers)
stats = summarize_queue(jobs, now)
if args.only_if_slow and not slow_pools(stats, args.slow_minutes):
print(f"no pool with p90 over {int(args.slow_minutes)}m; skipping")
def cmd_queue_timeline(args: argparse.Namespace, gh: GitHub) -> None:
with open(args.series_file) as f:
series = json.load(f)
rows = merge_timeline(series)
if not rows:
print("no CUDA buckets in the series; skipping")
return
report_url = gh.latest_run_url(UTILIZATION_WORKFLOW)
post_card(
render_queue_digest(stats, args.hours, args.slow_minutes, now, report_url),
args.webhook,
args.dry_run,
# The card is built from THIS run's scan, so link to it rather than to the
# last successful one, which would be yesterday's report.
run_id = os.environ.get("GITHUB_RUN_ID")
report_url = (
f"https://github.com/{gh.repo}/actions/runs/{run_id}"
if run_id
else gh.latest_run_url(UTILIZATION_WORKFLOW)
)
post_card(render_queue_timeline(rows, report_url), args.webhook, args.dry_run)
# --------------------------------------------------------------------------
@@ -713,18 +702,12 @@ def main() -> int:
)
p.add_argument("--remind-hours", type=float, default=1.0)
p = sub.add_parser("queue-digest", help="per-label queue time percentiles")
p.add_argument("--hours", type=float, default=8.0)
p = sub.add_parser("queue-timeline", help="daily queue backlog / wait chart")
p.add_argument(
"--slow-minutes", type=float, default=30.0, help="p90 above this is flagged"
"--series-file",
required=True,
help="JSON written by runner_utilization_report.py --queue-series-out",
)
p.add_argument(
"--only-if-slow",
action="store_true",
help="post only when some pool's p90 exceeds --slow-minutes",
)
p.add_argument("--workflows", default=",".join(CUDA_WORKFLOW_FILES))
p.add_argument("--workers", type=int, default=8)
args = parser.parse_args()
if not args.token:
@@ -740,7 +723,7 @@ def main() -> int:
{
"ci-status": cmd_ci_status,
"runner-health": cmd_runner_health,
"queue-digest": cmd_queue_digest,
"queue-timeline": cmd_queue_timeline,
}[args.command](args, gh)
return 0