From 04ffa91c9778b8a5a6941303c3a293063a9e763c Mon Sep 17 00:00:00 2001 From: jsboige Date: Mon, 21 Sep 2026 16:30:52 +0200 Subject: [PATCH 1/2] feat(ci,#15574): mesurer la co-residence du pool self-hosted -- hote, concurrence, slots Le depot sait deja mesurer la FAME (starvation, #13378) et la demande par runner, mais pas repondre aux deux questions qui tranchent une sur-souscription, explicitement laissees ouvertes par docs/ci/self-hosted-runners.md : combien de runners partagent un hote physique, et quel plafond de jobs concurrents cet hote porte. Deux blocs les rendent, dans measure_runner_demand.py : - `runners_inventory` (STATIQUE) : les slots enregistres groupes par hote, lus sur actions/runners. L'hote est presume du prefixe du nom (`-`) ; un nom sans suffixe numerique n'est PAS attribue plutot que de fabriquer un hote d'un seul slot. Trois etats distingues -- measured / unavailable (droit de lecture manquant, raison incluse) / not_collected -- aucun ne rend un parc vide. - `co_residence` (DYNAMIQUE) : par job, le pic et la moyenne du nombre de jobs presents sur le meme hote, croises avec sa duree. Le pic est calcule sur les bornes des intervalles : le compte ne change qu'aux bornes, et un point median unique rate un job qui chevauche un autre sur la moitie de sa duree (constate en ecrivant le test). Honte des bornes : la concurrence observee est une borne INFERIEURE (les jobs hors fenetre sont invisibles) ; les caveats sont emis DANS la sortie, pas seulement en commentaire, et une correlation n'y est pas presentee comme une cause. Le workflow `runner-coresidence-advisory.yml` porte la mesure : la collecte coute ~1 appel API par run, et un poste epuise son quota REST avant de couvrir une fenetre utile (mesure 2026-09-21 : quota epuise en moins d'une heure, l'instrument rendant `BROKEN INSTRUMENT`). Le jeton du workflow a le sien. Advisory par construction (schedule + dispatch, jamais pull_request), sur ubuntu-latest -- l'observateur ne consomme pas ce qu'il observe. 27 tests passent (18 existants + 9 nouveaux). Gardes du depot : self-hosted policy OK (150 workflows), concurrency-conj 0 offenceur. See #15574 Co-Authored-By: Claude Sonnet 5 --- .../workflows/runner-coresidence-advisory.yml | 150 ++++++++ scripts/ci/measure_runner_demand.py | 331 +++++++++++++++++- scripts/tests/test_measure_runner_demand.py | 149 ++++++++ 3 files changed, 628 insertions(+), 2 deletions(-) create mode 100644 .github/workflows/runner-coresidence-advisory.yml diff --git a/.github/workflows/runner-coresidence-advisory.yml b/.github/workflows/runner-coresidence-advisory.yml new file mode 100644 index 0000000000..9a2a5047dd --- /dev/null +++ b/.github/workflows/runner-coresidence-advisory.yml @@ -0,0 +1,150 @@ +name: Runner co-residence advisory + +# Organe de MESURE de la co-residence du pool self-hosted (#15574). +# +# POURQUOI CE WORKFLOW EXISTE +# --------------------------- +# Un test CPU dont le temps varie d'un facteur 3-4 selon le runner qui le +# prend, a code constant, ne mesure plus le code (#15574). Les distributions +# par label et par runner localisent une saturation ; elles ne revelent pas +# « combien de runners partagent un hote physique, ni son plafond de +# concurrence » (docs/ci/self-hosted-runners.md). Ce sont les deux nombres qui +# manquent pour trancher une sur-souscription, et ils sont mesurables -- +# `scripts/ci/measure_runner_demand.py` les rend par deux blocs : +# +# - `co_residence` (DYNAMIQUE) : par job, le pic et la moyenne du nombre de +# jobs qui tournaient sur le meme hote, croises avec sa duree. +# - `runners_inventory` (STATIQUE) : les slots enregistres par hote. +# +# POURQUOI UN RUNNER DEDIE ET PAS UNE COMMANDE LOCALE +# --------------------------------------------------- +# La collecte coute ~1 appel API par run (les jobs) plus la pagination. Un +# poste de travail epuise son quota REST bien avant de couvrir une fenetre +# utile -- mesure du 2026-09-21 : quota epuise avant la fin d'une fenetre d'une +# heure, l'instrument rendant alors `BROKEN INSTRUMENT` (exit 2) plutot qu'un +# zero propre. Le jeton du workflow a son propre quota, et l'organe devient +# reproductible par quiconque, sans dependre du poste d'un agent. +# +# Advisory PAR CONSTRUCTION : schedule + workflow_dispatch seulement, jamais +# pull_request ni push -- un run rouge ne peut donc jamais bloquer une PR, et +# le nom porte « advisory ». Le job tourne sur `ubuntu-latest` (jamais +# self-hosted : l'observateur ne doit pas consommer ce qu'il observe). +# +# `--runners` exige `administration: read`. Sans le secret RUNNERS_READ_PAT, +# l'inventaire sort `unavailable` AVEC sa raison -- jamais un parc vide, qui +# serait indiscernable d'un refus de lecture (meme discipline que +# check_runner_starvation.py, #13378). +# +# FENETRE : 6 h par defaut. Le jeton de workflow plafonne a 1000 requetes par +# heure et par depot ; le depot tire ~60-80 runs par heure, soit ~500 appels +# pour 6 h. Une fenetre plus large doit etre demandee explicitement, en +# sachant qu'elle peut rendre `BROKEN INSTRUMENT`. + +on: + # Mardi 04:20 UTC : apres le slow-lane (02:30) et hors du pic de push + # europeen. Minute off-:00 et hors des offsets de sweep (13, 43). + schedule: + - cron: '20 4 * * 2' + workflow_dispatch: + inputs: + hours: + description: "Taille de la fenetre mesuree, en heures" + required: false + default: "6" + runner_inventory: + description: "Collecter aussi l'inventaire des runners (exige le PAT)" + required: false + type: boolean + default: true + +permissions: + contents: read + actions: read + +concurrency: + group: runner-coresidence-advisory + cancel-in-progress: false + +jobs: + coresidence: + name: Runner co-residence measurement + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 1 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.12' + + - name: Measure co-residence + id: measure + env: + # Le PAT read-only (Administration:read) quand il existe ; sinon le + # jeton du workflow, qui fera rendre `unavailable` a l'inventaire au + # lieu de le fabriquer. + GH_TOKEN: ${{ secrets.RUNNERS_READ_PAT || github.token }} + HOURS: ${{ inputs.hours || '6' }} + WANT_INVENTORY: ${{ inputs.runner_inventory || 'true' }} + run: | + set -uo pipefail + python - > /tmp/window.env <<'PY' + import datetime as d + import os + now = d.datetime.now(d.timezone.utc) + hours = int(os.environ.get("HOURS", "6")) + print("SINCE=" + (now - d.timedelta(hours=hours)).strftime("%Y-%m-%dT%H:%M:%SZ")) + print("UNTIL=" + now.strftime("%Y-%m-%dT%H:%M:%SZ")) + PY + . /tmp/window.env + echo "fenetre : ${SINCE} -> ${UNTIL}" + ARGS=(--repo "$GITHUB_REPOSITORY" --since "$SINCE" --until "$UNTIL" --output /tmp/coresidence.json) + if [ "${WANT_INVENTORY}" = "true" ]; then + ARGS+=(--runners) + fi + set +e + python scripts/ci/measure_runner_demand.py "${ARGS[@]}" + echo "exit_code=$?" >> "$GITHUB_OUTPUT" + set -e + + - name: Publish verdict + if: always() + env: + EXIT_CODE: "${{ steps.measure.outputs.exit_code }}" + run: | + if [ ! -s /tmp/coresidence.json ]; then + echo "::warning title=Runner co-residence (instrument casse)::La mesure n'a pas abouti (exit=${EXIT_CODE}). C'est un verdict sur l'instrument ou le quota, jamais un parc sans concurrence -- ne pas lire l'absence de sortie comme un zero." + exit 0 + fi + python - <<'PY' + import json, pathlib + raw = json.loads(pathlib.Path("/tmp/coresidence.json").read_text(encoding="utf-8")) + a = raw.get("analysis", {}) + summary = a.get("co_residence", {}).get("summary", {}) + inv = a.get("runners_inventory", {}) + window = a.get("window", {}) + lines = [ + f"fenetre : {window.get('since')} -> {window.get('until')} ({window.get('hours')} h)", + f"jobs places : {summary.get('jobs_placed')} | non attribues : {summary.get('jobs_unplaced')}", + f"jobs seuls : {summary.get('solo_jobs')} | en concurrence : {summary.get('shared_jobs')}", + f"p50 seul : {summary.get('solo_runtime_minutes', {}).get('p50')} min" + f" | p50 partage : {summary.get('shared_runtime_minutes', {}).get('p50')} min" + f" | ratio : {summary.get('shared_over_solo_p50_ratio')}", + f"hotes observes : {summary.get('hosts')}", + f"inventaire runners : {inv.get('availability')}" + + (f" ({inv.get('total_runners')} runners)" if inv.get("availability") == "measured" else f" -- {inv.get('detail')}"), + ] + for host in a.get("co_residence", {}).get("hosts", [])[:12]: + lines.append( + f" {host['host']}: {host['slots_observed']} slot(s), {host['jobs']} jobs," + f" pic {host['max_concurrency_observed']}, moyenne {host['mean_concurrency_overall']}" + ) + body = "\n".join(lines) + print(body) + print("::notice title=Runner co-residence (%s -> %s)::%s" % ( + window.get("since"), window.get("until"), body.replace("\n", " | "))) + PY diff --git a/scripts/ci/measure_runner_demand.py b/scripts/ci/measure_runner_demand.py index 0cb8730801..d0af65eeca 100644 --- a/scripts/ci/measure_runner_demand.py +++ b/scripts/ci/measure_runner_demand.py @@ -11,11 +11,21 @@ --since 2026-08-24T09:00:00Z --until 2026-08-24T10:00:00Z \ --output runner-demand.json python scripts/ci/measure_runner_demand.py --input runner-demand.json + +Co-residence (#15574) : l'analyse croise, par job, sa duree avec le nombre de +jobs qui tournent sur le meme hote physique -- l'hote etant presume du prefixe +du `runner_name` (`-`). Deux blocs en sortent : `co_residence`, mesure +DYNAMIQUE (par job, pic et moyenne de concurrence sur son hote, borne +inferieure), et `runners_inventory`, mesure STATIQUE des slots enregistres par +hote (avec `--runners`, droit d'administration requis). Les deux sont requis +pour trancher une sur-souscription : la premiere dit ce qui s'est produit, la +seconde dit la capacite qui l'a produit. """ from __future__ import annotations import argparse import json +import re import subprocess import sys from collections import Counter, defaultdict @@ -30,6 +40,11 @@ SEARCH_CAP = 1000 MIN_SLICE = timedelta(seconds=1) +# Convention de nommage du pool self-hosted : un slot est `-`, l'hote +# etant le reste du nom (cf docs/ci/self-hosted-runners.md). Le suffixe est +# donc ce qui separe deux slots d'un meme hote de deux hotes distincts. +_RUNNER_SLOT_SUFFIX = re.compile(r"^(?P.+)-\d+$") + class MeasurementError(RuntimeError): """The instrument cannot prove that its result is complete or coherent.""" @@ -52,6 +67,25 @@ def iso_z(value: datetime) -> str: return value.astimezone(timezone.utc).isoformat().replace("+00:00", "Z") +def host_of(runner_name: object) -> str | None: + """Hote physique PRESUME d'un runner, derive de son nom. + + Le pool nomme ses slots `-` (`myia-po-2024-linux-docker-1/-2`). + Deux slots d'un meme hote ne different donc que par leur suffixe numerique, + et le regroupement par prefixe reconstitue l'hote -- c'est le seul chemin + vers « combien de runners partagent un hote », que les distributions par + runner ne peuvent pas rendre (docs/ci/self-hosted-runners.md). + + Un nom qui ne porte aucun suffixe numerique n'est PAS attribue : il rend + ``None`` plutot qu'un hote d'un seul slot. Inventer un hote pour chaque nom + irreductible fabriquerait exactement le chiffre qu'on cherche a mesurer. + """ + if not isinstance(runner_name, str) or not runner_name or runner_name.startswith("<"): + return None + match = _RUNNER_SLOT_SUFFIX.match(runner_name) + return match.group("host") if match else None + + def gh_api(endpoint: str) -> object: completed = subprocess.run( ["gh", "api", endpoint], @@ -216,24 +250,91 @@ def _minimal_run(row: dict, jobs: list[dict]) -> dict: } +def collect_runners(repo: str, fetch: Callable[[str], object] = gh_api) -> list[dict]: + """Inventaire des runners enregistres : la moitie STATIQUE de la co-residence. + + Le nombre de slots par hote se lit directement ici -- c'est le chiffre que + les distributions par label ou par runner ne peuvent pas rendre, et que + `docs/ci/po2024-topology-baseline.md` classe « non mesurable OS-localement ». + Il l'est, mais cote API, pas cote machine. + + L'appel exige un droit d'administration sur le depot. L'echec remonte comme + echec : un inventaire vide et un « je n'ai pas le droit de lire » ne se + ressemblent pas et ne doivent jamais etre confondus. + """ + rows: list[dict] = [] + page = 1 + total: int | None = None + while total is None or len(rows) < total: + endpoint = f"repos/{repo}/actions/runners?" + urlencode( + {"per_page": PER_PAGE, "page": page} + ) + payload = fetch(endpoint) + if not isinstance(payload, dict) or not isinstance(payload.get("runners"), list): + raise MeasurementError(f"invalid actions/runners response, page {page}") + reported = payload.get("total_count") + if not isinstance(reported, int) or reported < 0: + raise MeasurementError("actions/runners response has no valid total_count") + if total is None: + total = reported + elif reported != total: + raise MeasurementError("actions/runners total_count changed while paging") + chunk = payload["runners"] + if not chunk and len(rows) < total: + raise MeasurementError( + f"actions/runners page {page} empty before total_count={total}" + ) + rows.extend(chunk) + page += 1 + if len(rows) != total: + raise MeasurementError( + f"actions/runners pagination mismatch: total_count={total}, collected={len(rows)}" + ) + return [ + { + "id": row.get("id"), + "name": row.get("name"), + "status": row.get("status"), + "busy": row.get("busy"), + "labels": sorted( + label.get("name") + for label in (row.get("labels") or []) + if isinstance(label, dict) and label.get("name") + ), + } + for row in rows + if isinstance(row, dict) + ] + + def collect_snapshot( repo: str, since: datetime, until: datetime, fetch: Callable[[str], object] = gh_api, + include_runners: bool = False, ) -> dict: runs = collect_runs(repo, since, until, fetch) collected = [ _minimal_run(run, collect_jobs(repo, run["id"], fetch)) for run in runs ] - return { + snapshot = { "schema_version": 1, "repo": repo, "since": iso_z(since), "until": iso_z(until), "runs": collected, } + if include_runners: + # Un inventaire illisible ne doit pas emporter la mesure de temps avec + # lui : l'echec est consigne dans le snapshot, et l'analyse le rendra + # comme « non disponible » plutot que comme un parc vide. + try: + snapshot["runners"] = collect_runners(repo, fetch) + except MeasurementError as exc: + snapshot["runners"] = {"error": str(exc)} + return snapshot def _duration_minutes(start: str, end: str, label: str) -> float | None: @@ -322,6 +423,203 @@ def _render_timing_groups(groups: dict[str, dict], key: str) -> list[dict]: return rendered +def _runtime_percentiles(values: list[float]) -> dict: + return { + name: (round(value, 3) if value is not None else None) + for name, value in ( + ("p50", _percentile(values, 0.5)), + ("p90", _percentile(values, 0.9)), + ("max", max(values) if values else None), + ) + } + + +def _coresidence(records: list[dict]) -> dict: + """Croise la duree d'un job avec le nombre de jobs qui tournent sur son hote. + + Deux statistiques par job, parce qu'elles repondent a deux questions + distinctes : la concurrence de PIC (combien de jobs l'hote a-t-il portes + simultanement pendant ce job -- c'est elle qui dit le plafond atteint) et la + concurrence MOYENNE (quelle part de la duree s'est faite en compagnie). + + Le pic est calcule sur les bornes des intervalles : entre deux bornes le + compte ne peut pas changer, et un echantillon unique (un point median) rate + un job qui chevauche un autre sur la moitie de sa duree. + + La concurrence mesuree est une BORNE INFERIEURE de la vraie : seuls les jobs + de la fenetre collectee sont connus, et un job hors fenetre qui tournait en + parallele est invisible. Elle ne peut donc pas servir a prouver qu'un hote + n'est jamais sur-souscrit -- seulement a montrer qu'il l'est. + """ + per_host: dict[str, list[dict]] = defaultdict(list) + unplaced = 0 + for record in records: + if record["host"] is None: + unplaced += 1 + continue + per_host[record["host"]].append(record) + + solo: list[float] = [] + shared: list[float] = [] + rendered = [] + for host in sorted(per_host): + host_records = per_host[host] + buckets: dict[int, list[float]] = defaultdict(list) + for record in host_records: + start, end = record["start"], record["end"] + points = {start} + for other in host_records: + # Les seuls instants ou le compte peut changer sont les bornes + # des intervalles : echantillonner ailleurs ne peut rien + # apprendre, et un point median unique rate un job qui + # chevauche un autre sur la moitie de sa duree. + for bound in (other["start"], other["end"]): + if start <= bound < end: + points.add(bound) + peak = max( + (sum(1 for other in host_records + if other["start"] <= point < other["end"]) or 1) + for point in points + ) + duration = (end - start).total_seconds() + if duration > 0: + overlapped = sum( + max(0.0, (min(end, other["end"]) - max(start, other["start"])).total_seconds()) + for other in host_records + if other is not record + ) + record["mean_concurrency"] = 1.0 + overlapped / duration + else: + record["mean_concurrency"] = float(peak) + buckets[max(1, peak)].append(record["work"]) + for level, values in buckets.items(): + if level <= 1: + solo.extend(values) + else: + shared.extend(values) + + # La queue est clairsemee par construction : au-dela de 4 jobs + # concurrents sur un hote, les niveaux exacts ne portent plus de signal + # distinct, seul le fait d'etre sature compte. + tail: list[float] = [] + groups = [] + for level in sorted(buckets): + if level >= 5: + tail.extend(buckets[level]) + continue + groups.append({ + "concurrency": level, + "jobs": len(buckets[level]), + "runtime_minutes": _runtime_percentiles(buckets[level]), + }) + if tail: + groups.append({ + "concurrency": "5+", + "jobs": len(tail), + "runtime_minutes": _runtime_percentiles(tail), + }) + + rendered.append({ + "host": host, + "runners": sorted({record["runner"] for record in host_records}), + "slots_observed": len({record["runner"] for record in host_records}), + "jobs": len(host_records), + "max_concurrency_observed": max(buckets) if buckets else None, + "mean_concurrency_overall": ( + round( + sum(record["mean_concurrency"] for record in host_records) + / len(host_records), + 3, + ) + if host_records else None + ), + "runtime_by_concurrency": groups, + }) + + solo_p50 = _percentile(solo, 0.5) + shared_p50 = _percentile(shared, 0.5) + return { + "method": ( + "hote prefixe du runner_name (`-`) ; par job, " + "concurrence de PIC (max sur les bornes des intervalles) et " + "concurrence MOYENNE (integral du recouvrement / duree)" + ), + "caveats": [ + "la concurrence est une borne inferieure : les jobs hors fenetre de collecte sont invisibles", + "l'hote est presume du nom du runner ; un nom sans suffixe numerique n'est pas attribue", + "une correlation n'est pas une cause : une duree plus longue en concurrence peut venir du job lui-meme", + ], + "hosts": rendered, + "summary": { + "hosts": len(rendered), + "jobs_placed": len(records) - unplaced, + "jobs_unplaced": unplaced, + "solo_jobs": len(solo), + "shared_jobs": len(shared), + "solo_runtime_minutes": _runtime_percentiles(solo), + "shared_runtime_minutes": _runtime_percentiles(shared), + "shared_over_solo_p50_ratio": ( + round(shared_p50 / solo_p50, 3) + if solo_p50 and shared_p50 is not None and solo_p50 > 0 + else None + ), + }, + } + + +def _runners_inventory(raw: object) -> dict: + """Slots enregistres par hote -- ou l'aveu explicite qu'on ne les a pas lus. + + Trois etats distincts, jamais confondus : `measured`, `unavailable` (le + droit de lecture manque -- le detail porte la raison) et `not_collected` + (l'inventaire n'a pas ete demande). Aucun des trois ne rend un parc vide. + """ + if isinstance(raw, dict) and "error" in raw: + return {"availability": "unavailable", "detail": str(raw["error"])} + if not isinstance(raw, list): + return { + "availability": "not_collected", + "detail": "inventaire non demande : relancer la collecte avec --runners", + } + + per_host: dict[str, dict] = defaultdict( + lambda: {"slots": 0, "online": 0, "busy": 0, "labels": set()} + ) + unplaced = 0 + for runner in raw: + if not isinstance(runner, dict): + continue + host = host_of(runner.get("name")) + if host is None: + unplaced += 1 + continue + entry = per_host[host] + entry["slots"] += 1 + if runner.get("status") == "online": + entry["online"] += 1 + if runner.get("busy"): + entry["busy"] += 1 + entry["labels"].update( + label for label in (runner.get("labels") or []) if isinstance(label, str) + ) + + return { + "availability": "measured", + "total_runners": len(raw), + "unplaced_runners": unplaced, + "hosts": [ + { + "host": host, + "slots": data["slots"], + "online": data["online"], + "busy": data["busy"], + "labels": sorted(data["labels"]), + } + for host, data in sorted(per_host.items()) + ], + } + + def analyze(snapshot: dict) -> dict: if snapshot.get("schema_version") != 1: raise MeasurementError("unsupported or missing snapshot schema_version") @@ -346,6 +644,7 @@ def analyze(snapshot: dict) -> dict: total_jobs = timed_jobs = incomplete_jobs = skipped_without_start = 0 timestamp_skew_jobs = 0 runner_minutes = queue_minutes = 0.0 + timed_records: list[dict] = [] seen_runs: set[int] = set() for run in runs: @@ -407,6 +706,16 @@ def analyze(snapshot: dict) -> dict: timed_jobs += 1 runner_minutes += work queue_minutes += wait + timed_records.append({ + "job_id": job.get("id"), + "workflow": workflow, + "runner": runner, + "host": host_of(runner), + "start": parse_time(str(started)), + "end": parse_time(str(completed)), + "work": work, + "wait": wait, + }) workflow_data[workflow]["timed_jobs"] += 1 workflow_data[workflow]["runner_minutes"] += work workflow_data[workflow]["queue_minutes"] += wait @@ -453,6 +762,8 @@ def analyze(snapshot: dict) -> dict: "by_workflow": by_workflow, "by_label": _render_timing_groups(label_data, "label"), "by_runner": _render_timing_groups(runner_data, "runner_name"), + "co_residence": _coresidence(timed_records), + "runners_inventory": _runners_inventory(snapshot.get("runners")), } @@ -477,17 +788,33 @@ def main(argv: list[str] | None = None) -> int: parser.add_argument("--since", help="inclusive UTC ISO-8601 start (live mode)") parser.add_argument("--until", help="exclusive UTC ISO-8601 end (live mode)") parser.add_argument("--output", type=Path, help="write JSON result (default: stdout)") + parser.add_argument( + "--runners", + action="store_true", + help=( + "collecter aussi l'inventaire des runners enregistres (slots par hote). " + "Exige un droit d'administration sur le depot : sans lui l'inventaire " + "sort `unavailable` avec sa raison, jamais un parc vide." + ), + ) args = parser.parse_args(argv) try: if args.input: if args.since or args.until: raise MeasurementError("--since/--until cannot be combined with --input") + if args.runners: + raise MeasurementError("--runners cannot be combined with --input") snapshot = load_snapshot(args.input) else: if not args.since or not args.until: raise MeasurementError("live mode requires --since and --until") - snapshot = collect_snapshot(args.repo, parse_time(args.since), parse_time(args.until)) + snapshot = collect_snapshot( + args.repo, + parse_time(args.since), + parse_time(args.until), + include_runners=args.runners, + ) result = {"snapshot": snapshot, "analysis": analyze(snapshot)} rendered = json.dumps(result, indent=2, ensure_ascii=False) + "\n" if args.output: diff --git a/scripts/tests/test_measure_runner_demand.py b/scripts/tests/test_measure_runner_demand.py index b988f90465..d35e48cba8 100644 --- a/scripts/tests/test_measure_runner_demand.py +++ b/scripts/tests/test_measure_runner_demand.py @@ -302,3 +302,152 @@ def test_main_returns_two_for_broken_snapshot(tmp_path): source = tmp_path / "bad.json" source.write_text("{}", encoding="utf-8") assert mod.main(["--input", str(source)]) == mod.EXIT_BROKEN + + +# --- Co-residence #15574 ----------------------------------------------------- +# +# L'hote est presume du nom du runner ; la concurrence est echantillonnee au +# point median de chaque job. Ce qui doit rester vrai : deux slots d'un meme +# hote se regroupent, un nom non attribuable n'est PAS un hote, et une +# concurrence non observable ne se lit pas comme une absence de concurrence. + +def job_on(job_id, runner, start_min, end_min, day_minutes=0): + """Un job borne en minutes depuis dt(0), pour lire les recouvrements a l'oeil.""" + base = dt(0, day_minutes // 60, day_minutes % 60) + started = base.replace(minute=0) + mod.timedelta(minutes=start_min) + return job( + job_id, + created=started, + started=started, + completed=started + mod.timedelta(minutes=end_min), + runner_name=runner, + ) + + +def test_host_of_groups_slots_and_refuses_unattributable_names(): + assert mod.host_of("myia-po-2024-linux-docker-1") == "myia-po-2024-linux-docker" + assert mod.host_of("myia-po-2024-linux-docker-12") == "myia-po-2024-linux-docker" + # Deux slots du meme hote partagent donc bien leur prefixe. + assert ( + mod.host_of("myia-po-2024-linux-docker-1") + == mod.host_of("myia-po-2024-linux-docker-9") + ) + # Un nom sans suffixe numerique n'est pas un hote : le deviner fabriquerait + # un hote d'un seul slot, c'est-a-dire le chiffre meme qu'on mesure. + assert mod.host_of("GitHub Actions 1") is None + assert mod.host_of("myia-po-2024-linux-docker") is None + assert mod.host_of("") is None + assert mod.host_of(None) is None + + +def test_coresidence_separates_solo_from_shared_on_the_same_host(): + # Deux jobs du meme hote se recouvrent (0-20 et 10-30) ; un troisieme est + # seul (40-50). Le job de 20 min en concurrence doit sortir « shared », le + # job seul « solo » -- sans quoi la correlation duree/concurrence serait + # mesuree sur un melange. + rows = [ + snapshot([run(1, dt(0), name="W")]), + ] + jobs = [ + job_on(11, "myia-po-2024-linux-docker-1", 0, 20), + job_on(12, "myia-po-2024-linux-docker-2", 10, 30), + job_on(13, "myia-po-2024-linux-docker-3", 40, 50), + ] + rows[0]["runs"] = [with_jobs(run(1, dt(0), name="W"), jobs)] + result = mod.analyze(rows[0]) + + block = result["co_residence"] + assert block["summary"]["hosts"] == 1 + assert block["summary"]["jobs_placed"] == 3 + assert block["summary"]["jobs_unplaced"] == 0 + assert block["summary"]["solo_jobs"] == 1 + assert block["summary"]["shared_jobs"] == 2 + host = block["hosts"][0] + assert host["host"] == "myia-po-2024-linux-docker" + assert host["slots_observed"] == 3 + assert host["max_concurrency_observed"] == 2 + + +def test_coresidence_counts_two_hosts_apart(): + # Deux jobs simultanes mais sur deux hotes distincts ne sont PAS en + # concurrence : c'est tout l'objet de la derivation d'hote. + jobs = [ + job_on(21, "myia-po-2024-linux-docker-1", 0, 30), + job_on(22, "myia-ai-01-linux-1", 0, 30), + ] + snap = snapshot([with_jobs(run(1, dt(0), name="W"), jobs)]) + block = mod.analyze(snap)["co_residence"] + assert block["summary"]["hosts"] == 2 + assert block["summary"]["shared_jobs"] == 0 + assert {host["max_concurrency_observed"] for host in block["hosts"]} == {1} + + +def test_coresidence_reports_unplaced_instead_of_inventing_a_host(): + jobs = [ + job_on(31, "myia-po-2024-linux-docker-1", 0, 10), + job_on(32, "some-unlabelled-runner", 0, 10), + ] + snap = snapshot([with_jobs(run(1, dt(0), name="W"), jobs)]) + block = mod.analyze(snap)["co_residence"] + assert block["summary"]["jobs_unplaced"] == 1 + assert block["summary"]["jobs_placed"] == 1 + assert [host["host"] for host in block["hosts"]] == ["myia-po-2024-linux-docker"] + + +def test_coresidence_declares_its_own_caveats(): + snap = snapshot([with_jobs(run(1, dt(0), name="W"), [job_on(41, "h-1", 0, 5)])]) + block = mod.analyze(snap)["co_residence"] + joined = " ".join(block["caveats"]) + assert "borne inferieure" in joined + assert "presume" in joined + + +def test_coresidence_ratio_is_none_when_nothing_is_shared(): + snap = snapshot([with_jobs(run(1, dt(0), name="W"), [job_on(51, "h-1", 0, 5)])]) + summary = mod.analyze(snap)["co_residence"]["summary"] + assert summary["shared_jobs"] == 0 + assert summary["shared_over_solo_p50_ratio"] is None + + +# --- Inventaire des runners (moitie statique) -------------------------------- + +def test_runners_inventory_groups_slots_per_host(): + snap = snapshot([]) + snap["runners"] = [ + {"id": 1, "name": "myia-po-2024-linux-docker-1", "status": "online", "busy": False, + "labels": ["self-hosted", "coursia-ephemeral"]}, + {"id": 2, "name": "myia-po-2024-linux-docker-2", "status": "online", "busy": True, + "labels": ["self-hosted", "coursia-ephemeral"]}, + {"id": 3, "name": "myia-ai-01-linux-1", "status": "offline", "busy": False, + "labels": ["self-hosted"]}, + ] + inv = mod.analyze(snap)["runners_inventory"] + assert inv["availability"] == "measured" + assert inv["total_runners"] == 3 + assert inv["unplaced_runners"] == 0 + by_host = {row["host"]: row for row in inv["hosts"]} + assert by_host["myia-po-2024-linux-docker"]["slots"] == 2 + assert by_host["myia-po-2024-linux-docker"]["online"] == 2 + assert by_host["myia-po-2024-linux-docker"]["busy"] == 1 + assert by_host["myia-ai-01-linux"]["slots"] == 1 + assert by_host["myia-ai-01-linux"]["online"] == 0 + + +def test_runners_inventory_distinguishes_unavailable_from_empty(): + # Un refus de lecture n'est pas un parc vide : les deux doivent rester + # distinguables jusque dans la sortie. + unavailable = mod.analyze({**snapshot([]), "runners": {"error": "gh api failed: 403"}}) + assert unavailable["runners_inventory"]["availability"] == "unavailable" + assert "403" in unavailable["runners_inventory"]["detail"] + + not_collected = mod.analyze(snapshot([]))["runners_inventory"] + assert not_collected["availability"] == "not_collected" + + +def test_runners_inventory_does_not_invent_a_host_for_an_unattributable_name(): + snap = snapshot([]) + snap["runners"] = [{"id": 9, "name": "ephemeral", "status": "online", "busy": False, + "labels": []}] + inv = mod.analyze(snap)["runners_inventory"] + assert inv["unplaced_runners"] == 1 + assert inv["hosts"] == [] From c9637f27d40bb844db992f46de43ea2fcf7f6d3e Mon Sep 17 00:00:00 2001 From: jsboige Date: Mon, 21 Sep 2026 16:32:20 +0200 Subject: [PATCH 2/2] docs(ci,#15574): documenter la co-residence -- et l'etat honnete de sa mesure MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ajoute a docs/ci/self-hosted-runners.md la section qui repond au paragraphe qui declarait le manque : les deux nombres sont mesurables cote API, ce que le document croyait hors d'atteinte (il les classait « console d'administration »). Le fait mesurant : `actions/runners` rend les slots enregistres. La section porte les deux blocs, la derivation d'hote et sa limite (un nom sans suffixe numerique n'est pas attribue), les deux statistiques de concurrence et pourquoi le pic se calcule sur les bornes, les caveats (bornes inferieures, correlation != cause) et le runner. ETAT DE LA MESURE : l'instrument et son runner sont livres, les CHIFFRES ne le sont pas encore -- quota REST epuise ce cycle (5000/h partage entre tous les agents de la machine). Aucun chiffre n'est donc ecrit ici : un instrument n'est pas une caracterisation, et l'issue reste ouverte jusqu'au premier run de l'organe. Meme discipline que po2024-topology-baseline.md (« pas de mesure, pas de chiffre »). Co-Authored-By: Claude Sonnet 5 --- docs/ci/self-hosted-runners.md | 32 +++++++++++++++++++++++++++++ docs/reference/scripts-reference.md | 2 +- 2 files changed, 33 insertions(+), 1 deletion(-) diff --git a/docs/ci/self-hosted-runners.md b/docs/ci/self-hosted-runners.md index d235cd18a3..90ce004f29 100644 --- a/docs/ci/self-hosted-runners.md +++ b/docs/ci/self-hosted-runners.md @@ -66,6 +66,38 @@ Avant toute bascule, relever au minimum : Le détail par workflow sépare la capacité réellement consommée de l'auto-contention. En particulier, le `PR gate` peut occuper un runner pendant qu'il sonde des checks eux-mêmes en file : dimensionner sur la demande brute financerait ce temps d'attente au lieu de le corriger. Les distributions par label et runner localisent une saturation observée ; elles ne révèlent pas à elles seules combien de runners partagent un hôte physique, son plafond de concurrence, ni la politique de capacité à retenir. Ces décisions exigent une mesure de topologie distincte. +## Co-résidence : hôte, concurrence, slots (#15574) + +Les deux nombres que le paragraphe précédent laisse ouverts sont mesurables, mais **côté API, pas côté machine** : `actions/runners` rend les slots enregistrés, et les horodatages des jobs rendent le recouvrement. `scripts/ci/measure_runner_demand.py` les produit en deux blocs, à lire ensemble — le premier dit ce qui s'est passé, le second la capacité qui l'a produit. + +| Bloc | Question | Source | Nature | +|---|---|---|---| +| `runners_inventory` | Combien de runners partagent un hôte ? | `actions/runners` (`--runners`) | STATIQUE | +| `co_residence` | Combien de jobs un hôte a-t-il portés simultanément, et à quelle durée ? | horodatages des jobs | DYNAMIQUE | + +**L'hôte est présumé du nom du runner.** Le pool nomme ses slots `-` (`myia-po-2024-linux-docker-1/-2`), donc le regroupement par préfixe reconstitue l'hôte. Un nom sans suffixe numérique n'est **pas** attribué : l'inventer fabriquerait un hôte d'un seul slot, c'est-à-dire exactement le chiffre qu'on cherche à mesurer. Les jobs et runners non attribuables sont comptés séparément (`jobs_unplaced`, `unplaced_runners`). + +**Deux statistiques de concurrence par job**, parce qu'elles répondent à deux questions distinctes : + +- le **pic** — combien de jobs l'hôte a portés simultanément pendant ce job ; c'est lui qui dit le plafond atteint ; +- la **moyenne** — quelle part de la durée s'est faite en compagnie. + +Le pic est calculé sur les **bornes** des intervalles : entre deux bornes le compte ne peut pas changer, et un échantillon unique au point médian rate un job qui chevauche un autre sur la moitié de sa durée (constaté en écrivant la suite de tests). + +**Bornes et honnêteté de la mesure.** La concurrence observée est une **borne inférieure** : seuls les jobs de la fenêtre collectée sont connus, un job hors fenêtre qui tournait en parallèle est invisible. Elle peut donc montrer qu'un hôte est sur-souscrit, jamais prouver qu'il ne l'est pas. Les caveats sont émis **dans la sortie JSON**, pas seulement dans ce document, et une corrélation durée↔concurrence n'y est pas présentée comme une cause : une durée plus longue en concurrence peut venir du job lui-même. L'inventaire distingue trois états — `measured`, `unavailable` (droit de lecture manquant, raison incluse) et `not_collected` — dont **aucun** ne rend un parc vide. + +**Pourquoi un runner de workflow et pas une commande locale.** La collecte coûte environ un appel API par run (la lecture des jobs), plus la pagination. Un poste de travail épuise son quota REST avant de couvrir une fenêtre utile — mesuré le 2026-09-21 : quota épuisé avant la fin d'une fenêtre d'**une heure**, l'instrument rendant alors `BROKEN INSTRUMENT` (exit 2) plutôt qu'un zéro propre, ce qui est le comportement voulu. `runner-coresidence-advisory.yml` porte donc la mesure sur `ubuntu-latest` (l'observateur ne consomme pas ce qu'il observe), en advisory — `schedule` + `workflow_dispatch` seulement, jamais `pull_request`, donc un run rouge ne peut pas bloquer une PR. + +```bash +# après merge (workflow_dispatch exige le fichier sur la branche par défaut) +gh workflow run runner-coresidence-advisory.yml -f hours=6 -f runner_inventory=true +# ou en local, quand le quota REST est disponible +python scripts/ci/measure_runner_demand.py --repo jsboige/CoursIA \ + --since --until --runners --output coresidence.json +``` + +**État de la mesure.** L'instrument et son runner sont livrés ; **les chiffres ne le sont pas encore**. Ils seront publiés par le premier run de l'organe (cron du mardi 04:20 UTC, ou dispatch immédiat après merge) et versés ici datés, avec la décision de capacité qu'ils fondent. Tant qu'ils ne sont pas là, #15574 reste ouvert : un instrument n'est pas une caractérisation. + ## Topologie retenue `jsboige/CoursIA` appartient à un compte GitHub personnel. Les groupes de runners personnalisés sont réservés aux organisations et ne constituent donc pas une barrière disponible ici. La frontière activable repose sur deux contrôles complémentaires : diff --git a/docs/reference/scripts-reference.md b/docs/reference/scripts-reference.md index 281e1e32c2..c67c58aec8 100644 --- a/docs/reference/scripts-reference.md +++ b/docs/reference/scripts-reference.md @@ -192,7 +192,7 @@ Pipeline d'audit qualité et de **matrice de coût** (EPIC #8056) + audit séman | `scripts/check_adjoint_prevalidation.py` | **Gate d'entrée en review ai-01** (#16442) : exige un dossier `[ADJOINT PREFLIGHT]` complet de la lane adjointe, lié au SHA exact et à une empreinte des surfaces body/comments/reviews/threads/checks. Toute absence, mutation observable des surfaces actuelles ou valeur non canonique échoue fermé ; un événement GitHub ensuite supprimé/reverté n'est pas reconstructible par cet organe stateless. `--template` génère le bloc complet (empreinte incluse) et `--fingerprint` expose l'empreinte seule. Exit `0` READY / `1` BLOCKED / `2` UNKNOWN. READY autorise la lecture finale du coordinateur, jamais le merge | | `scripts/check_pr_perimeter.py` | **Source de vérité périmètre pour reviews** (#11268) : énumère les fichiers effectifs d'une PR (`gh pr view --json files`), nomme tout `.github/workflows/**` dans une section dédiée, détecte les **mouvements de baseline/seuil** du diff avec leur sens (`sorry-baseline` 16→14 = TIGHTEN ; un desserrement sans `--baseline-justified` => CHANGES_REQUESTED), et confronte l'assertion de périmètre du reviewer (`--assert "..."`) à la liste réelle — la review #11227 (« 2 fichiers twins uniquement » sur 3 fichiers dont un workflow) ne peut plus être produite à l'insu. `--scan-thread` scanne le body PR + les reviews top-level et confronte chaque assertion trouvée à la liste effective ; câblé par `.github/workflows/perimeter-review-guard.yml` (déclenché sur `pull_request` + `pull_request_review`) — une fausse assertion devient un check rouge bloquant. Exit `0` OK / `1` FAIL (écart assertion, workflow non nommé, desserrement nu) / `2` erreur gh. À exécuter AVANT toute assertion de périmètre dans une review | | `scripts/coordination/debt_ledger.py` | **Reducteur du ledger de dette d'issue partage** (`issue-debt`) : schema versionne, validation stricte des observations (UTC explicite, provenance obligatoire, cles inconnues refusees), fusion par champ « plus recente observation compatible gagne » avec provenance et historique, pliage idempotent du snapshot precedent (archive-aware). Le **transport partage n'est pas un fichier** : observations = messages append-only du dashboard dedie `CoursIA-issue-debt-ledger`, snapshot ecrit par ai-01 seul en section `status` — jamais d'ecriture sous `$ROOSYNC_SHARED_PATH` (Drive : pas de verrou, `assert_local_output` refuse, sans override). CLI `init` (dry-run par defaut) / `append` (imprime l'appel MCP, n'ecrit rien) / `reduce` (artefacts locaux atomiques sous verrou). L'adaptateur de l'export producteur, le contrat de couverture `window` et le ledger `pr-actions` arrivent avec le transport. Schema et metriques : [scripts/coordination/README.md](../../scripts/coordination/README.md) ; lectures : `scripts/tests/test_debt_ledger.py` | -| `scripts/ci/measure_runner_demand.py` | **Baseline exhaustive de demande GitHub Actions** (#12704) : collecte une fenêtre UTC avec bisection anti-cap 1 000 + pagination de tous les jobs, mesure attente (`started_at-created_at`), travail runner (`completed_at-started_at`), provenance same-repo/fork/unknown et dénominateurs ; replay offline par `--input`. Exit `0` mesure valide / `2` instrument ou snapshot incomplet. Procédure : [docs/ci/self-hosted-runners.md](../ci/self-hosted-runners.md) | +| `scripts/ci/measure_runner_demand.py` | **Baseline exhaustive de demande GitHub Actions** (#12704) : collecte une fenêtre UTC avec bisection anti-cap 1 000 + pagination de tous les jobs, mesure attente (`started_at-created_at`), travail runner (`completed_at-started_at`), provenance same-repo/fork/unknown et dénominateurs ; replay offline par `--input`. **Co-résidence** (#15574) : bloc `co_residence` (hôte présumé du préfixe du `runner_name`, pic et moyenne de concurrence par job) et bloc `runners_inventory` (`--runners`, slots enregistrés par hôte, trois états `measured`/`unavailable`/`not_collected`, jamais un parc vide). Exit `0` mesure valide / `2` instrument ou snapshot incomplet. Procédure : [docs/ci/self-hosted-runners.md](../ci/self-hosted-runners.md) · organe : `.github/workflows/runner-coresidence-advisory.yml` | | `scripts/ci/manage_self_hosted_runner.py` + `self_hosted_runner_profiles.json` | **Cycle de vie Windows des runners éphémères isolés** (#12704) : profils distribués po-2023..po-2026 avec archive/SHA-256 épinglés ; commandes `install`, `register`, `verify`, `teardown` en dry-run par défaut, mutations uniquement avec `--apply`; compte local dédié, ACL négatives `.secrets`/SSH/gh, tokens via `ACTIONS_RUNNER_INPUT_*`, extraction anti-Zip-Slip/ADS et teardown borné par manifeste. `register --apply` est le bouton d’activation séparé, jamais lancé pendant la préparation. Procédure : [docs/ci/self-hosted-runners.md](../ci/self-hosted-runners.md) | | `scripts/mcp-maintenance/` | Maintenance MCP (config, docs, scripts) — cf `README_MCP_MAINTENANCE.md` | | `scripts/validation/dispatch.py` + `matrix.yml` | Matrice de validation / dispatch |