Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
59 changes: 48 additions & 11 deletions .github/workflows/coursia-waiter-starvation-advisory.yml
Original file line number Diff line number Diff line change
Expand Up @@ -40,25 +40,59 @@ name: Waiter runner starvation advisory
# porte "advisory" (ADVISORY_MARKER, cf pr-gate-sweep-health-advisory.yml).
#
# #15332 : le scheduler GitHub a cesse de livrer l'evenement schedule le
# 2026-09-09 (4 h sans AUCUN declenchement planifie, depot entier) -- un
# organe porte par schedule seul meurt de la panne qu'il observe. Le
# heartbeat workflow_run s'ancre sur DEUX workflows, par jambe :
# - "Secret Scan" : chaque push et pull_request, jobs sur le parc
# self-hosted (coursia-ephemeral) -- jambe vivante quand le scheduler
# se tait et que le parc vit.
# - "Notebook outputs required (H.4 schema)" : push/PR touchant un
# notebook et jobs ubuntu-latest -- jambe GitHub-hosted de bout en bout :
# ce job tourne sur ubuntu-latest, donc le heartbeat survit AUSSI au parc
# eteint tant que le flux notebook pousse.
# 2026-09-09 (4 h sans AUCUN declenchement planifie, depot entier, 159
# workflows) -- un organe porte par schedule seul meurt de la panne qu'il
# observe. Le heartbeat workflow_run s'ancre sur « Secret Scan », qui se
# declenche sur CHAQUE push et CHAQUE pull_request du depot, par jambe :
# - jambe « pull_request » : elle se declenche sur l'activite PR, qui
# continue quand le scheduler se tait -- mesure de la panne du
# 2026-09-09 : 47 fins push/main ET ~53 fins pull_request de l'ancre
# entre 00:00Z et 06:00Z, dont la fenetre de panne (01:13Z -> 05:22Z).
# C'est cette jambe qui compte le plus : un pool eteint bloque les jobs
# de PR, donc les merges s'arretent -- une jambe « push » seule ne
# declencherait plus rien, et l'observateur mourrait de la panne qu'il
# mesure ;
# - jambe « push » : sur un push vers main, les jobs de Secret Scan
# tournent sur ubuntu-latest (mesure : 20/20 jobs lus) -- elle survit
# donc a un parc self-hosted eteint.
# L'ancre « Notebook outputs required (H.4 schema) » est RETIREE de la liste :
# ses jobs sont sur le parc self-hosted depuis #21112 (« carrier routing »,
# forme hybride Linux) -- mesure : 20/20 jobs de ses runs push/main sur
# [self-hosted, coursia-ephemeral, coursia-linux]. La « jambe GitHub-hosted
# de bout en bout » que ce header annoncait n'existe donc plus (le routage
# l'a annulee sans que le header soit corrige) : le heartbeat ne survit
# aujourd'hui a la panne du SCHEDULER, pas a celle du parc. Et l'ensemble
# de declenchements de cette ancre est un SOUS-ENSEMBLE de celui de
# « Secret Scan » -- elle ne se declenche que sur les evenements qui
# touchent un *.ipynb, que Secret Scan voit aussi -- la garder ne doublait
# que le volume.
# La cadence SERVIE est mesuree par le script lui-meme (runs event=schedule
# uniquement -- les runs workflow_run ne faussent pas la mesure).
#
# PORTE DE MESURE (#22937) : le heartbeat se declenche sur chaque fin
# d'ancre -- mesure sur 24 h (fenetre `created=` cote serveur, paginee :
# le listing tronque a 100 runs, et une fenetre non paginee donne un
# FAUX 4,2/h la ou l'incident en montre 48/h) : 418 fins « Secret Scan »
# (309 pull_request + 109 push), soit 17,4 runs/h par advisory, 35 pour la
# paire. Chaque
# passage complet paie ~9 appels API (compteur `gh_calls` du script ;
# l'inventaire des runners en ajoute 1, volontairement non compte) sur le
# jeton de depot (budget 1000 req/h) : la paire consommait ~480 appels/h,
# et le 2026-10-28 22:04Z le jeton a ete sature (« API rate limit
# exceeded for installation ») -> 18 rouges sur main en 10 min. La porte
# (MEASURE_EVERY_N_RUNS) borne la MESURE : un passage complet sur 4, les
# autres ne paient que la lecture de la cadence servie (1 appel). Le RUN
# existe toujours et son rapport de cadence servie (#15332) est emis a
# chaque passage -- c'est la SURVIVANCE de l'observateur qui est la
# garantie, pas la frequence de la lecture complete. La jambe `schedule`
# n'est jamais gatee : elle est la cadence de reference de l'advisory.

on:
schedule:
- cron: '11,41 * * * *'
workflow_dispatch:
workflow_run:
workflows: ["Secret Scan", "Notebook outputs required (H.4 schema)"]
workflows: ["Secret Scan"]
types: [completed]

permissions:
Expand Down Expand Up @@ -98,4 +132,7 @@ jobs:
SELF_WORKFLOW: coursia-waiter-starvation-advisory.yml
DECLARED_CADENCE_MINUTES: "30"
CADENCE_WARN_MINUTES: "360"
# #22937 : borne la MESURE complete -- le run existe toujours et
# son rapport de cadence servie (#15332) est emis a chaque passage.
MEASURE_EVERY_N_RUNS: "4"
run: python3 scripts/ci/check_runner_starvation.py
59 changes: 46 additions & 13 deletions .github/workflows/linux-runner-starvation-advisory.yml
Original file line number Diff line number Diff line change
Expand Up @@ -26,27 +26,57 @@ name: Linux runner starvation advisory
# #15332 : le scheduler GitHub a cesse de livrer l'evenement schedule le
# 2026-09-09 (4 h sans AUCUN declenchement planifie, depot entier, 159
# workflows) -- un organe porte par schedule seul meurt de la panne qu'il
# observe. Le heartbeat workflow_run s'ancre sur DEUX workflows, par jambe :
# - "Secret Scan" : chaque push et pull_request, mais ses jobs tournent
# sur le parc self-hosted (coursia-ephemeral) -- jambe vivante quand le
# scheduler se tait et que le parc vit.
# - "Notebook outputs required (H.4 schema)" : push/PR touchant un
# notebook (le flux principal du depot) et jobs ubuntu-latest -- jambe
# GitHub-hosted de bout en bout : ce job aussi tourne sur ubuntu-latest,
# donc le heartbeat survit AUSSI au parc eteint tant que le flux
# notebook pousse (correction de la revue ai-01 du 2026-09-16 : Secret
# Scan seul reste en file indeterminee quand le parc est mort -- run
# jamais completed, workflow_run jamais emis ; un ancrage "heberge
# GitHub" doit l'etre de fait, pas de claim).
# observe. Le heartbeat workflow_run s'ancre sur « Secret Scan », qui se
# declenche sur CHAQUE push et CHAQUE pull_request du depot, par jambe :
# - jambe « pull_request » : elle se declenche sur l'activite PR, qui
# continue quand le scheduler se tait -- mesure de la panne du
# 2026-09-09 : 47 fins push/main ET ~53 fins pull_request de l'ancre
# entre 00:00Z et 06:00Z, dont la fenetre de panne (01:13Z -> 05:22Z).
# C'est cette jambe qui compte le plus : un pool eteint bloque les jobs
# de PR, donc les merges s'arretent -- une jambe « push » seule ne
# declencherait plus rien, et l'observateur mourrait de la panne qu'il
# mesure ;
# - jambe « push » : sur un push vers main, les jobs de Secret Scan
# tournent sur ubuntu-latest (mesure : 20/20 jobs lus) -- elle survit
# donc a un parc self-hosted eteint.
# L'ancre « Notebook outputs required (H.4 schema) » est RETIREE de la liste :
# ses jobs sont sur le parc self-hosted depuis #21112 (« carrier routing »,
# forme hybride Linux) -- mesure : 20/20 jobs de ses runs push/main sur
# [self-hosted, coursia-ephemeral, coursia-linux]. La « jambe GitHub-hosted
# de bout en bout » que ce header annoncait n'existe donc plus (le routage
# l'a annulee sans que le header soit corrige) : le heartbeat ne survit
# aujourd'hui a la panne du SCHEDULER, pas a celle du parc. Et l'ensemble
# de declenchements de cette ancre est un SOUS-ENSEMBLE de celui de
# « Secret Scan » -- elle ne se declenche que sur les evenements qui
# touchent un *.ipynb, que Secret Scan voit aussi -- la garder ne doublait
# que le volume.
# La cadence SERVIE est mesuree par le script lui-meme (runs event=schedule
# uniquement -- les runs workflow_run ne faussent pas la mesure).
#
# PORTE DE MESURE (#22937) : le heartbeat se declenche sur chaque fin
# d'ancre -- mesure sur 24 h (fenetre `created=` cote serveur, paginee :
# le listing tronque a 100 runs, et une fenetre non paginee donne un
# FAUX 4,2/h la ou l'incident en montre 48/h) : 418 fins « Secret Scan »
# (309 pull_request + 109 push), soit 17,4 runs/h par advisory, 35 pour la
# paire. Chaque
# passage complet paie ~9 appels API (compteur `gh_calls` du script ;
# l'inventaire des runners en ajoute 1, volontairement non compte) sur le
# jeton de depot (budget 1000 req/h) : la paire consommait ~480 appels/h,
# et le 2026-10-28 22:04Z le jeton a ete sature (« API rate limit
# exceeded for installation ») -> 18 rouges sur main en 10 min. La porte
# (MEASURE_EVERY_N_RUNS) borne la MESURE : un passage complet sur 4, les
# autres ne paient que la lecture de la cadence servie (1 appel). Le RUN
# existe toujours et son rapport de cadence servie (#15332) est emis a
# chaque passage -- c'est la SURVIVANCE de l'observateur qui est la
# garantie, pas la frequence de la lecture complete. La jambe `schedule`
# n'est jamais gatee : elle est la cadence de reference de l'advisory.

on:
schedule:
- cron: '19,49 * * * *'
workflow_dispatch:
workflow_run:
workflows: ["Secret Scan", "Notebook outputs required (H.4 schema)"]
workflows: ["Secret Scan"]
types: [completed]

permissions:
Expand Down Expand Up @@ -121,4 +151,7 @@ jobs:
SELF_WORKFLOW: linux-runner-starvation-advisory.yml
DECLARED_CADENCE_MINUTES: "30"
CADENCE_WARN_MINUTES: "360"
# #22937 : borne la MESURE complete -- le run existe toujours et
# son rapport de cadence servie (#15332) est emis a chaque passage.
MEASURE_EVERY_N_RUNS: "4"
run: python3 scripts/ci/check_runner_starvation.py
161 changes: 135 additions & 26 deletions scripts/ci/check_runner_starvation.py
Original file line number Diff line number Diff line change
Expand Up @@ -576,17 +576,22 @@ def absorb(run: dict, status: str, created: datetime) -> None:
return result


def fetch_scheduled_runs(repo: str, self_workflow: str, limit: int = 3) -> list[datetime]:
def fetch_scheduled_runs(repo: str, self_workflow: str, limit: int = 3,
stats: CallStats | None = None) -> list[datetime]:
"""Dates de creation (UTC, ordre descroissant) des derniers runs
``event=schedule`` du workflow lui-meme.

#15332 : la cadence SERVIE se mesure sur les seuls runs planifies -- les
runs workflow_run/workflow_dispatch (heartbeats) ne prouvent rien du
scheduler et ne doivent pas fausser la mesure.

`stats` est optionnel : fourni, l'appel de ce listing entre dans le
compteur `gh_calls` du passage (honnetete du cout annonce -- #22937).
"""
data = _gh_json(
[f"repos/{repo}/actions/workflows/{self_workflow}/runs"
f"?event=schedule&per_page={limit}"]
f"?event=schedule&per_page={limit}"],
stats=stats,
)
if not isinstance(data, dict):
return []
Expand All @@ -598,6 +603,72 @@ def fetch_scheduled_runs(repo: str, self_workflow: str, limit: int = 3) -> list[
return out


def _env_int(name: str) -> int | None:
"""Entier d'environnement, ou None si absent/illisible.

`GITHUB_RUN_NUMBER` est fourni par le runner Actions ; hors Actions il
est absent, et la porte de mesure doit alors laisser passer (une
lecture qui ne sait pas ou elle est ne doit pas s'arreter).
"""
raw = os.environ.get(name)
if raw is None or not raw.strip():
return None
try:
return int(raw.strip())
except ValueError:
return None


def gate_measurement(
*,
every_n: int,
run_number: int | None,
event_name: str,
) -> tuple[bool, str]:
"""Decide si CE passage paie la mesure complete (#22937).

Le heartbeat `workflow_run` d'une advisory se declenche sur CHAQUE fin
d'ancre. Mesure sur 24 h (fenetre `created=` cote serveur, sans
troncature de pagination) : 418 fins « Secret Scan » (309 pull_request
+ 109 push) et 300 fins « Notebook outputs required » (238 + 62), soit
718 fins d'ancre -- 29,92/h, 60,62 runs/h pour la paire d'advisories, ~8
appels API par passage (compteur du script), donc ~479 appels/h sur le
jeton de depot (budget 1000 req/h). Le 2026-10-28 22:04Z : « API rate
limit exceeded for installation », puis 18 rouges sur `main` en 10 min.

La porte borne la MESURE, pas le RUN : le run existe toujours, son
rapport de cadence servie -- le devoir #15332 -- est emis a chaque
passage, et c'est cette SURVIE de l'observateur qui est la garantie. Un passage sur
`every_n` paie la lecture complete (inventaire + jobs des runs) ; les
autres ne paient que la lecture de la cadence servie (1 appel).

La jambe `schedule` n'est PAS concernee : elle est la cadence de
REFERENCE de l'advisory, elle mesure toujours. Le gate ne s'applique
qu'aux runs `workflow_run` -- ce qui laisse intact l'advisory
`runner-starvation-advisory.yml` (coursia-lean), qui n'a pas de
heartbeat workflow_run et n'est donc jamais gate.

`every_n <= 1` desactive la porte : le defaut est volontairement
inerte, pour qu'un appelant tiers ne change pas de comportement sans
l'avoir demande.
"""
if every_n <= 1:
return True, "porte inactive (every_n<=1) : mesure complete"
if event_name != "workflow_run":
return True, (
f"jambe {event_name or 'inconnue'} : mesure complete "
"(cadence de reference, jamais gatee)"
)
if run_number is None:
return True, "numero de run inconnu : mesure complete"
if run_number % every_n == 0:
return True, f"run {run_number} : 1 sur {every_n} -> mesure complete"
return False, (
f"run {run_number} : mesure CADENCE SEULE (1 sur {every_n}) -- la "
"lecture complete (inventaire + jobs des runs) n'est pas payee"
)


def evaluate_cadence(
scheduled: list[datetime],
now: datetime,
Expand Down Expand Up @@ -884,51 +955,89 @@ def main() -> int:
default=int(os.environ.get("IN_PROGRESS_SAMPLE", DEFAULT_IN_PROGRESS_SAMPLE)),
help="nombre de runs in_progress inspectes (preuve qu'un "
"slot sert) -- les plus recents d'abord")
ap.add_argument("--measure-every-n-runs", type=int,
default=int(os.environ.get("MEASURE_EVERY_N_RUNS", "0")),
help="borne la mesure complete (#22937) : sur les runs "
"workflow_run, une mesure complete sur N passages ; "
"les autres ne paient que la lecture de la cadence "
"servie (1 appel). 0 ou 1 = porte inactive")
ap.add_argument("--json", action="store_true", help="sortie JSON du verdict")
args = ap.parse_args()

token = os.environ.get("RUNNERS_READ_PAT") or None
inv = fetch_inventory(args.repo, args.label, token)
st = fetch_starvation(
args.repo, args.label, args.starve_minutes,
window_max_minutes=args.window_max_minutes,
workflow=args.workflow or None,
job_read_budget=args.job_read_budget,
in_progress_sample=args.in_progress_sample,
)
v = evaluate(inv, st, args.warn_floor, label=args.label)
# La cadence servie est lue AVANT la porte de mesure : c'est elle qui
# porte le rapport #15332, et elle ne doit jamais dependre du passage
# que la porte economise. Le compteur `calls` est partage pour que le
# `gh_calls` annonce soit le cout REEL du passage (#22937).
calls = CallStats()
v = Verdict()
if args.self_workflow:
cad = evaluate_cadence(
fetch_scheduled_runs(args.repo, args.self_workflow),
fetch_scheduled_runs(args.repo, args.self_workflow, stats=calls),
datetime.now(timezone.utc),
args.declared_cadence_minutes,
args.cadence_warn_minutes,
)
v.notes.extend(cad.notes)
v.warnings.extend(cad.warnings)
v.errors.extend(cad.errors)
v.status = "ERROR" if v.errors else "OK"

run_number = _env_int("GITHUB_RUN_NUMBER")
event_name = os.environ.get("GITHUB_EVENT_NAME", "")
measure, gate_reason = gate_measurement(
every_n=args.measure_every_n_runs,
run_number=run_number,
event_name=event_name,
)
v.notes.append(f"porte de mesure: {gate_reason}")

inv: RunnerInventory | None = None
st: Starvation | None = None
if measure:
token = os.environ.get("RUNNERS_READ_PAT") or None
inv = fetch_inventory(args.repo, args.label, token)
st = fetch_starvation(
args.repo, args.label, args.starve_minutes,
window_max_minutes=args.window_max_minutes,
workflow=args.workflow or None,
job_read_budget=args.job_read_budget,
in_progress_sample=args.in_progress_sample,
)
full = evaluate(inv, st, args.warn_floor, label=args.label)
v.notes = full.notes + v.notes
v.warnings = full.warnings + v.warnings
v.errors = full.errors + v.errors
v.status = "ERROR" if v.errors else "OK"

payload = {
"status": v.status,
"errors": v.errors,
"warnings": v.warnings,
"notes": v.notes,
"online_runners": inv.online if inv.available else None,
"offline_runners": inv.offline if inv.available else None,
"starved_jobs": [r.__dict__ for r in st.starved],
"in_progress_jobs": len(st.in_progress),
"unexamined_runs": st.unexamined,
"gh_calls": st.stats.calls,
"measurement": "full" if measure else "cadence-only",
"gate": {
"every_n": args.measure_every_n_runs,
"run_number": run_number,
"event_name": event_name,
"reason": gate_reason,
},
"online_runners": inv.online if (inv is not None and inv.available) else None,
"offline_runners": inv.offline if (inv is not None and inv.available) else None,
"starved_jobs": [r.__dict__ for r in st.starved] if st is not None else [],
"in_progress_jobs": len(st.in_progress) if st is not None else None,
"unexamined_runs": st.unexamined if st is not None else None,
"gh_calls": calls.calls + (st.stats.calls if st is not None else 0),
"gh_failures": [
{"endpoint": ep, "returncode": code, "message": msg}
for ep, code, msg in calls.failures
] + ([
{"endpoint": ep, "returncode": code, "message": msg}
for ep, code, msg in st.stats.failures
],
"job_reads": st.job_reads,
"skipped_young_runs": st.skipped_young,
"budget_unread_runs": len(st.budget_unread),
"in_progress_unread_runs": st.in_progress_unread,
"listing_failed": sorted(st.listing_failed),
] if st is not None else []),
"job_reads": st.job_reads if st is not None else None,
"skipped_young_runs": st.skipped_young if st is not None else None,
"budget_unread_runs": len(st.budget_unread) if st is not None else None,
"in_progress_unread_runs": st.in_progress_unread if st is not None else None,
"listing_failed": sorted(st.listing_failed) if st is not None else [],
}
if args.json:
print(json.dumps(payload, ensure_ascii=False, indent=2))
Expand Down
Loading
Loading