IA éditeur : appel asynchrone via worker — ne plus bloquer PHP-FPM #101

Open
opened 2026-05-16 14:37:35 +00:00 by cedricAbonnel · 0 comments
Owner

Problème

Actuellement, cliquer sur « Analyser et proposer » dans l'éditeur déclenche :

fetch POST /?action=ai_query
  → PHP-FPM worker bloqué 10–90 s (cURL vers api.anthropic.com ou proc_open claude)
  → JSON retourné

Un seul appel IA monopolise un worker PHP-FPM pendant toute la durée de l'appel API. Sur un pool de taille modeste (8–16 workers), une poignée d'appels simultanés suffit à rendre le site indisponible pour les autres visiteurs. Le timeout est actuellement fixé à 90 s (AiService.php).


Solution : pattern enqueue + worker + poll

fetch POST /?action=ai_enqueue   → job_id retourné immédiatement (< 1 ms)
PHP spawne un worker détaché    → php scripts/ai_worker.php {job_id} &
                                   worker appelle AiService, écrit le résultat en DB
fetch GET  /?action=ai_poll      → { status, critique, rewrite } dès que done

Le worker tourne en dehors du cycle requête/réponse HTTP. PHP-FPM est libéré dès le retour du job_id.


Infrastructure existante à réutiliser

Élément Emplacement
PDO (PostgreSQL) src/Infrastructure/Database.phpDatabase::get()
Pattern queue DB database/migration_015_mail_queue.sql + src/Service/MailQueue.php
Pattern SKIP LOCKED Voir MailQueue::reserveBatch()
Numéro de migration suivant migration_017_ai_jobs.sql
AiService src/Service/AiService.php

1. Migration database/migration_017_ai_jobs.sql

CREATE TABLE IF NOT EXISTS ai_jobs (
    id          UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    status      TEXT NOT NULL DEFAULT 'pending'
                CHECK (status IN ('pending', 'running', 'done', 'error')),
    action      TEXT NOT NULL,
    title       TEXT NOT NULL DEFAULT '',
    content     TEXT NOT NULL,
    result      JSONB,
    error_msg   TEXT,
    created_at  TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    started_at  TIMESTAMPTZ,
    finished_at TIMESTAMPTZ,
    expires_at  TIMESTAMPTZ NOT NULL DEFAULT NOW() + INTERVAL '1 hour'
);

CREATE INDEX IF NOT EXISTS idx_ai_jobs_pending
    ON ai_jobs (created_at ASC)
    WHERE status = 'pending';

CREATE INDEX IF NOT EXISTS idx_ai_jobs_expires
    ON ai_jobs (expires_at)
    WHERE status IN ('done', 'error');

Le champ expires_at (1 h) permet un nettoyage périodique des résultats lus. content peut être long (jusqu'à 8 000 caractères) — utiliser TEXT sans limite.


2. Worker CLI scripts/ai_worker.php

Script PHP autonome, appelé avec l'UUID du job :

<?php
// scripts/ai_worker.php {job_id}
define('BASE_PATH', dirname(__DIR__));
require_once BASE_PATH . '/vendor/autoload.php';
require_once BASE_PATH . '/config/config.php';

$jobId = $argv[1] ?? '';
if (!preg_match('/^[0-9a-f-]{36}$/', $jobId)) exit(1);

$pdo = App\Infrastructure\Database::get();

// Claim atomique (évite les doublons si deux workers démarrent)
$pdo->beginTransaction();
$stmt = $pdo->prepare(
    "SELECT id, action, title, content FROM ai_jobs
     WHERE id = ? AND status = 'pending'
     FOR UPDATE SKIP LOCKED"
);
$stmt->execute([$jobId]);
$job = $stmt->fetch();
if (!$job) { $pdo->rollBack(); exit(0); } // déjà pris ou inexistant

$pdo->prepare("UPDATE ai_jobs SET status='running', started_at=NOW() WHERE id=?")
    ->execute([$jobId]);
$pdo->commit();

// Appel IA
require_once BASE_PATH . '/src/SiteSettings.php';
require_once BASE_PATH . '/src/Service/AiService.php';
$result = (new AiService())->query($job['action'], $job['title'], $job['content']);

// Stocker le résultat
if ($result['ok'] ?? false) {
    $pdo->prepare(
        "UPDATE ai_jobs SET status='done', result=?::jsonb, finished_at=NOW() WHERE id=?"
    )->execute([json_encode($result), $jobId]);
} else {
    $pdo->prepare(
        "UPDATE ai_jobs SET status='error', error_msg=?, finished_at=NOW() WHERE id=?"
    )->execute([$result['error'] ?? 'Erreur inconnue', $jobId]);
}

3. Routes dans public/index.php

POST /?action=ai_enqueue

Remplace ai_query. Insère le job en DB et spawne le worker détaché :

case 'ai_enqueue':
    requireAuth();
    header('Content-Type: application/json');
    if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
        echo json_encode(['ok' => false, 'error' => 'Méthode invalide']); exit;
    }
    $_aiAction  = trim($_POST['action']  ?? '');
    $_aiTitle   = trim($_POST['title']   ?? '');
    $_aiContent = str_replace("\r\n", "\n", trim($_POST['content'] ?? ''));
    if (!in_array($_aiAction, ['analyze'], true) || $_aiContent === '') {
        echo json_encode(['ok' => false, 'error' => 'Paramètres invalides']); exit;
    }

    // Insérer le job
    $pdo = db();
    $stmt = $pdo->prepare(
        "INSERT INTO ai_jobs (action, title, content)
         VALUES (?, ?, ?) RETURNING id"
    );
    $stmt->execute([$_aiAction, $_aiTitle, $_aiContent]);
    $jobId = $stmt->fetchColumn();

    // Spawner le worker détaché (ne bloque pas)
    $php = PHP_BINARY;
    $script = BASE_PATH . '/scripts/ai_worker.php';
    $cmd = escapeshellcmd($php) . ' ' . escapeshellarg($script)
         . ' ' . escapeshellarg($jobId) . ' > /dev/null 2>&1 &';
    shell_exec($cmd);

    echo json_encode(['ok' => true, 'job_id' => $jobId]);
    exit;

GET /?action=ai_poll&job_id=xxx

Retourne le statut courant :

case 'ai_poll':
    requireAuth();
    header('Content-Type: application/json');
    $jobId = trim($_GET['job_id'] ?? '');
    if (!preg_match('/^[0-9a-f-]{36}$/', $jobId)) {
        echo json_encode(['ok' => false, 'error' => 'job_id invalide']); exit;
    }
    $stmt = db()->prepare(
        "SELECT status, result, error_msg FROM ai_jobs WHERE id = ?"
    );
    $stmt->execute([$jobId]);
    $job = $stmt->fetch();
    if (!$job) {
        echo json_encode(['ok' => false, 'error' => 'Job introuvable']); exit;
    }
    $out = ['ok' => true, 'status' => $job['status']];
    if ($job['status'] === 'done') {
        $result = json_decode($job['result'] ?? '{}', true);
        $out['critique'] = $result['critique'] ?? '';
        $out['rewrite']  = $result['rewrite']  ?? '';
    } elseif ($job['status'] === 'error') {
        $out['error'] = $job['error_msg'] ?? 'Erreur inconnue';
    }
    echo json_encode($out);
    exit;

4. Mise à jour de ai-editor.js

Remplacer le fetch bloquant par un cycle enqueue → poll :

btnAnalyze.addEventListener('click', async function () {
    setLoading(true);
    panel.style.display = 'none';

    // 1. Enqueue
    var enqRes = await fetch('/?action=ai_enqueue', {
        method: 'POST',
        headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
        body: new URLSearchParams({ action: 'analyze', title: titleVal, content: contentVal }),
    });
    var enqData = await enqRes.json();
    if (!enqData.ok) { showError(enqData.error); setLoading(false); return; }

    // 2. Poll toutes les 2 s jusqu'à done/error (max 120 s)
    var jobId    = enqData.job_id;
    var attempts = 0;
    var maxAttempts = 60; // 60 × 2 s = 120 s

    var pollTimer = setInterval(async function () {
        attempts++;
        if (attempts > maxAttempts) {
            clearInterval(pollTimer);
            showError("Délai dépassé — l'analyse IA n'a pas répondu à temps.");
            setLoading(false);
            return;
        }
        try {
            var pollRes  = await fetch('/?action=ai_poll&job_id=' + encodeURIComponent(jobId));
            var pollData = await pollRes.json();

            if (pollData.status === 'done') {
                clearInterval(pollTimer);
                showResult(pollData.critique, pollData.rewrite);
                setLoading(false);
            } else if (pollData.status === 'error') {
                clearInterval(pollTimer);
                showError(pollData.error || 'Erreur IA');
                setLoading(false);
            }
            // status === 'pending' | 'running' : continuer à poller
        } catch (e) {
            // erreur réseau transitoire : on continue
        }
    }, 2000);
});

5. Nettoyage des jobs expirés

Ajouter dans scripts/ai_worker.php, après chaque exécution :

// Purge des jobs expirés (status done/error ET expires_at dépassé)
$pdo->prepare("DELETE FROM ai_jobs WHERE expires_at < NOW()")->execute();

Pas besoin de cron dédié : le nettoyage se fait à chaque invocation du worker.


6. Sécurité

  • ai_enqueue et ai_poll nécessitent requireAuth() (déjà le cas pour ai_query)
  • Le job_id est un UUID v4 généré par PostgreSQL (gen_random_uuid()) — non prédictible
  • ai_poll ne retourne le résultat qu'à l'utilisateur authentifié, sans vérifier que le job lui appartient (mono-user dans Folio). Si Folio devient multi-auteur, ajouter un champ owner_email et vérifier currentUserEmail().
  • content est tronqué à 8 000 caractères dans AiService — la validation existe déjà.

Fichiers à créer / modifier

Fichier Action
database/migration_017_ai_jobs.sql Créer — table ai_jobs
scripts/ai_worker.php Créer — worker CLI (claim + AiService + résultat DB)
public/index.php Ajouter ai_enqueue + ai_poll, retirer ou garder ai_query
public/assets/js/ai-editor.js Remplacer l'appel bloquant par enqueue + poll

Critères d'acceptation

  • Cliquer « Analyser et proposer » retourne job_id en < 100 ms (aucun appel IA synchrone)
  • Le worker tourne en dehors du cycle HTTP (PHP-FPM libéré immédiatement)
  • Le frontend affiche « En cours… » et interroge /ai_poll toutes les 2 s
  • Le résultat (critique + réécriture) s'affiche dès que le job est done
  • Un message d'erreur s'affiche si le job passe en error
  • Timeout frontend de 120 s avec message lisible si dépassé
  • Les jobs expirés (> 1 h) sont supprimés de la DB à chaque exécution du worker
  • requireAuth() sur les deux routes
## Problème Actuellement, cliquer sur « Analyser et proposer » dans l'éditeur déclenche : ``` fetch POST /?action=ai_query → PHP-FPM worker bloqué 10–90 s (cURL vers api.anthropic.com ou proc_open claude) → JSON retourné ``` Un seul appel IA **monopolise un worker PHP-FPM pendant toute la durée de l'appel API**. Sur un pool de taille modeste (8–16 workers), une poignée d'appels simultanés suffit à rendre le site indisponible pour les autres visiteurs. Le timeout est actuellement fixé à 90 s (`AiService.php`). --- ## Solution : pattern enqueue + worker + poll ``` fetch POST /?action=ai_enqueue → job_id retourné immédiatement (< 1 ms) PHP spawne un worker détaché → php scripts/ai_worker.php {job_id} & worker appelle AiService, écrit le résultat en DB fetch GET /?action=ai_poll → { status, critique, rewrite } dès que done ``` Le worker tourne en dehors du cycle requête/réponse HTTP. PHP-FPM est libéré dès le retour du `job_id`. --- ## Infrastructure existante à réutiliser | Élément | Emplacement | |---------|-------------| | PDO (PostgreSQL) | `src/Infrastructure/Database.php` → `Database::get()` | | Pattern queue DB | `database/migration_015_mail_queue.sql` + `src/Service/MailQueue.php` | | Pattern SKIP LOCKED | Voir `MailQueue::reserveBatch()` | | Numéro de migration suivant | `migration_017_ai_jobs.sql` | | `AiService` | `src/Service/AiService.php` | --- ## 1. Migration `database/migration_017_ai_jobs.sql` ```sql CREATE TABLE IF NOT EXISTS ai_jobs ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), status TEXT NOT NULL DEFAULT 'pending' CHECK (status IN ('pending', 'running', 'done', 'error')), action TEXT NOT NULL, title TEXT NOT NULL DEFAULT '', content TEXT NOT NULL, result JSONB, error_msg TEXT, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), started_at TIMESTAMPTZ, finished_at TIMESTAMPTZ, expires_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + INTERVAL '1 hour' ); CREATE INDEX IF NOT EXISTS idx_ai_jobs_pending ON ai_jobs (created_at ASC) WHERE status = 'pending'; CREATE INDEX IF NOT EXISTS idx_ai_jobs_expires ON ai_jobs (expires_at) WHERE status IN ('done', 'error'); ``` Le champ `expires_at` (1 h) permet un nettoyage périodique des résultats lus. `content` peut être long (jusqu'à 8 000 caractères) — utiliser `TEXT` sans limite. --- ## 2. Worker CLI `scripts/ai_worker.php` Script PHP autonome, appelé avec l'UUID du job : ```php <?php // scripts/ai_worker.php {job_id} define('BASE_PATH', dirname(__DIR__)); require_once BASE_PATH . '/vendor/autoload.php'; require_once BASE_PATH . '/config/config.php'; $jobId = $argv[1] ?? ''; if (!preg_match('/^[0-9a-f-]{36}$/', $jobId)) exit(1); $pdo = App\Infrastructure\Database::get(); // Claim atomique (évite les doublons si deux workers démarrent) $pdo->beginTransaction(); $stmt = $pdo->prepare( "SELECT id, action, title, content FROM ai_jobs WHERE id = ? AND status = 'pending' FOR UPDATE SKIP LOCKED" ); $stmt->execute([$jobId]); $job = $stmt->fetch(); if (!$job) { $pdo->rollBack(); exit(0); } // déjà pris ou inexistant $pdo->prepare("UPDATE ai_jobs SET status='running', started_at=NOW() WHERE id=?") ->execute([$jobId]); $pdo->commit(); // Appel IA require_once BASE_PATH . '/src/SiteSettings.php'; require_once BASE_PATH . '/src/Service/AiService.php'; $result = (new AiService())->query($job['action'], $job['title'], $job['content']); // Stocker le résultat if ($result['ok'] ?? false) { $pdo->prepare( "UPDATE ai_jobs SET status='done', result=?::jsonb, finished_at=NOW() WHERE id=?" )->execute([json_encode($result), $jobId]); } else { $pdo->prepare( "UPDATE ai_jobs SET status='error', error_msg=?, finished_at=NOW() WHERE id=?" )->execute([$result['error'] ?? 'Erreur inconnue', $jobId]); } ``` --- ## 3. Routes dans `public/index.php` ### `POST /?action=ai_enqueue` Remplace `ai_query`. Insère le job en DB et spawne le worker détaché : ```php case 'ai_enqueue': requireAuth(); header('Content-Type: application/json'); if ($_SERVER['REQUEST_METHOD'] !== 'POST') { echo json_encode(['ok' => false, 'error' => 'Méthode invalide']); exit; } $_aiAction = trim($_POST['action'] ?? ''); $_aiTitle = trim($_POST['title'] ?? ''); $_aiContent = str_replace("\r\n", "\n", trim($_POST['content'] ?? '')); if (!in_array($_aiAction, ['analyze'], true) || $_aiContent === '') { echo json_encode(['ok' => false, 'error' => 'Paramètres invalides']); exit; } // Insérer le job $pdo = db(); $stmt = $pdo->prepare( "INSERT INTO ai_jobs (action, title, content) VALUES (?, ?, ?) RETURNING id" ); $stmt->execute([$_aiAction, $_aiTitle, $_aiContent]); $jobId = $stmt->fetchColumn(); // Spawner le worker détaché (ne bloque pas) $php = PHP_BINARY; $script = BASE_PATH . '/scripts/ai_worker.php'; $cmd = escapeshellcmd($php) . ' ' . escapeshellarg($script) . ' ' . escapeshellarg($jobId) . ' > /dev/null 2>&1 &'; shell_exec($cmd); echo json_encode(['ok' => true, 'job_id' => $jobId]); exit; ``` ### `GET /?action=ai_poll&job_id=xxx` Retourne le statut courant : ```php case 'ai_poll': requireAuth(); header('Content-Type: application/json'); $jobId = trim($_GET['job_id'] ?? ''); if (!preg_match('/^[0-9a-f-]{36}$/', $jobId)) { echo json_encode(['ok' => false, 'error' => 'job_id invalide']); exit; } $stmt = db()->prepare( "SELECT status, result, error_msg FROM ai_jobs WHERE id = ?" ); $stmt->execute([$jobId]); $job = $stmt->fetch(); if (!$job) { echo json_encode(['ok' => false, 'error' => 'Job introuvable']); exit; } $out = ['ok' => true, 'status' => $job['status']]; if ($job['status'] === 'done') { $result = json_decode($job['result'] ?? '{}', true); $out['critique'] = $result['critique'] ?? ''; $out['rewrite'] = $result['rewrite'] ?? ''; } elseif ($job['status'] === 'error') { $out['error'] = $job['error_msg'] ?? 'Erreur inconnue'; } echo json_encode($out); exit; ``` --- ## 4. Mise à jour de `ai-editor.js` Remplacer le `fetch` bloquant par un cycle **enqueue → poll** : ```javascript btnAnalyze.addEventListener('click', async function () { setLoading(true); panel.style.display = 'none'; // 1. Enqueue var enqRes = await fetch('/?action=ai_enqueue', { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: new URLSearchParams({ action: 'analyze', title: titleVal, content: contentVal }), }); var enqData = await enqRes.json(); if (!enqData.ok) { showError(enqData.error); setLoading(false); return; } // 2. Poll toutes les 2 s jusqu'à done/error (max 120 s) var jobId = enqData.job_id; var attempts = 0; var maxAttempts = 60; // 60 × 2 s = 120 s var pollTimer = setInterval(async function () { attempts++; if (attempts > maxAttempts) { clearInterval(pollTimer); showError("Délai dépassé — l'analyse IA n'a pas répondu à temps."); setLoading(false); return; } try { var pollRes = await fetch('/?action=ai_poll&job_id=' + encodeURIComponent(jobId)); var pollData = await pollRes.json(); if (pollData.status === 'done') { clearInterval(pollTimer); showResult(pollData.critique, pollData.rewrite); setLoading(false); } else if (pollData.status === 'error') { clearInterval(pollTimer); showError(pollData.error || 'Erreur IA'); setLoading(false); } // status === 'pending' | 'running' : continuer à poller } catch (e) { // erreur réseau transitoire : on continue } }, 2000); }); ``` --- ## 5. Nettoyage des jobs expirés Ajouter dans `scripts/ai_worker.php`, après chaque exécution : ```php // Purge des jobs expirés (status done/error ET expires_at dépassé) $pdo->prepare("DELETE FROM ai_jobs WHERE expires_at < NOW()")->execute(); ``` Pas besoin de cron dédié : le nettoyage se fait à chaque invocation du worker. --- ## 6. Sécurité - `ai_enqueue` et `ai_poll` nécessitent `requireAuth()` (déjà le cas pour `ai_query`) - Le `job_id` est un UUID v4 généré par PostgreSQL (`gen_random_uuid()`) — non prédictible - `ai_poll` ne retourne le résultat qu'à l'utilisateur authentifié, sans vérifier que le job lui appartient (mono-user dans Folio). Si Folio devient multi-auteur, ajouter un champ `owner_email` et vérifier `currentUserEmail()`. - `content` est tronqué à 8 000 caractères dans `AiService` — la validation existe déjà. --- ## Fichiers à créer / modifier | Fichier | Action | |---------|--------| | `database/migration_017_ai_jobs.sql` | Créer — table `ai_jobs` | | `scripts/ai_worker.php` | Créer — worker CLI (claim + AiService + résultat DB) | | `public/index.php` | Ajouter `ai_enqueue` + `ai_poll`, retirer ou garder `ai_query` | | `public/assets/js/ai-editor.js` | Remplacer l'appel bloquant par enqueue + poll | --- ## Critères d'acceptation - [ ] Cliquer « Analyser et proposer » retourne `job_id` en < 100 ms (aucun appel IA synchrone) - [ ] Le worker tourne en dehors du cycle HTTP (PHP-FPM libéré immédiatement) - [ ] Le frontend affiche « En cours… » et interroge `/ai_poll` toutes les 2 s - [ ] Le résultat (critique + réécriture) s'affiche dès que le job est `done` - [ ] Un message d'erreur s'affiche si le job passe en `error` - [ ] Timeout frontend de 120 s avec message lisible si dépassé - [ ] Les jobs expirés (> 1 h) sont supprimés de la DB à chaque exécution du worker - [ ] `requireAuth()` sur les deux routes
Sign in to join this conversation.
No Label
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: cedricAbonnel/folio#101