Skip to content

Commit b0bd85b

Browse files
committed
run-error-handling: job d'exécution sérialisé complet + erreur GPU affichée
- ROOT CAUSE : expire_on_commit vidait le __dict__ de l'instance Job après commit() → execute renvoyait {} → frontend poll GET /api/jobs/undefined en boucle (422) + faux succès (le job.error 'Location GPU…' était perdu) - Backend : session.refresh(job) avant les 3 return de execute_pipeline — prouvé en réel : execute → 200 {id, status:error, error FR} (avant : {}) - Frontend : onRun n'affiche plus de succès sur job invalide ou déjà error — message job.error immédiat + failRun, pas de polling ; pollJob arrête le polling sur erreur 4xx permanente (hors 429) - Test de régression : execute gpu (launch_instance mocké dummy) retourne id + status error — 96 passed (7 pré-existants /api/blocks*) - Smoke navigateur réel (backend gpu, rent refusé) : console affiche 'Exécution en erreur : Location GPU…', 0 undefined dans les logs
1 parent f34a5c1 commit b0bd85b

8 files changed

Lines changed: 164 additions & 2 deletions

File tree

backend/mlblock/server/routes.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -456,6 +456,7 @@ def execute_pipeline(
456456
job.completed_at = datetime.now(timezone.utc)
457457
session.add(job)
458458
session.commit()
459+
session.refresh(job) # expire_on_commit vide le __dict__ — sinon la réponse est {}
459460
return job
460461
job.status = "dispatched"
461462
session.add(job)
@@ -469,6 +470,7 @@ def execute_pipeline(
469470
instance_id = job.vast_instance_id
470471

471472
if _is_mock_vast():
473+
session.refresh(job) # expire_on_commit vide le __dict__ — sinon la réponse est {}
472474
return job
473475

474476
gpu_timeout = int(os.environ.get("MLBLOCK_GPU_TIMEOUT", "1800")) # 30 min par défaut
@@ -489,6 +491,7 @@ def _timeout_cleanup():
489491

490492
threading.Timer(gpu_timeout, _timeout_cleanup).start()
491493

494+
session.refresh(job) # expire_on_commit vide le __dict__ — sinon la réponse est {}
492495
return job
493496

494497

backend/mlblock/tests/test_server.py

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -542,3 +542,29 @@ def test_generated_code_instance_auth_and_self_destroy():
542542
main_body = code.split("def main():")[1]
543543
assert "finally:" in main_body
544544
assert "_self_destroy()" in main_body
545+
546+
547+
def test_execute_gpu_rent_failure_returns_full_job(client: TestClient, monkeypatch):
548+
"""Régression expire_on_commit : le rent GPU échoué retourne un job
549+
sérialisé COMPLET (id + status error), pas un objet vide {}."""
550+
class FakeVast:
551+
def __init__(self, api_key): pass
552+
def launch_instance(self, *a, **kw):
553+
return {"id": "dummy-instance-id", "api_key": ""}
554+
def start_instance(self, *a, **kw): pass
555+
def destroy_instance(self, *a, **kw): pass
556+
557+
monkeypatch.setenv("MLBLOCK_RUN_MODE", "gpu")
558+
monkeypatch.setenv("VAST_API_KEY", "real-key-for-test")
559+
monkeypatch.setenv("MLBLOCK_GPU_TIMEOUT", "1")
560+
monkeypatch.setattr("mlblock.server.routes.VastAI", FakeVast)
561+
562+
created = client.post(
563+
"/api/pipelines", json={"name": "gpu-fail", "nodes": [], "edges": []}
564+
).json()
565+
resp = client.post(f"/api/pipelines/{created['id']}/execute")
566+
assert resp.status_code == 200
567+
job = resp.json()
568+
assert job.get("id"), f"job.id manquant dans la réponse : {job}"
569+
assert job["status"] == "error"
570+
assert "Location GPU" in job["error"]

frontend/src/hooks/useBlockRunner.ts

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,8 +26,14 @@ function pollJob(jobId: string): void {
2626
} else if (tries > 40) {
2727
clearInterval(timer)
2828
}
29-
} catch {
30-
/* réseau : on continue de poller */
29+
} catch (err) {
30+
// 4xx (hors 429) : erreur permanente (job invalide/inexistant) — jamais
31+
// résolue, on arrête. Réseau/5xx (backend Render en réveil) : on continue.
32+
const status = (err as { response?: { status?: number } } | undefined)?.response?.status
33+
if (status && status >= 400 && status < 500 && status !== 429) {
34+
clearInterval(timer)
35+
useAppStore.getState().appendConsoleLines([{ k: 'sys', t: 'Statut du job indisponible — suivi arrêté.' }])
36+
}
3137
}
3238
}, 3000)
3339
}
@@ -84,7 +90,19 @@ export function useBlockRunner() {
8490
// remontent via callbacks vers le job.
8591
try {
8692
const job = await executePipeline(pipelineId)
93+
if (!job?.id) {
94+
// Job non sérialisé (régression) : ne pas poller un id fantôme
95+
useAppStore.getState().appendConsoleLines([{ k: 'sys', t: 'Exécution lancée sans identifiant de job — statut indisponible.' }])
96+
useAppStore.getState().failRun()
97+
return
98+
}
8799
useAppStore.getState().setLastJob(job)
100+
if (job.status === 'error') {
101+
// Échec immédiat (ex. location GPU refusée) : message backend, pas de polling
102+
useAppStore.getState().appendConsoleLines([{ k: 'sys', t: `Exécution en erreur : ${job.error || 'inconnue'}` }])
103+
useAppStore.getState().failRun()
104+
return
105+
}
88106
pollJob(job.id)
89107
} catch {
90108
useAppStore.getState().appendConsoleLines([{ k: 'sys', t: "Échec du lancement de l'exécution." }])
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
schema: spec-driven
2+
created: 2026-08-12
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
## Context
2+
3+
La route `execute_pipeline` (routes.py) crée le job, génère le code, tente la location Vast (ou lance le subprocess local), puis `session.commit()` et `return job`. SQLAlchemy/SQLModel a `expire_on_commit=True` par défaut : après `commit()`, le `__dict__` de l'instance est vidé et les attributs ne sont rechargés que lors d'un accès (lazy) ou d'un `refresh()`.
4+
5+
Reproduction prouvée : mode gpu + rent refusé (400 insufficient_credit) → `execute` répond `200 {}` (0 champ) alors que `get_job` (session.get) répond `200` avec les 11 champs. `session.refresh(job)` avant sérialisation → 11 champs. La branche locale « survivait » par accident : l'accès `job.id` après le commit (ligne `job_id = job.id`) rechargeait l'instance avant le return.
6+
7+
Côté frontend : `useBlockRunner.onRun` appelle `pollJob(job.id)` sans vérifier `job.id` ni `job.status` → avec un job vide, `pollJob("undefined")` boucle sur `GET /api/jobs/undefined → 422` (le catch continue indéfiniment, seule la limite `tries > 40` arrête après 120 s), et `finishRun(build)` est appelé juste après → faux succès.
8+
9+
## Goals / Non-Goals
10+
11+
**Goals:**
12+
- `execute` retourne toujours un job sérialisé complet (id, status, error).
13+
- Le frontend affiche l'erreur du job (ex. « Location GPU Vast.ai impossible… ») immédiatement, sans polling ni faux succès.
14+
- Le polling s'arrête sur les erreurs permanentes (4xx).
15+
16+
**Non-Goals:**
17+
- Changer le comportement de `get_session` globalement (`expire_on_commit=False`) — impact large, non nécessaire.
18+
- Modifier le message d'erreur backend existant (déjà en français et pertinent).
19+
- Gérer les erreurs GPU côté Vast (location) — hors périmètre, le backend les traduit déjà en job error.
20+
21+
## Decisions
22+
23+
### D1 — `session.refresh(job)` avant chaque return de `execute_pipeline`
24+
Les 3 retours (branche local, dummy gpu, dispatched gpu) rechargent l'instance depuis la DB avant le `return job` → la sérialisation FastAPI est complète.
25+
*Alternatives* : `expire_on_commit=False` sur la session (impact global, risque de lectures stale) ; retourner un dict construit manuellement (duplique le schéma). Refresh : localisé, standard.
26+
27+
### D2 — Frontend : pas de polling sur job invalide ou déjà en erreur
28+
Dans `onRun`, après `executePipeline` :
29+
- `!job?.id` → console « statut de l'exécution indisponible » + `failRun`, return (aucun polling).
30+
- `job.status === 'error'` → console « Exécution en erreur : {job.error} » + `failRun`, return (le cas budget — affiché sans attendre le polling).
31+
- Sinon → `setLastJob(job)` + `pollJob(job.id)`.
32+
`failRun` met l'état d'échec global (running=false) — cohérent avec les autres chemins d'erreur.
33+
34+
### D3 — `pollJob` : arrêt net sur 4xx
35+
Dans le `catch` de `pollJob` : si `err.response?.status` est un 4xx hors 429 → `clearInterval` + console « job introuvable/statut indisponible » (erreur permanente, jamais résolue). Réseau/5xx → comportement actuel (continuer, limite `tries > 40`).
36+
37+
### D4 — Test de régression backend
38+
Test unitaire : `execute_pipeline` (mode gpu mocké — `launch_instance` force dummy) retourne un JSON avec `id` non vide et `status` (error). Le mock remplace `VastAI.launch_instance` pour éviter tout appel réseau réel.
39+
40+
## Risks / Trade-offs
41+
42+
- **Refresh = requête DB supplémentaire** par exécution — négligeable (1 SELECT par run).
43+
- **Job déjà en erreur au retour** : le frontend n'appelle plus jamais `getJobOutputs` pour ce job (l'erreur de location n'a pas de sorties) — comportement souhaité.
44+
- **4xx dans pollJob** : un 401/403 (clé expirée) arrêterait aussi le polling — acceptable (erreur permanente, pas de résolution sans action utilisateur).
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
## Why
2+
3+
Quand une exécution GPU échoue (ex. pas de budget Vast.ai), le backend met le job en `error` avec un message clair, mais **le frontend reçoit un job vide** : la route `execute` sérialise l'instance SQLModel après `commit()`, or SQLAlchemy l'expire (expire_on_commit) → réponse `{}` → le frontend poll `GET /api/jobs/undefined` en boucle (422) et affiche un faux succès (« Build réussi ») au lieu de l'erreur réelle. Root cause reproduite en local : `execute → 200 {}` vs `get_job → 200 {11 champs}` ; `refresh` avant sérialisation restaure les 11 champs.
4+
5+
## What Changes
6+
7+
- **Backend** : `session.refresh(job)` avant chacun des 3 `return job` de `execute_pipeline` — l'instance retournée est complète (id, status, error…). Les autres routes sont déjà saines (create/update font refresh).
8+
- **Frontend** : `useBlockRunner` — après `executePipeline`, ne pas lancer le polling sur un job invalide (`!job?.id`) ni sur un job déjà en erreur (`status === 'error'`) ; afficher l'erreur du job immédiatement (console + état échec). `pollJob` s'arrête net sur erreur 4xx (hors 429) — un 422 ne se résoudra jamais ; les erreurs réseau/5xx continuent (wake-up Render) avec la limite existante.
9+
- **Test de régression** : `execute` retourne un job sérialisé complet (id présent) en mode gpu avec rent en échec (mocké).
10+
11+
## Capabilities
12+
13+
### New Capabilities
14+
- `run-error-handling`: gestion des erreurs d'exécution de pipeline côté API (sérialisation du job) et frontend (affichage immédiat de l'erreur, arrêt du polling sur job invalide).
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
## ADDED Requirements
2+
3+
### Requirement: Exécution retourne un job sérialisé complet
4+
Le backend SHALL retourner le job complet (id, status, error, vast_instance_id…) dans la réponse de `POST /api/pipelines/{id}/execute`, y compris quand l'exécution échoue (ex. location GPU refusée).
5+
6+
#### Scenario: Échec de la location GPU
7+
- **WHEN** le rent Vast échoue (crédit insuffisant, offre introuvable) et que le job passe en `error`
8+
- **THEN** la réponse de execute contient `id` (UUID non vide), `status: "error"` et le message d'erreur français
9+
10+
#### Scenario: Exécution locale
11+
- **WHEN** le pipeline s'exécute en mode local
12+
- **THEN** la réponse de execute contient l'`id` du job et `status` (queued/dispatched)
13+
14+
### Requirement: Le frontend affiche l'erreur d'exécution immédiatement
15+
Le frontend SHALL afficher l'erreur du job (message `job.error`) dans la console et passer en état d'échec sans lancer le polling, quand le job retourné par execute est déjà en `error` ou n'a pas d'`id` valide.
16+
17+
#### Scenario: Job en erreur dès le retour
18+
- **WHEN** `executePipeline` retourne un job avec `status: "error"` (ex. location GPU refusée)
19+
- **THEN** la console affiche « Exécution en erreur : {job.error} » et l'UI passe en état d'échec, sans polling
20+
21+
#### Scenario: Job sans identifiant
22+
- **WHEN** `executePipeline` retourne un objet sans `id` valide
23+
- **THEN** la console indique que le statut est indisponible et l'UI passe en état d'échec, sans polling
24+
25+
#### Scenario: Job en cours
26+
- **WHEN** `executePipeline` retourne un job valide non terminé
27+
- **THEN** le polling du job démarre normalement
28+
29+
### Requirement: Le polling s'arrête sur erreur permanente
30+
Le polling de job (`pollJob`) SHALL s'arrêter sur une erreur HTTP 4xx (hors 429) — un job invalide ou inexistant ne se résoudra jamais — et SHALL continuer sur les erreurs réseau/5xx (reprise du backend) jusqu'à la limite existante.
31+
32+
#### Scenario: Job invalide (422)
33+
- **WHEN** le polling reçoit une réponse 4xx (hors 429) pour le job
34+
- **THEN** le polling s'arrête et un message de statut indisponible est affiché
35+
36+
#### Scenario: Backend en veille (5xx/réseau)
37+
- **WHEN** le polling reçoit une erreur réseau ou 5xx (backend Render en réveil)
38+
- **THEN** le polling continue jusqu'à la limite de tentatives existante
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
## 1. Backend
2+
3+
- [x] 1.1 `routes.py` execute_pipeline : `session.refresh(job)` avant les 3 `return job` (branche locale, dummy gpu, dispatched gpu)
4+
- [x] 1.2 Test de régression : `execute` (mode gpu, `launch_instance` mocké → dummy) retourne un JSON avec `id` non vide + `status` (error) — dans test_server.py
5+
6+
## 2. Frontend
7+
8+
- [x] 2.1 `useBlockRunner.onRun` : après `executePipeline``!job?.id` → console « statut indisponible » + failRun ; `job.status === 'error'` → console « Exécution en erreur : {job.error} » + failRun ; sinon setLastJob + pollJob
9+
- [x] 2.2 `pollJob` : dans le catch — arrêt net + message si `err.response?.status` est 4xx hors 429 ; sinon continuer
10+
- [x] 2.3 Build frontend : `npm run build` OK
11+
12+
## 3. Validation
13+
14+
- [x] 3.1 Backend : reproduction locale (mode gpu, clé réelle, rent refusé) — `execute` répond avec `id` + `status: error` + message FR
15+
- [x] 3.2 Suite pytest : 92 + nouveaux, 7 pré-existants `/api/blocks*`
16+
- [x] 3.3 Smoke navigateur : run sur Render → erreur de location affichée dans la console (pas de faux succès, pas de boucle 422)
17+
- [x] 3.4 Commit + push dev/chedli + fast-forward main

0 commit comments

Comments
 (0)