ENbext.dev

Traduction automatique depuis l’anglais. Consulter l’original.

Sur cette page

Travaux en arrière-plan et travaux de longue durée

Chaque requête dans une route PRISM — à la fois les pages et les gestionnaires d'API route.ts — s'exécute dans un isolat V8 avec une date limite de rendu d'horloge murale ( BEXT_RENDER_DEADLINE_MS, par défaut 28 s ). Une fois le délai écoulé, l'isolement est terminé, de sorte que toute requête nécessitant un travail lent (un appel LLM en plusieurs étapes, la création d'un PDF, une exportation volumineuse, un lot de diffusion) sera supprimée en plein vol.

Le correctif n'est pas un délai d'attente plus long. Il s'agit de de maintenir la demande rapide et de déplacer le travail lent hors de l'isolat : accepter la demande, mettre un travail en file d'attente, revenir immédiatement et laisser un travailleur qui est et non lié par la date limite de rendu faire le gros du travail. Le client suit la progression des événements envoyés par le serveur.

Attention

La date limite s'applique également aux gestionnaires d'API, et pas seulement aux rendus de pages. Un route.ts qui est un appel LLM de 30 s pour await sera terminé même s'il renvoie JSON. Le routage d'un travail lent via une file d'attente ou un planificateur dont le consommateur est lui-même une route PRISM n'aide pas - le consommateur se heurte au même mur de 28 s. Le travail doit s'exécuter dans un processus extérieur à V8.

La forme du motif#

code
Request  ──▶  enqueue job (KV / queue)  ──▶  202 { jobId }   (fast, in-isolate)
                      │
Worker (off-isolate)  ▼
   drain queue ──▶ do the slow work ──▶ write result + status
                      │
Client   ──▶  SSE /…/stream?jobId=…  ──▶  running → completed   (live status)

Trois éléments : un enregistrement de tâche (état durable), un travailleur qui s'exécute en dehors de l'isolat et un canal d'état retournant à l'interface utilisateur.

1. Mettre en file d'attente la demande#

Gardez le gestionnaire trivial : conservez une tâche et renvoyez son identifiant. Le magasin KV d'applications (SDK de bouclage, à l'échelle de l'application) est le support durable le plus simple ; le file d'attente intégré fonctionne de la même manière pour un débit plus élevé.

ts
// app/api/reports/route.ts — returns instantly, does no slow work
export async function POST(request: Request): Promise<Response> {
  const { websiteId } = await request.json();
  const jobId = "job_" + crypto.randomUUID();
  // app-scoped KV via the loopback SDK (X-Bext-App-Id)
  await kvSet("job:" + jobId, JSON.stringify({ id: jobId, websiteId, status: "pending" }));
  await pushPending(jobId); // append to a "jobs:pending" list
  return new Response(JSON.stringify({ jobId, status: "pending" }), {
    status: 202,
    headers: { "content-type": "application/json" },
  });
}
Conseil

Les valeurs KV sont codées en JSON par la couche SDK, donc une valeur que vous effectuez en aller-retour JSON.stringify est doublement codée. Lors de la lecture d'un travail, analysez de manière défensive (pelez jusqu'à ce que vous ayez une non-chaîne).

2. Exécutez le travail hors de l'isolat#

Le travailleur doit s'exécuter dans un environnement d'exécution réel (sous-processus Bun/Node), et non dans un isolat V8. Le pilote le plus propre est une tâche planifiée command — voir le guide Planificateur de tâches — qui génère un sous-processus avec un vrai timeout_secs (300 par défaut, et non 28) :

toml
# bext.config.toml — drains the queue every minute; no render deadline applies
[[tasks]]
name = "report-drain"
schedule = "* * * * *"
kind = "command"
command = "bun tools/report-runner.ts drain"
timeout_secs = 200

Le coureur est un script ordinaire qui partage la même couche de données (il peut appeler le SDK de bouclage pour KV/DB exactement comme du code isolé), de sorte qu'il peut réutiliser textuellement votre logique de génération - et, libéré du plafond de 28 s, utiliser un modèle plus grand ou un pipeline en plusieurs étapes :

ts
// tools/report-runner.ts — runs in Bun, no V8 deadline
for (const jobId of await claimPending()) {
  await patch(jobId, { status: "running" });
  try {
    const result = await generateReport(job.websiteId); // ~45 s is fine here
    await patch(jobId, { status: "completed", result });
  } catch (e) {
    await patch(jobId, { status: "failed", error: String(e) });
  }
}

Pour un lot récurrent (par exemple, l'exécution d'un rapport hebdomadaire), ajoutez une deuxième tâche avec un cron tel que 0 9 * * 5 pointant vers le même exécuteur. Un système externe cron + flock fonctionne de manière identique si vous préférez continuer à planifier en dehors de l'application.

3. Diffusez le statut avec SSE#

Offrez à l’interface utilisateur une vue en direct avec un itinéraire de streaming dédié qui interroge l’enregistrement de la tâche et pousse les deltas – le modèle ESS standard. Videz un premier octet avant toute attente afin que la réponse soit diffusée au lieu de la mise en mémoire tampon, et se termine automatiquement après une courte rafale afin que le EventSource se reconnecte (en gardant l'utilisation de l'emplacement de streaming limitée) :

ts
// app/api/reports/stream/route.ts
export async function GET(request: Request): Promise<Response> {
  const jobId = new URL(request.url).searchParams.get("jobId") || "";
  const enc = new TextEncoder();
  const stream = new ReadableStream({
    async start(controller) {
      const send = (s: string) => controller.enqueue(enc.encode(s));
      send("retry: 2000\n: open\n\n"); // flush before awaiting → forces streaming
      for (let i = 0; i < 16; i++) {
        const job = await getJob(jobId);
        if (job) {
          send(`event: status\ndata: ${JSON.stringify(job)}\n\n`);
          if (job.status === "completed" || job.status === "failed") break;
        }
        await new Promise((r) => setTimeout(r, 1500));
      }
      controller.close();
    },
  });
  return new Response(stream, { headers: { "content-type": "text/event-stream", "cache-control": "no-cache" } });
}
js
// client island
const es = new EventSource("/api/reports/stream?jobId=" + jobId);
es.addEventListener("status", (ev) => {
  const job = JSON.parse(ev.data);
  if (job.status === "completed") { es.close(); location.reload(); }
});
Remarque

Une réponse en streaming réserve un slot V8 pendant toute sa durée. Elle échappe donc au délai de rendu de 28 s des réponses mises en tampon : elle dispose de sa propre limite par itération et d’une durée maximale de réservation plus longue. Réservez cependant une capacité dédiée avec BEXT_V8_POOL_STREAMING_LANE=N si vous gérez de nombreuses connexions SSE simultanées. L’approche par courtes rafales et reconnexions décrite ci-dessus garde chaque connexion bien en dessous de cette limite. Pour éviter entièrement le streaming, une interrogation périodique avec setInterval d’un simple point de terminaison JSON constitue une solution robuste.

Lorsque vous n’avez pas besoin des trois#

  • S'adapte en quelques secondes ? Faites-le simplement dans la requête – aucune file d’attente n’est nécessaire.
  • Lent mais l'utilisateur peut attendre sur la page ? Une route de streaming seule (pas de file d'attente) peut exécuter le travail et diffuser le résultat, car le streaming échappe au délai mis en mémoire tampon.
  • Lent et doit-il survivre à la navigation de l'utilisateur ou s'exécute selon un planning ? Utiliser la file d'attente complète + le travailleur hors isolement ; le SSE/sondage est uniquement destiné aux commentaires en direct.

En rapport#

  • Planificateur de tâches — tâches cron/intervalle/one-shot, y compris le type command qui génère un sous-processus
  • Travaux planifiés : capacité de tâches planifiées de niveau supérieur et file d'attente durable
  • WebSockets et SSE — événements envoyés par le serveur et réponses en streaming
  • Flux durables — flux de travail en plusieurs étapes récupérables après incident avec tentatives
Modifier cette page ↗Besoin d’aide ? ↗
Rechercher dans la documentation

Saisissez un sujet, une commande ou une question.