Skip to content

CI runners ai-01 : un quota REST epuise arrete tout le pool, et des restes de tests bloquent son redemarrage #21527

Open

Description

@jsboige

Mesure du 2026-10-20 sur ai-01 (journal coursia-runner.service, heures en UTC). Le pool Linux ephemere d'ai-01 est reste a zero de 01:26Z a 03:30Z, avec 1 202 runs en file et 13 runners inscrits pour toute la flotte.

Ce qui s'est passe

Heure (UTC) Evenement
01:24 Quota REST du compte jsboige epuise (5000/5000 ; tout la flotte l'utilise). fetch_token recoit un HTTP 403 « API rate limit exceeded ».
01:26:32 Slot 13 : ABANDON : 5 echecs consecutifs HTTP 403 sur le compte '{' (compte sans droit admin ?). Le slot pose STOP_FILE et appelle die, si bien que tous les slots sortent.
01:36:33 Le superviseur s'arrete ; l'unite systemd est desactivee.
01:37-01:39 Six redemarrages refuses : sentinelle presente ET superviseur vivant -- arret en cours, on n'interfere pas. Puis Start request repeated too quickly, et l'unite reste en failed.
03:30 Reparation a la main : processus de test orphelins tues, reset-failed, start. La sentinelle perimee est purgee et le pool redemarre.

Les quatre defauts

1. Un 403 de quota est classe « structurel ». supervise.sh L1599-1602 : tout 4xx sauf 408 est terminal, et au cinquieme le slot arrete tout le pool (L1612-1613 ; meme logique pour les waiters, L2135). Or GitHub signale un quota epuise par un 403 (ou 429) avec X-RateLimit-Remaining: 0 : c'est transitoire, avec une echeance connue (X-RateLimit-Reset). Le retry le resout, a condition d'attendre jusqu'a la remise a zero au lieu d'abandonner.

2. Le compte s'affiche '{', et un corps JSON passe pour un jeton. Le log du slot 13 se termine par New-line characters are not allowed in header values : le corps d'erreur JSON a ete lu comme nom de compte (ACCOUNT={) puis comme jeton d'inscription. fetch_token doit valider la forme du jeton avant de le rendre, et ecrire ACCOUNT= depuis le compte epingle, jamais depuis la reponse.

3. Le predicat « superviseur vivant » est global a la machine. persist/ai-01/coursia-runner-start.sh L145 et persist/coursia-runner-start.sh L212 : pgrep -f 'supervise\.sh start' reconnait n'importe quel supervise.sh start de la machine. Il reconnaissait ici trois processus d'un autre worktree (/mnt/d/CoursIA-2-wt-baseline/.../supervise.sh start 1 --force), dont l'etat vivait sous /tmp/supervise-test-13007/state-C : des restes de test_supervise_guards.sh. Le predicat doit reconnaitre le superviseur de ce COURSIA_RUNNER_STATE_DIR, par exemple par un fichier de PID dans l'etat de production ou par la lecture de l'environnement du processus. Meme defaut dans coursia-waiters-start.sh L93 et coursia-lean-start.sh L145.

4. Les tests laissent des superviseurs vivants. Les orphelins avaient 6 a 11 h, avec /init pour parent et des enfants relances en boucle : start 1 --force du worktree baseline, waiters 1 de CoursIA-2-wt-21446 et de CoursIA-21474-base. test_supervise_guards.sh doit tuer tout ce qu'il lance (trap sur EXIT, groupe de processus), y compris quand la suite est interrompue.

Le couplage de fond

Les runners obtiennent leur jeton d'inscription avec le meme compte jsboige que toutes les lanes de la flotte. Une flotte qui consomme son quota REST arrete donc la CI : c'etait le cas ce matin (signale par l'adjoint vers 01:40Z). Deux voies a mesurer, sans les trancher ici : (a) un jeton d'inscription est valable une heure et reutilisable pour plusieurs inscriptions, le partager entre slots divise les appels ; (b) un compte ou une app dedies aux runners, ce qui touche les droits du depot et releve donc d'une decision du user.

Criteres de fermeture

  • Un 403 ou 429 de quota (X-RateLimit-Remaining: 0 ou corps « rate limit ») attend la remise a zero et ne pose jamais STOP_FILE ; test qui le verrouille.
  • fetch_token ne rend jamais un corps JSON comme jeton ; test.
  • Les trois scripts de demarrage ne voient que le superviseur de leur propre etat ; test avec un supervise.sh start leurre lance depuis un autre chemin.
  • test_supervise_guards.sh ne laisse aucun processus apres une interruption ; test.
  • La voie (a) est mesuree (appels REST par heure avant/apres).

See #20727, #15154, #15163.

Activity

  1. jsboige commented on Oct 20, 2026

    @jsboige
    OwnerAuthor

    [CLAIMED] lane myia-ai-01:CoursIA-2 — corriger les quatre defauts (403 de quota transitoire, jeton JSON, predicat de demarrage par etat, nettoyage des tests) ; claim pose au dispatch par le coordinateur myia-ai-01:CoursIA. paths: scripts/ci/docker/linux-runner/**

  2. added a commit that references this issue on Oct 20, 2026
  3. jsboige commented on Oct 20, 2026

    @jsboige
    OwnerAuthor

    [DELIVERED] lane myia-ai-01:CoursIA-2 -- PR #21541

    Quatre defauts fermes, voir le body. La suite de garde (Test 70 inclus) tournera sur la PR ; les FAIL isoles a minuteur sous run GPU 2 sont du bruit de charge (mesure c.2237), pas une regression.

    Le jeton d'inscription partage (option (a)) reste a mesurer — non tranche ici.

    Riders : les commits 34a5a10 + 3dd0b0238 sont encore sur la branche fix/20727-budget-reprise-famille (pousses avant le split) ; ils deviennent redondants quand #21541 merge — le coordinateur choisit : revert de la branche #21494 ou acceptation en rider.

  4. added 2 commits that reference this issue on Oct 20, 2026
  5. added a commit that references this issue on Oct 20, 2026
  6. added a commit that references this issue on Oct 20, 2026
  7. added a commit that references this issue on Oct 21, 2026
  8. jsboige commented on Oct 21, 2026

    @jsboige
    OwnerAuthor

    [CLOSURE PREFLIGHT]
    schema: 1
    lane: myia-po-2028:CoursIA-3
    issue: 21527
    verdict: KEEP
    acceptance:

    • Un 403 ou 429 de quota n'est plus classe structurel et ne pose jamais STOP_FILE -> PR fix(ci,#21527): quota REST transitoire + jeton valide + predicat par-checkout #21541 MERGED : fetch_token classe le quota en HTTP=RATE (corps « rate limit » + code 403/429), les boucles slot_loop et waiter_loop attendent X-RateLimit-Reset au lieu de bruler les essais, RATE n'entre pas dans les terminaux ; Test 70 verrouille la classification et la survie du slot_loop a quota persistant (scripts/ci/docker/linux-runner/supervise.sh, scripts/ci/docker/linux-runner/test_supervise_guards.sh)
    • fetch_token ne rend jamais un corps JSON comme jeton -> PR fix(ci,#21527): quota REST transitoire + jeton valide + predicat par-checkout #21541 MERGED : forme du jeton validee par grep -qxE [A-Za-z0-9]{20,} et compte epingle valide avant ecriture dans FETCH_TOKEN_STATE_FILE (scripts/ci/docker/linux-runner/supervise.sh)
    • Les trois scripts de demarrage ne voient que le superviseur de leur propre etat -> PR fix(ci,#21527): quota REST transitoire + jeton valide + predicat par-checkout #21541 MERGED : nouveau scripts/ci/docker/linux-runner/persist/supervisor_live_for.sh qui filtre la cmdline par le REPO_DIR du lanceur, plus le test a supervise.sh leurre ; scripts/ci/docker/linux-runner/persist/ai-01/coursia-runner-start.sh
    • test_supervise_guards.sh ne laisse aucun processus apres une interruption -> PR fix(ci,#21527): quota REST transitoire + jeton valide + predicat par-checkout #21541 MERGED : trap cleanup_launched sur EXIT, INT et TERM, qui tue le groupe de processus lance (scripts/ci/docker/linux-runner/test_supervise_guards.sh)
    • Voie (a) : mesure des appels REST par heure avant/apres du jeton d'inscription partage -> NON FAITE, le cinquieme critere de fermeture du body n'est pas tenu ; PR fix(ci,#21527): quota REST transitoire + jeton valide + predicat par-checkout #21541 (section « Hors scope (declare) ») ecrit « Le jeton d'inscription partage entre slots (option (a) du dispatch) : a mesurer, pas decide. A suivre sur l'issue » ; l'option (b), compte ou App dedie aux runners, releve d'une decision user non prise
      residue: waiver: les quatre defauts du body sont clos par la PR fix(ci,#21527): quota REST transitoire + jeton valide + predicat par-checkout #21541 (MERGED) ; il reste la voie (a), mesure des appels REST par heure avant/apres du jeton d'inscription partage entre slots, explicitement renvoyee a l'issue par la PR elle-meme (section « Hors scope (declare) »), et l'arbitrage user de l'option (b), compte ou App dedie aux runners, non pris ; dossier atteste au 2026-10-21, issue maintenue ouverte sur ce seul point
      open-prs: 0
      comments-reviewed: 2
      [/CLOSURE PREFLIGHT]
  9. jsboige commented on Oct 26, 2026

    @jsboige
    OwnerAuthor

    [INFO] lane myia-ai-01:CoursIA-2 — voie (a) mesurée : le pool consomme 155 appels REST par heure pour des jetons d'inscription ; un jeton partagé est faisable (expérimenté). Il reste l'implémentation + le déploiement.

    « Avant » — mesuré sur le pool d'ai-01

    Chaque cycle de conteneur demande un jeton d'inscription, et chaque jeton est un appel REST sur le quota du compte partagé par la flotte (5000/h) :

    journalctl -u coursia-runner.service --since=-24h | grep -cE "conteneur (termine sainement|echoue|abandonne)|cycle court AVEC travail|cycle long"
      -> 3732 cycles en 24 h  =>  155,5 inscriptions/h
    

    Répartition (24 h) : slot 13 → 379, slot 7 → 347, slot 2 → 340, slot 1 → 317, slot 8 → 279, … slot 10 → 163. Les 14 slots du pool d'ai-01 tournant en continu.

    Soit ~155 appels/h sur le quota de la flotte, soit 3,1 % des 5000/h — la part que ce pool contrôle, la cause directe du blocage du 2026-10-20 étant un quota à 5000/5000 partagé avec toutes les lanes.

    État du compte au moment de la mesure : core remaining = 3225 / 5000.

    (Note : fetch_token fait un seul appel depuis #21541 — l'ancienne version en faisait deux. Le facteur 2 est déjà corrigé.)

    « Faisabilité » — expérience décisive, et son résultat

    Question : un même jeton d'inscription peut-il enregistrer plusieurs runners ? Sans réponse, la voie (a) est morte.

    Protocole : un seul appel REST pour obtenir le jeton ; deux containers jetables de l'image du runner (coursia-linux-runner:2.338.0), chacun recevant ce même jeton ; dans chaque container, config.sh --unattended --ephemeral --replace sur une copie de /opt/runner (le .runner des slots vivants n'est pas touché) ; runners nommés coursia-token-probe-A/B avec le label coursia-token-probe, qu'aucun workflow ne réclame (ils ne peuvent donc pas recevoir un job) ; suppression par l'API des deux runners à la fin.

    Résultat :

    coursia-token-probe-A : √ Runner successfully added   √ Settings Saved.
    coursia-token-probe-B : √ Runner successfully added   √ Settings Saved.
    => un jeton partage enregistre plusieurs runners : OUI
    

    Nettoyage vérifié : DELETE …/actions/runners/420316 et …/420317 → HTTP 204, et GET …/actions/runners ne rend plus aucun runner coursia-token-probe.

    Validité du jeton, mesurée sur la réponse de l'API : expires_at = 2026-10-26T04:19:47Z pour une émission à 03:19 → 1 heure.

    « Après » — projection bornée par la mesure

    appels REST d'inscription / h
    aujourd'hui (un jeton par cycle de conteneur) 155,5 (mesuré)
    jeton partagé, renouvelé à 45 min de sa date d'émission 1 (borné par la validité mesurée de 1 h)

    Soit −154 appels/h, soit −99,4 % de la consommation d'inscription du pool, et ≈ 3 % du quota REST de la flotte rendu aux autres lanes. Le « 1 par heure » est une borne haute : un jeton qui reste valide plus longtemps (jusqu'à 60 min) ne coûte pas plus.

    Ce que la mesure ne dit pas : que le partage soit sans risque. Un jeton révoqué ou expiré plus tôt que prévu ferait échouer l'enregistrement de plusieurs slots d'un coup ; le remède est l'invalidation du jeton partagé au premier docker run non nul (le chemin d'échec existe déjà dans slot_loop), avec repli sur le comportement actuel.

    Ce qui reste, et à qui

    1. L'implémentation : un cache de jeton partagé ($STATE_DIR/registration_token, âge max 45 min, invalidé sur échec de cycle) + shared_registration_token() appelé par slot_loop au lieu de fetch_token. Repli : le comportement actuel, si le jeton partagé est absent ou trop vieux.
    2. Le déploiement : c'est le superviseur de l'hôte (/mnt/d/CoursIA/scripts/ci/docker/linux-runner/supervise.sh) qu'il faut modifier et redémarrer — 14 slots, donc une opération de flotte, à planifier côté coordinateur et non dans une session de nuit.
    3. L'option (b) (compte ou App dédiés aux runners) : décision user, pas une mesure. Elle reste ouverte, et c'est elle qui traiterait la cause de fond (le quota partagé entre toutes les lanes), la voie (a) ne faisant qu'amputer la part du pool.

    Je n'ai pas implémenté (1) dans ce passage : le bénéfice mesuré est de 3 % du quota, et le coût du déploiement est un redémarrage du pool — ce n'est pas un arbitrage que je prends seul à cette heure. Le dossier est prêt à être exécuté quand le coordinateur ouvre une fenêtre.

    Voir aussi #21541 (les quatre défauts clos), #15154 (épinglage du compte).

  10. jsboige commented on Oct 28, 2026

    @jsboige
    OwnerAuthor

    [DELIVERED] lane myia-ai-01:CoursIA-2 -- PR #23055 -- paths: scripts/ci/docker/linux-runner/persist/**, .github/workflows/assert-memory-budget.yml

    Le critere « les trois scripts de demarrage ne voient que le superviseur de leur propre etat ; test avec un supervise.sh start leurre lance depuis un autre chemin » n'etait pas tenu par main : #21541 avait cable l'organe sur une seule jambe (ai-01), et la forme du cable etait un exec direct d'un fichier 100644 dans l'index -- donc inerte sur un checkout Linux natif (Permission denied -> predicat faux -> purge a tort). Les six autres wrappers restaient sur le pgrep per-machine.

    PR #23055 cable les sept wrappers sur l'organe lu par bash, conserve un repli conservateur (organe illisible -> predicat per-machine), rend la racine de scan injectable, reecrit le harnais sur un /proc FAKE (avec stub pgrep) et y ajoute les cas leurre / organe-absent / organe-non-executable, puis cable ce harnais dans assert-memory-budget.yml -- il etait hors CI, donc une regression du predicat n'aurait rougi nulle part.

    Mesure : 66 PASS / 0 FAIL sur Git Bash ET sur WSL (Linux natif, superviseurs reels de l'hote en cours). Controles negatifs : predicat degrade en per-machine -> 8 FAIL ; wrapper revenu au pgrep global -> FAIL waiters/5 ; wrapper revenu a l'exec direct -> FAIL waiters/8.

    Residu de deploiement, a dire : les wrappers sont des copies deployees (/usr/local/bin/coursia-*.sh + unites systemd). Le predicat ne sera effectif sur ai-01, po-2024, po-2025, po-2027 et po-2028 qu'apres le merge et le redeploiement du wrapper sur chaque machine, avec un checkout a jour. Cette PR livre le code et son verrou ; elle ne redeploie rien.

    Etat des criteres de closure au 2026-10-28 :

  11. added 3 commits that reference this issue on Oct 28, 2026
  12. jsboige commented on Oct 30, 2026

    @jsboige
    OwnerAuthor

    [CLAIMED] lane myia-po-2028:CoursIA-2 -- critere 5 restant (voie (a) : jeton d'inscription partage entre slots, mesure avant/apres ; les criteres 1-4 sont livres sur main, verifies ce cycle) -- paths: scripts/ci/docker/linux-runner/supervise.sh, scripts/ci/docker/linux-runner/test_supervise_guards.sh -- 2026-10-30T13:25Z

    Verification firsthand au claim (c49) : c1 (403/429 quota transitoire + attente reset) sur supervise.sh:1749-1760 ; c2 (forme du compte l.1684-1689, forme du jeton l.1731, appel unique l.1726) ; c3 (PR #23055) ; c4 (trap cleanup_launched EXIT INT TERM + kill du groupe de processus, test_supervise_guards.sh:43-55). Temoin live du correctif c1 : le pool de po-2028 a traverse l'epuisement du quota REST de ce jour (fenetre ~13:1xZ) sans aucun ABANDON/STOP_FILE, 3 slots en travail reel. Reste c5 : partager le jeton (valable 1 h) entre slots/cycles, le verrouiller par test, et mesurer les appels REST/h apres deploiement (avant : 155/h, mesure ai-01 du 26/10).

  13. jsboige commented on Oct 30, 2026

    @jsboige
    OwnerAuthor

    [DELIVERED] lane myia-po-2028:CoursIA-2 -- PR #23529 -- paths: scripts/ci/docker/linux-runner/supervise.sh, scripts/ci/docker/linux-runner/test_supervise_guards.sh

    Critere 5 (voie (a)) livre : cache partage du jeton d inscription dans STATE_DIR (cle compte-effectif+repo, TTL 3300 s, ecriture atomique chmod 600, miss silencieuse sur cache corrompu). Borne mecanique : 155 appels/h -> <= 1,1/h, independante du nombre de demarrages. Mesure post-deploiement (lecture de FETCHED_AT du cache sur une heure, pool en service) relevee au prochain cycle sur le poste qui porte le pool. Suite test_supervise_guards.sh complete rejouee : 181 PASS / 0 FAIL, dont Test 73 (7 assertions) et l isolation du cache dans le Test 70. Les criteres 1-4 etaient deja sur main, verifies firsthand en c.6523533265.

  14. added a commit that references this issue on Oct 30, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions