Skip to content
Open
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
68 changes: 68 additions & 0 deletions scripts/ci/check_runner_starvation.py
Original file line number Diff line number Diff line change
Expand Up @@ -56,8 +56,10 @@
import argparse
import json
import os
import re
import subprocess
import sys
from collections import defaultdict
from dataclasses import dataclass, field
from datetime import datetime, timezone

Expand Down Expand Up @@ -429,10 +431,76 @@ def evaluate(inv: RunnerInventory, st: Starvation, warn_floor: int) -> Verdict:
else:
v.notes.append("ni queue affamee ni job en cours -- OK")

# Alertes de perte de pool par hote (#20697 point 4) -- disjointes de
# EXTINCTION globale : un hote peut perdre tous ses online pendant que le
# pool global reste vert (les autres hotes du label tournent). Cas
# fondateur : myia-ai-01-wsl 0 online / 10 offline pendant ~2 jours,
# EXTINCTION muette car d'autres hotes du label restaient online.
if inv.available:
v.warnings.extend(evaluate_host_loss(inv))

v.status = "ERROR" if v.errors else "OK"
return v


_HOST_SUFFIX = re.compile(r"-\d+$")


def _host_of(runner_name: str) -> str:
"""Nom de l'hote extrait du nom de slot (suffixe '-N' jete).

Le format canonique sur la flotte est `<host>-<slot>` (ex :
`myia-ai-01-wsl-1` -> host `myia-ai-01-wsl` ; `myia-po-2024-linux-docker-3`
-> host `myia-po-2024-linux-docker`). Les noms qui ne portent pas ce
suffixe (futur format) restent tels quels -- la detection par hote est
best-effort, le predicat EXTINCTION global reste la borne dure.
"""
return _HOST_SUFFIX.sub("", runner_name)


def evaluate_host_loss(inv: RunnerInventory) -> list[str]:
"""Warnings de perte de pool par hote (#20697 point 4).

Constat fondateur (12:45Z) : le pool `myia-ai-01-wsl` est reste 0 online
pendant ~2 jours alors que les 10 conteneurs tournaient (visible cote
superviseur, invisible cote GitHub). Aucun signal n'a nomme cette
disjonction -- l'organe EXTINCTION global ne rougit que si TOUS les
online tombent (les 10 slots etaient OFFLINE cote GitHub, mais d'autres
hotes du label restaient online ; l'extinction globale n'a jamais tire).

Le predicat par hote : `online == 0` ET `offline > 0` -- les conteneurs
existent (slots listes, ne serait-ce qu'en OFFLINE) mais aucun n'a pu
s'inscrire. C'est la disjonction superviseur mort / conteneurs vivants
sur UN hote, pas la famine globale.

Un hote avec `online > 0` et `offline > 0` n'est PAS une perte de pool :
certains slots sont en cours de redemarrage et le pool rend des jobs.
C'est la garde anti-FP, mesure du jour : `myia-po-2024-linux-docker-3`
est OFFLINE pendant que `myia-po-2024-linux-docker-1` est ONLINE, et
l'inventaire rend `online=1, offline=1` -- on NE DOIT PAS alerter.
"""
if not inv.available:
return []
online = set(inv.online)
offline = set(inv.offline)
by_host: dict[str, dict[str, int]] = defaultdict(lambda: {"online": 0, "offline": 0})
for name in online | offline:
host = _host_of(name)
bucket = by_host[host]
if name in online:
bucket["online"] += 1
else:
bucket["offline"] += 1
out: list[str] = []
for host, counts in sorted(by_host.items()):
if counts["online"] == 0 and counts["offline"] > 0:
out.append(
f"POOL LOSS: host {host} a 0 runner online et {counts['offline']} offline -- "
f"conteneurs listes mais aucun ne s'inscrit (superviseur mort ? #20697)"
)
return out


def main() -> int:
ap = argparse.ArgumentParser(description=__doc__.splitlines()[0])
ap.add_argument("--repo", default=os.environ.get("REPO", DEFAULT_REPO))
Expand Down
67 changes: 62 additions & 5 deletions scripts/ci/docker/linux-runner/entrypoint.sh
Original file line number Diff line number Diff line change
Expand Up @@ -100,12 +100,69 @@ done
for gitdir in "$ACTIONS_RUNNER_INPUT_WORK"/*/*/.git; do
[ -e "$gitdir" ] || continue
repo="${gitdir%/.git}"
while read -r ref sha; do
if ! git -C "$repo" cat-file -e "$sha^{object}" 2>/dev/null; then
echo "entrypoint: ref distante dangling $ref -- suppression (objet absent)"
git -C "$repo" update-ref -d "$ref" 2>/dev/null || true
# Borne du cout (fix #20697, myia-ai-01-wsl pool down 2 jours) : avec ~4 600
# refs distantes et ~21 000 packs par cache, l'ancienne boucle faisait un
# `cat-file -e` par ref -- chaque appel parcourt l'index de packs, cout
# O(refs * packs) = 23 min sur le slot mesure. La borne est ici O(refs + packs) :
# UN seul `cat-file --batch-check` pour toutes les SHAs de la liste, puis
# suppression des refs dont le SHA est dans la liste des `missing`. Le
# fd de la liste temporaire est ferme par redirection explicite pour eviter
# un `lsof` permanent sur le slot (le bloc tourne avant l'enregistrement).
_el_refs="$(mktemp -t el-refs.XXXXXX)" && _el_miss="$(mktemp -t el-miss.XXXXXX)" || {
# mktemp a echoue (saturation /tmp) : on retombe sur l'ancienne boucle pour
# ne pas empecher le slot de s'enregistrer, et on consigne.
# Le test d'existence d'un objet (cat-file -e) declenche le fetch paresseux
# en clone partiel (promisor=true, partialclonefilter=blob:none -- slot 3
# po-2024 en incidence) ; sans garde, git tente un fetch sans timeout et le
# 2>/dev/null masque tout. GIT_NO_LAZY_FETCH=1 (git >= 2.46, no-op avant)
# desactive le fetch, timeout 10 borne le pire cas. Le de-masquage de stderr
# permet de voir la cause d'un timeout (sinon le bloc est muet, et c'est
# exactement la panne slot 3).
echo "entrypoint: mktemp indisponible -- fallback boucle O(refs*packs)" >&2
while read -r ref sha; do
if ! GIT_NO_LAZY_FETCH=1 timeout 10 git -C "$repo" cat-file -e "$sha^{object}"; then
echo "entrypoint: ref distante dangling $ref -- suppression (objet absent ou timeout cat-file)"
git -C "$repo" update-ref -d "$ref" 2>/dev/null || true
fi
done < <(git -C "$repo" for-each-ref --format='%(refname) %(objectname)' refs/remotes 2>/dev/null)
continue
}
git -C "$repo" for-each-ref --format='%(refname) %(objectname)' refs/remotes 2>/dev/null > "$_el_refs" || true
if [ -s "$_el_refs" ]; then
# Meme garde que pour le fallback : cat-file --batch-check en clone partiel
# declenche un fetch paresseux par objet absent. GIT_NO_LAZY_FETCH=1
# (no-op sur git < 2.46) desactive le fetch ; timeout 60 borne le batch
# (4 600 refs mesure, batch process unique). Le 2>/dev/null historique
# est retire -- un timeout doit etre visible sur stderr (cf. incident slot 3
# po-2024 c.327).
awk '{print $NF}' "$_el_refs" | sort -u \
| GIT_NO_LAZY_FETCH=1 timeout 60 git -C "$repo" cat-file --batch-check \
| awk '$2 == "missing" { print $1 }' > "$_el_miss" || true
if [ -s "$_el_miss" ]; then
while read -r sha; do
awk -v s="$sha" '$NF == s { print $1 }' "$_el_refs" | while read -r ref; do
[ -n "$ref" ] || continue
echo "entrypoint: ref distante dangling $ref -- suppression (objet absent)"
git -C "$repo" update-ref -d "$ref" 2>/dev/null || true
done
done < "$_el_miss"
fi
done < <(git -C "$repo" for-each-ref --format='%(refname) %(objectname)' refs/remotes 2>/dev/null)
fi
rm -f "$_el_refs" "$_el_miss"

# Prune des refs pull/* (fix #20697 point 2) : chaque PR vue depose un
# `refs/remotes/{remote}/pull/N/{head,merge}` (fetch --refspec EXPLICITE de
# actions/checkout, jamais prune auto, mesure : ~4 600 refs dont la majorite
# obsolete apres merge/close). `update-ref --stdin` borne le cout a un
# processus par depot (au lieu d'un update-ref par ref) ; les refs seront
# recreees au prochain fetch si encore pertinentes.
_el_pull="$(mktemp -t el-pull.XXXXXX)" || continue
git -C "$repo" for-each-ref --format='delete %(refname)' refs/remotes 2>/dev/null \
| grep '^delete .*/pull/' > "$_el_pull" || true
if [ -s "$_el_pull" ]; then
git -C "$repo" update-ref --stdin < "$_el_pull" 2>/dev/null || true
fi
rm -f "$_el_pull"
done
# TEST-ENTRYPOINT-DANGLING-REFS-END

Expand Down
10 changes: 8 additions & 2 deletions scripts/ci/docker/linux-runner/persist/job-start-disarm.sh
Original file line number Diff line number Diff line change
Expand Up @@ -46,8 +46,14 @@ disarm_one() { # <chemin-du-clone>
rm -f "$gitdir/info/sparse-checkout" || true
fi
while read -r ref sha; do
if ! git -C "$repo" cat-file -e "$sha^{object}" 2>/dev/null; then
echo "job-start-disarm: ref distante dangling $ref -- suppression (objet absent)" >&2
# Meme garde que l'entrypoint (#20699 / c.327 slot 3 po-2024) :
# cat-file -e en clone partiel declenche un fetch paresseux sans timeout.
# GIT_NO_LAZY_FETCH=1 (git >= 2.46, no-op avant) desactive le fetch ;
# timeout 10 borne le pire cas. stderr de-masque pour qu'un timeout reste
# diagnosticable (la trace du slot 3 etait silencieuse a cause du
# 2>/dev/null).
if ! GIT_NO_LAZY_FETCH=1 timeout 10 git -C "$repo" cat-file -e "$sha^{object}"; then
echo "job-start-disarm: ref distante dangling $ref -- suppression (objet absent ou timeout cat-file)" >&2
git -C "$repo" update-ref -d "$ref" 2>/dev/null || true
fi
done < <(git -C "$repo" for-each-ref --format='%(refname) %(objectname)' refs/remotes 2>/dev/null)
Expand Down
72 changes: 72 additions & 0 deletions scripts/ci/docker/linux-runner/test_entrypoint_disarm.sh
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,78 @@ echo "1234567890123456789012345678901234567890" > "$REPO_POISONED/.git/refs/remo
else ko "T3 le clone a ete purge (historique absent)"; fi
)

# --- Test 4 : prune des refs pull/* (#20697 point 2) ------------------------
# Le fetch EXPLICITE de actions/checkout (refspec +<sha>:refs/remotes/pull/N/...)
# depose une ref `refs/remotes/<remote>/pull/N/{head,merge}` par PR vue. Ces
# refs ne sont JAMAIS prunees auto et s'accumulent (~4 600 mesure du jour).
# La borne `update-ref --stdin` doit retirer les pull/*, en preservant les
# autres refs (notamment main, qui passe par refs/remotes/<remote>/main --
# pas par pull/*).
REPO_PULL="$TEST_DIR/_work/jsboige/PullRepo"
make_fixture "$REPO_PULL"
head_sha="$(git -C "$REPO_PULL" rev-parse HEAD)"
mkdir -p "$REPO_PULL/.git/refs/remotes/origin/pull/12345"
echo "$head_sha" > "$REPO_PULL/.git/refs/remotes/origin/pull/12345/head"
echo "$head_sha" > "$REPO_PULL/.git/refs/remotes/origin/pull/12345/merge"
echo "$head_sha" > "$REPO_PULL/.git/refs/remotes/origin/main"

(
out="$(ACTIONS_RUNNER_INPUT_WORK="$TEST_DIR/_work" bash "$TEST_DIR/block.sh" 2>&1)"; rc=$?
if [ "$rc" -ne 0 ]; then ko "T4 le bloc doit sortir 0 (rc=$rc)"; else ok "T4 bloc exit 0"; fi
if git -C "$REPO_PULL" rev-parse --verify -q refs/remotes/origin/pull/12345/head >/dev/null; then
ko "T4 la ref pull/N/head doit etre supprimee"
else ok "T4 ref pull/N/head supprimee"; fi
if git -C "$REPO_PULL" rev-parse --verify -q refs/remotes/origin/pull/12345/merge >/dev/null; then
ko "T4 la ref pull/N/merge doit etre supprimee"
else ok "T4 ref pull/N/merge supprimee"; fi
if git -C "$REPO_PULL" rev-parse --verify -q refs/remotes/origin/main >/dev/null; then
ok "T4 ref main survit (n'est pas un pull/*)"
else ko "T4 ref main injustement supprimee"; fi
)

# --- Test 5 : borne sur fetch paresseux en clone partiel (#20699) -----------
# Le bloc entrypoint.sh doit terminer en temps borne meme quand cat-file -e
# declenche un fetch paresseux (clone partiel : promisor=true, partialclonefilter=
# blob:none -- c.327 slot 3 po-2024). On simule en positionnant extensions.
# partialClone + remote.origin.promisor + une URL de fetch invalide : git tente
# alors un fetch paresseux sur l'objet absent. Sans timeout, le fetch pend
# indefiniment ; avec `timeout 10` (et `GIT_NO_LAZY_FETCH=1` no-op sur git 2.43
# mais actif si l'image est bumpee), le bloc sort vite. Le 2>/dev/null
# historique a ete retire -- le de-masquage est aussi verifie ici (T5 attend
# la trace sur stderr, pas en silence).
REPO_PARTIAL="$TEST_DIR/_work/jsboige/PartialRepo"
make_fixture "$REPO_PARTIAL"
git -C "$REPO_PARTIAL" config --local extensions.partialClone origin
git -C "$REPO_PARTIAL" config --local remote.origin.promisor true
git -C "$REPO_PARTIAL" config --local remote.origin.url "file:///nonexistent-at-$(date +%s)"
echo "deadbeef00000000000000000000000000000000" > "$REPO_PARTIAL/.git/refs/remotes/origin/ghost"
(
start_ns=$(date +%s%N)
out="$(ACTIONS_RUNNER_INPUT_WORK="$TEST_DIR/_work" bash "$TEST_DIR/block.sh" 2>&1)"; rc=$?
end_ns=$(date +%s%N)
duration_s=$(( (end_ns - start_ns) / 1000000000 ))
echo "$out" >> "$LOG"
if [ "$rc" -ne 0 ]; then ko "T5 bloc doit sortir 0 (rc=$rc)"; else ok "T5 bloc exit 0"; fi
# Timeout 10s/SHA + 1 ref. Borne large (15s) pour absorptions CI bruyantes.
if [ "$duration_s" -le 15 ]; then
ok "T5 desarmement en ${duration_s}s (<15s, timeout effectif)"
else
ko "T5 desarmement en ${duration_s}s (>15s, timeout absent ou trop long)"
fi
if ! git -C "$REPO_PARTIAL" rev-parse --verify -q refs/remotes/origin/ghost >/dev/null; then
ok "T5 ref ghost supprimee (cat-file absent/timeout -> update-ref -d)"
else
ko "T5 ref ghost non supprimee"
fi
# De-masquage de stderr : un garde de sante reste diagnosticable (#16938).
# La trace de suppression apparait sur stderr (que l'on capture via 2>&1).
if echo "$out" | grep -q "ghost"; then
ok "T5 suppression journalisee (stderr de-masque)"
else
ko "T5 aucune trace de la suppression (stderr encore masque ?)"
fi
)

# --- Verdict agrege -----------------------------------------------------------
n_pass="$(grep -c '^PASS' "$RESULTS" || true)"
n_fail="$(grep -c '^FAIL' "$RESULTS" || true)"
Expand Down
15 changes: 14 additions & 1 deletion scripts/ci/docker/linux-runner/work_cache_health.sh
Original file line number Diff line number Diff line change
Expand Up @@ -206,10 +206,23 @@ wch_maintenance_pass() {
if [ "$before" -le "$threshold" ]; then
return 0
fi
if ! wch_git -C "$repo" repack -ad >/dev/null 2>&1; then
# Stderr du repack : avant #20697, il partait dans `>/dev/null 2>&1` et
# les 223 'repack ECHEC' du journal du slot 1 (myia-ai-01-wsl) restaient
# sans cause nommee. On capture stderr vers un tmpfile et on le CAT-a-la-ligne
# sur le journal du slot. Le rc du repack reste 0 en sortie -- un garde de
# sante ne doit jamais etre la raison pour laquelle un slot meurt.
_wch_rep_err="$(mktemp -t wch-rep.XXXXXX 2>/dev/null)" || _wch_rep_err=""
if ! wch_git -C "$repo" repack -ad >/dev/null 2>"${_wch_rep_err:-/dev/null}"; then
echo "work_cache: repack ECHEC dans $repo ($before packs) -- cache conserve tel quel, nouvelle tentative au prochain passage" >&2
if [ -n "$_wch_rep_err" ] && [ -s "$_wch_rep_err" ]; then
while IFS= read -r line; do
echo "work_cache: repack stderr: $line" >&2
done < "$_wch_rep_err"
fi
[ -n "$_wch_rep_err" ] && rm -f "$_wch_rep_err"
return 0
fi
[ -n "$_wch_rep_err" ] && rm -f "$_wch_rep_err"
after="$(wch_pack_count "$repo")"
echo "work_cache: $repo repack $before -> $after packs (seuil $threshold)"
return 0
Expand Down
Loading
Loading