diff --git a/src/controllers/__tests__/correctionController.test.ts b/src/controllers/__tests__/correctionController.test.ts index a1e8b95..a931307 100644 --- a/src/controllers/__tests__/correctionController.test.ts +++ b/src/controllers/__tests__/correctionController.test.ts @@ -138,7 +138,9 @@ describe('correctionController.correctEE — Sprint 3.6a', () => { expect(result.data.score).toBe(14) } - // La persistance de la correction inclut les nouveaux champs + statuts pending + // La persistance de la correction inclut les nouveaux champs. + // Les statuts ne sont PAS dans l'update principal (race condition — les + // jobs async les pilotent exclusivement). DEFAULT 'pending' côté migration. const persisted = supabaseMock.updates.find( (u) => u.table === 'productions' && u.data.score !== undefined, ) @@ -147,13 +149,14 @@ describe('correctionController.correctEE — Sprint 3.6a', () => { score: 14, nclc: 9, nclc_cible: 9, - exercices_status: 'pending', - modele_status: 'pending', }) expect(persisted!.data.revelation).toBeDefined() expect(persisted!.data.diagnostic).toBeDefined() expect(persisted!.data.conseil_nclc).toBeDefined() expect(persisted!.data.erreurs_codes).toBeDefined() + // Race condition : ces champs ne doivent PAS être touchés par l'update principal. + expect(persisted!.data.modele_status).toBeUndefined() + expect(persisted!.data.exercices_status).toBeUndefined() }) it('modele_status passe à "ready" quand le job réussit', async () => { diff --git a/src/controllers/correctionController.ts b/src/controllers/correctionController.ts index ae0bfc0..37d3482 100644 --- a/src/controllers/correctionController.ts +++ b/src/controllers/correctionController.ts @@ -137,7 +137,13 @@ export async function correctEE( } } - // 4. Persister la correction (statuts modèle/exercices : pending — les jobs sont lancés juste après) + // 4. Persister la correction. + // ⚠️ RACE CONDITION — ne PAS inclure `modele_status` ni `exercices_status` + // ici : runModeleJob (lancé en parallèle option b) peut avoir déjà terminé + // et écrit 'ready' avant que cet update ne s'exécute. Écraser avec 'pending' + // perdrait le résultat. + // Les colonnes *_status sont initialisées à 'pending' par la migration + // (DEFAULT) et gérées exclusivement par runModeleJob / runExercicesJob. const { error: updateError } = await supabase .from('productions') .update({ @@ -149,8 +155,6 @@ export async function correctEE( conseil_nclc: rapport.conseil_nclc, erreurs_codes: rapport.erreurs_codes, rapport: JSON.stringify(rapport), - exercices_status: 'pending', - modele_status: 'pending', }) .eq('id', simulationId) @@ -190,17 +194,35 @@ interface ModeleJobInput { async function runModeleJob(input: ModeleJobInput): Promise { const { simulationId, tache, sujet, texte, nclcObtenu } = input + console.log('[runModeleJob] START', { simulationId, tache, nclcObtenu }) try { const modele = await generateProductionModele({ tache, sujet, texte, nclcObtenu }) - await supabase + console.log('[runModeleJob] DeepSeek OK, updating productions', { + simulationId, + modeleWordCount: modele.tcf_word_count, + }) + const { error: updateErr, data: updateData } = await supabase .from('productions') .update({ modele, modele_status: 'ready' }) .eq('id', simulationId) - } catch { - await supabase - .from('productions') - .update({ modele_status: 'error' }) - .eq('id', simulationId) + .select('id, modele_status') + console.log('[runModeleJob] update result', { simulationId, updateErr, updateData }) + } catch (err) { + const message = err instanceof Error ? err.message : String(err) + const stack = err instanceof Error ? err.stack : undefined + console.error('[runModeleJob] CAUGHT ERROR', { simulationId, message, stack }) + try { + const { error: fallbackErr } = await supabase + .from('productions') + .update({ modele_status: 'error' }) + .eq('id', simulationId) + console.log('[runModeleJob] fallback update result', { simulationId, fallbackErr }) + } catch (fallbackExc) { + console.error('[runModeleJob] FALLBACK UPDATE THREW', { + simulationId, + message: fallbackExc instanceof Error ? fallbackExc.message : String(fallbackExc), + }) + } } } @@ -212,21 +234,43 @@ interface ExercicesJobInput { async function runExercicesJob(input: ExercicesJobInput): Promise { const { simulationId, tache, rapport } = input + console.log('[runExercicesJob] START', { + simulationId, + tache, + erreursCodesCount: rapport.erreurs_codes.length, + }) try { const exercices = await generateExercices({ tache, erreursCodes: rapport.erreurs_codes, criteres: rapport.criteres, }) - await supabase + console.log('[runExercicesJob] DeepSeek OK, updating productions', { + simulationId, + exercicesCount: exercices.length, + }) + const { error: updateErr, data: updateData } = await supabase .from('productions') .update({ exercices, exercices_status: 'ready' }) .eq('id', simulationId) - } catch { - await supabase - .from('productions') - .update({ exercices_status: 'error' }) - .eq('id', simulationId) + .select('id, exercices_status') + console.log('[runExercicesJob] update result', { simulationId, updateErr, updateData }) + } catch (err) { + const message = err instanceof Error ? err.message : String(err) + const stack = err instanceof Error ? err.stack : undefined + console.error('[runExercicesJob] CAUGHT ERROR', { simulationId, message, stack }) + try { + const { error: fallbackErr } = await supabase + .from('productions') + .update({ exercices_status: 'error' }) + .eq('id', simulationId) + console.log('[runExercicesJob] fallback update result', { simulationId, fallbackErr }) + } catch (fallbackExc) { + console.error('[runExercicesJob] FALLBACK UPDATE THREW', { + simulationId, + message: fallbackExc instanceof Error ? fallbackExc.message : String(fallbackExc), + }) + } } }