Aller au contenu

Générer des PDF en toute sécurité dans un worker à longue durée de vie

Un worker PHP à longue durée de vie (RoadRunner, Swoole, Laravel Octane) garde le processus actif sur de nombreuses requêtes. Réanalyser les mêmes polices et redécoder les mêmes images à chaque requête gaspille du CPU et augmente la mémoire résidente. Pour éviter cela, NextPDF sépare deux durées de vie :

  • Durée de vie du processus, partagée : FontRegistry et ImageRegistry conservent les tables de polices analysées et les caches d’images décodées. Crée-les une seule fois au démarrage du worker.
  • Durée de vie de la requête, jetable : le Document renvoyé par DocumentFactory::create(). Construis-le, écris-le, puis laisse-le sortir de portée. Le ramasse-miettes récupère ensuite tout le graphe d’objets.

Cette recette te donne la séquence de démarrage du worker, le corps par requête et la réinitialisation par cycle qui garde le pic de mémoire stable.

Fenêtre de terminal
composer require nextpdf/core:^3

Le modèle de worker lui-même ne nécessite aucune extension supplémentaire, et un runtime de worker (RoadRunner / Swoole / Octane) est optionnel. Le même modèle de fabrique fonctionne dans une simple boucle for en CLI : c’est exactement ce qu’exécute le harnais.

Le point d’entrée recommandé pour les workers est DocumentFactory. Construis-le une seule fois avec un FontRegistry et un ImageRegistry partagés :

  • FontRegistry::warmup() analyse les fichiers de polices que tu nommes et met en cache les tables analysées. FontRegistry::lock() fige ensuite le registre, de sorte qu’aucun code par requête ne puisse modifier le jeu de polices partagé. isLocked() indique l’état actuel. Une fois verrouillé, le registre peut être partagé sans risque entre coroutines concurrentes.
  • Construis ImageRegistry avec un budget maxCacheBytes. Quand le budget est dépassé, il évince les entrées les moins récemment utilisées. Une image à elle seule plus grande que le budget contourne entièrement le cache au lieu de le saturer.
  • ImageRegistry::reset() évince toutes les images en cache tout en gardant le registre pleinement fonctionnel. La requête suivante le repeuple à la demande. Appelle-le à intervalles réguliers (toutes les N requêtes, ou quand memoryUsage() franchit un seuil) pour ramener le point haut à son niveau de référence.

Chaque document créé par la fabrique est un PDF indépendant. ISO 32000-2 §7.5.5 définit le trailer d’un fichier jamais mis à jour comme dépourvu d’entrée Prev, et chaque requête du worker émet exactement un tel fichier de première génération. Les requêtes ne partagent donc aucun état de document, même si elles partagent les caches de polices et d’images. Le tag BaseFont de sous-ensemble de police (ISO 32000-2 §9.6.4) reste stable d’une requête à l’autre, car la police analysée vit dans le registre partagé.

La surface de l’API utilisée par cette recette est générée à partir du PHPDoc de NextPDF\Core\DocumentFactory, NextPDF\Typography\FontRegistry, NextPDF\Graphics\ImageRegistry et NextPDF\Support\MemoryReport. Les membres clés utilisés ci-dessous sont DocumentFactory::create(), FontRegistry::warmup() / lock() / isLocked() / memoryUsage(), ImageRegistry::reset() / memoryUsage(), et MemoryReport::$currentBytes / $peakBytes / $entryCount / utilizationPercent().

<?php
declare(strict_types=1);
require_once __DIR__ . '/vendor/autoload.php';
use NextPDF\Core\DocumentFactory;
use NextPDF\Graphics\ImageRegistry;
use NextPDF\Typography\FontRegistry;
// --- Worker boot (run ONCE, before the request loop) ---------------------
$fonts = new FontRegistry();
$fonts->lock(); // freeze the shared font set
$images = new ImageRegistry(maxCacheBytes: 50 * 1024 * 1024);
$factory = new DocumentFactory($fonts, $images);
// --- Per request ---------------------------------------------------------
$doc = $factory->create();
$doc->setTitle('Worker output');
$doc->addPage();
$doc->setFont('helvetica', 'B', 16);
$doc->cell(0, 12, 'Generated in a shared-registry worker', newLine: true);
$doc->save(getenv('NEXTPDF_COOKBOOK_OUTPUT') ?: __DIR__ . '/out.pdf');
// $doc leaves scope here → GC reclaims the whole document tree.

L’exemple complet respecte le canal de sortie du harnais. Il montre la séquence de démarrage, une boucle de requêtes bornée, un appel à reset() par cycle et une assertion sur le point haut de mémoire. C’est le script que le harnais de reproductibilité exécute deux fois.

<?php
declare(strict_types=1);
require_once __DIR__ . '/vendor/autoload.php';
use NextPDF\Core\DocumentFactory;
use NextPDF\Graphics\ImageRegistry;
use NextPDF\Typography\FontRegistry;
// --- Worker boot: shared, process-lifetime registries --------------------
$fonts = new FontRegistry();
$fonts->lock(); // share-safe once locked
$images = new ImageRegistry(maxCacheBytes: 50 * 1024 * 1024);
$factory = new DocumentFactory($fonts, $images);
$resetEvery = 4; // reset cadence in requests
$peakAfterReset = 0;
// --- Simulated request loop ---------------------------------------------
for ($request = 1; $request <= 12; $request++) {
$doc = $factory->create();
$doc->setTitle("Worker Request #{$request}");
$doc->addPage();
$doc->setFont('helvetica', 'B', 16);
$doc->cell(0, 12, "Worker Request #{$request}", newLine: true);
$doc->setFont('helvetica', '', 11);
$doc->cell(0, 8, 'Shared FontRegistry / ImageRegistry across requests.', newLine: true);
// The harness captures the LAST request's PDF via the side channel.
if ($request === 12) {
$doc->save(getenv('NEXTPDF_COOKBOOK_OUTPUT') ?: __DIR__ . '/out.pdf');
} else {
$doc->getPdfData(); // force render, then drop
}
unset($doc); // explicit end-of-request
// Bound the cache high-water mark on a fixed cadence.
if ($request % $resetEvery === 0) {
$images->reset();
\gc_collect_cycles();
$report = $images->memoryUsage();
$peakAfterReset = \max($peakAfterReset, $report->currentBytes);
}
}
$final = $images->memoryUsage();
fwrite(STDERR, \sprintf(
"fonts.locked=%s images.entries=%d images.current=%dB peak_after_reset=%dB\n",
$fonts->isLocked() ? 'yes' : 'no',
$final->entryCount,
$final->currentBytes,
$peakAfterReset,
));

STDOUT reste libre pour le harnais ; le texte de progression va vers STDERR. Le PDF est écrit uniquement dans NEXTPDF_COOKBOOK_OUTPUT, jamais affiché.

  • Verrouille avant de partager. Appelle FontRegistry::lock() au démarrage. Un registre encore mutable alors que deux coroutines y accèdent crée une situation de compétition (data race). Utilise isLocked() comme assertion dans un contrôle de santé.
  • reset() n’est pas unset(). ImageRegistry::reset() évince les données binaires en cache mais garde le registre utilisable ; c’est donc le bon appel périodique. Détruire et reconstruire le registre à chaque requête anéantit tout l’intérêt du cache partagé.
  • Contournement des images surdimensionnées. Une image plus grande que maxCacheBytes est décodée à chaque utilisation et jamais mise en cache ; elle ne peut donc pas évincer le jeu de travail. C’est intentionnel. Dimensionne le budget pour tes images courantes, pas pour la rare grande image.
  • Le document doit sortir de portée. Conserver le Document dans une variable statique, une liaison de conteneur à longue durée de vie ou une closure capturée par le worker maintient tout le graphe d’objets en vie et anéantit la collecte par requête. Un appel à unset() ou une sortie de portée est obligatoire.
  • Placement de gc_collect_cycles(). Le collecteur de cycles de PHP ne connaît pas les frontières de requête. Appelle-le après la cadence de réinitialisation, pas à chaque requête. Cela garde le point haut borné sans payer le coût de la collecte sur le chemin critique.
  • Mise en garde sur le déterminisme. Les horodatages du document et le /ID du trailer sont régénérés à chaque sauvegarde (ISO 32000-2 §14.3). Le PDF capturé est donc comparé avec le profil sémantique (AST structurel plus métadonnées, jamais les octets volatils). Voir « Conformité ».
  • Le registre partagé transforme l’analyse répétée des polices et le décodage répété des images en un coût de démarrage unique. Le travail par requête se limite alors à la mise en page et à la sérialisation.
  • Le pic de mémoire résidente est borné par maxCacheBytes plus le jeu de travail d’un document en cours. L’appel à reset() par cycle ramène le cache à son niveau de référence, de sorte qu’un worker à longue durée de vie ne montre pas de dents de scie à tendance croissante.
  • Le front-matter performance_budget (wall_ms: 4000, peak_mb: 192) borne l’exécution de la boucle de 12 requêtes par le harnais. Le harnais l’impose ; ce n’est pas une garantie pour des documents arbitraires.
  • Cette recette fournit la couverture « mémoire/GC » de la liste de lacunes §4.3 pour le #31. Le fichier examples/14-worker-factory.php sous-jacent existe, et le nouveau tests/Cookbook/Php/WorkerSafeBatchRenderingRecipeTest.php ajoute l’assertion mémoire/GC manquante (le pic ne croît pas d’un cycle à l’autre après la réinitialisation).
  • Le modèle de worker traite un document par requête et ne partage que les caches de polices analysées et d’images décodées. Aucun contenu de document ne traverse la frontière de requête. Une requête ne peut pas lire les données de document d’une autre requête à travers les registres partagés.
  • Les entrées non fiables passent toujours par les frontières d’entrée normales de NextPDF, et le modèle de worker n’assouplit aucune validation. Traite l’entrée HTML/ressource de chaque requête comme non fiable, exactement comme tu le ferais dans un processus par requête.
ÉnoncéSpécificationArticlereference_id
La date de modification du document est régénérée à chaque sauvegarde ; la sortie par requête n’est donc pas stable au niveau des octets.ISO 32000-2§14.3
Chaque document de worker est un fichier jamais mis à jour (pas de Prev dans le trailer) ; les requêtes ne partagent pas d’état de document.ISO 32000-2§7.5.5
Le préfixe du tag de sous-ensemble de police est stable d’une requête à l’autre, car la police analysée vit dans le registre partagé.ISO 32000-2§9.6.4

Comme le /ID du trailer et la date de modification sont régénérés à chaque sauvegarde, cette recette est vérifiée avec le profil de reproductibilité sémantique (égalité d’AST structurel plus une comparaison portant uniquement sur les métadonnées). Une revendication de stabilité bit à bit ou structurelle serait malhonnête pour une sortie de worker.