Ir al contenido

Renderizar PDF de forma segura en un worker de larga duración

Un worker de PHP de larga duración (RoadRunner, Swoole, Laravel Octane) mantiene vivo el proceso durante muchas solicitudes. Volver a analizar las mismas fuentes y volver a decodificar las mismas imágenes en cada solicitud desperdicia CPU y aumenta la memoria residente. Para evitarlo, NextPDF separa dos ciclos de vida:

  • Ciclo de vida del proceso, compartido: FontRegistry e ImageRegistry mantienen las tablas de fuentes analizadas y las cachés de imágenes decodificadas. Crearlos una sola vez al arrancar el worker.
  • Ciclo de vida de la solicitud, desechable: el Document que devuelve DocumentFactory::create(). Construirlo, escribirlo y dejar que salga del ámbito. Entonces, el recolector de basura recupera todo el grafo de objetos.

Esta recipe proporciona la secuencia de arranque del worker, la lógica por solicitud y el reinicio por ciclo que mantiene estable el consumo máximo de memoria.

Ventana de terminal
composer require nextpdf/core:^3

El patrón de worker en sí no requiere ninguna extensión adicional, y un runtime de worker (RoadRunner / Swoole / Octane) es opcional. El mismo patrón de fábrica funciona en un bucle for de CLI simple, que es exactamente lo que ejercita el arnés.

El punto de entrada recomendado para workers es DocumentFactory. Construirlo una sola vez con un FontRegistry e ImageRegistry compartidos:

  • FontRegistry::warmup() analiza los archivos de fuentes que especifiques y guarda en caché las tablas analizadas. A continuación, FontRegistry::lock() congela el registro, de modo que ningún código por solicitud pueda mutar el conjunto de fuentes compartido. isLocked() informa del estado actual. Una vez bloqueado, el registro se puede compartir de forma segura entre corrutinas concurrentes.
  • Crear ImageRegistry con un presupuesto maxCacheBytes. Cuando se supera el presupuesto, expulsa las entradas menos usadas recientemente. Una sola imagen mayor que el presupuesto evita la caché por completo en lugar de saturarla.
  • ImageRegistry::reset() expulsa todas las imágenes en caché y mantiene el registro plenamente funcional. La siguiente solicitud vuelve a poblarlo bajo demanda. Llamarlo con una cadencia (cada N solicitudes, o cuando memoryUsage() cruce un umbral) devuelve el máximo histórico a la línea base.

Cada documento que crea la fábrica es un PDF independiente. ISO 32000-2 §7.5.5 define el tráiler de un archivo nunca actualizado como uno que no tiene entrada Prev, y cada solicitud del worker emite exactamente un archivo de primera generación de ese tipo. De este modo, las solicitudes no comparten estado de documento, aunque sí compartan las cachés de fuentes y de imágenes. La etiqueta BaseFont de la fuente subconjunto (ISO 32000-2 §9.6.4) permanece estable entre solicitudes porque la fuente analizada vive en el registro compartido.

La superficie de la API de esta recipe se genera a partir del PHPDoc de NextPDF\Core\DocumentFactory, NextPDF\Typography\FontRegistry, NextPDF\Graphics\ImageRegistry y NextPDF\Support\MemoryReport. Los miembros clave que se usan más adelante son DocumentFactory::create(), FontRegistry::warmup() / lock() / isLocked() / memoryUsage(), ImageRegistry::reset() / memoryUsage(), y 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.

El ejemplo completo respeta el canal de salida del arnés. Muestra la secuencia de arranque, un bucle de solicitudes acotado, el reset() por ciclo y una aserción sobre el máximo histórico de memoria. Este es el script que ejecuta dos veces el arnés de reproducibilidad.

<?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 queda reservado para el arnés; el texto de progreso se envía a STDERR. El PDF se escribe únicamente en NEXTPDF_COOKBOOK_OUTPUT; nunca se imprime con echo.

  • Bloquear antes de compartir. Llamar a FontRegistry::lock() durante el arranque. Un registro que sigue siendo mutable cuando dos corrutinas lo tocan es una condición de carrera de datos. Usar isLocked() como aserción en una comprobación de salud.
  • reset() no es unset(). ImageRegistry::reset() expulsa los datos binarios en caché, pero mantiene el registro utilizable, así que es la llamada periódica adecuada. Destruir y reconstruir el registro en cada solicitud anula por completo el propósito de la caché compartida.
  • Omisión de imágenes de gran tamaño. Una imagen mayor que maxCacheBytes se decodifica cada vez que se usa y nunca se almacena en caché, así que no puede expulsar el conjunto de trabajo. Esto es intencional. Dimensionar el presupuesto para las imágenes habituales, no para la rara imagen grande.
  • El documento debe salir del ámbito. Mantener el Document en una variable estática, un enlace de contenedor de larga duración o un closure capturado por el worker mantiene vivo todo el grafo de objetos y anula la recolección por solicitud. Llamar a unset() o permitir que salga del ámbito es obligatorio.
  • Colocación de gc_collect_cycles(). El recolector de ciclos de PHP no conoce los límites entre solicitudes. Llamarlo después de la cadencia de reinicio, no en cada solicitud. Esto mantiene acotado el máximo histórico sin pagar el costo de la recolección en la ruta caliente.
  • Advertencia sobre el determinismo. Las marcas de tiempo del documento y el /ID del tráiler se regeneran en cada guardado (ISO 32000-2 §14.3). Por tanto, el PDF capturado se compara con el perfil semántico (AST estructural más metadatos, nunca bytes volátiles). Consulta «Conformidad».
  • El registro compartido convierte el análisis repetido de fuentes y la decodificación repetida de imágenes en un costo de arranque que se paga una sola vez. A partir de ahí, el trabajo por solicitud se reduce al diseño y la serialización.
  • El pico de memoria residente está acotado por maxCacheBytes más el conjunto de trabajo de un único documento en curso. El reset() por ciclo devuelve la caché a la línea base, de modo que un worker de larga duración no muestra un diente de sierra con tendencia ascendente.
  • El front-matter performance_budget (wall_ms: 4000, peak_mb: 192) acota la ejecución del arnés del bucle de 12 solicitudes. El arnés lo impone; no es una garantía para documentos arbitrarios.
  • Esta recipe aporta la cobertura de «memoria/GC» de la lista de carencias §4.3 para #31. Existe el examples/14-worker-factory.php de respaldo, y el nuevo tests/Cookbook/Php/WorkerSafeBatchRenderingRecipeTest.php añade la aserción de memoria/GC que faltaba (el pico no crece entre ciclos tras el reinicio).
  • El patrón de worker procesa un documento por solicitud y comparte únicamente cachés de fuentes analizadas y de imágenes decodificadas. Ningún contenido de documento cruza el límite entre solicitudes. Una solicitud no puede leer los datos de documento de otra solicitud a través de los registros compartidos.
  • La entrada no confiable sigue fluyendo por los límites de entrada normales de NextPDF, y el patrón de worker no relaja ninguna validación. Tratar la entrada HTML y de activos de cada solicitud como no confiable, exactamente igual que se haría en un proceso por solicitud.
DeclaraciónEspecificaciónCláusulareference_id
La fecha de modificación del documento se regenera en cada guardado; por eso, la salida por solicitud no es estable byte a byte.ISO 32000-2§14.3
Cada documento del worker es un archivo nunca actualizado (sin Prev en el tráiler); las solicitudes no comparten estado de documento.ISO 32000-2§7.5.5
El prefijo de la etiqueta de la fuente subconjunto se mantiene estable entre solicitudes porque la fuente analizada vive en el registro compartido.ISO 32000-2§9.6.4

Como el /ID del tráiler y la fecha de modificación se regeneran en cada guardado, esta recipe se verifica con el perfil de reproducibilidad semántico (igualdad de AST estructural más una comparación solo de metadatos). Una afirmación bit a bit o estructural no sería veraz para la salida del worker.