Streaming y memoria: perfilado y workers por lotes
De un vistazo
Sección titulada «De un vistazo»NextPDF renderiza en una sola pasada y nunca retiene un DOM de nivel de documento, de modo que la memoria del lado de entrada queda acotada por la profundidad de anidamiento y no por el número de elementos. Esta página explica el modelo de streaming, qué limita la ADR-001 y cómo ejecutar el motor de forma segura dentro de un worker de cola de larga duración.
Instalación
Sección titulada «Instalación»composer require nextpdf/core:^3Panorama conceptual
Sección titulada «Panorama conceptual»NextPDF ofrece dos rutas de escritura con perfiles de memoria distintos.
El escritor en memoria predeterminado compone el documento completo y luego lo serializa. El pico de memoria sigue el tamaño total de la salida: funciona bien para documentos típicos, pero resulta costoso para documentos muy grandes.
El escritor de streaming serializa cada página a medida que se compone y la vacía antes de que comience la página siguiente. El motor incluido —StreamingPdfWriter, StreamingCursor, DevNullWriter y el enum WriterState en src/Writer/Streaming/— es real, final y está probado, y se distribuye desde la 3.1.0. Se expone a través de los contratos de nivel experimental StreamingWriterInterface y CursorInterface. Las clases del motor son internas, por lo que se debe depender del contrato y dejar que Core proporcione la implementación. (Una anotación anterior en .ai/contracts-map.md describía incorrectamente el streaming como «solo contrato / sin implementación»; se trata de un defecto de anotación obsoleta registrado en el issue #610 y corregido en la documentación de contratos B1: el motor se distribuye desde la 3.1.0.)
El objetivo de diseño del motor de streaming es que la memoria residente no crezca con el número de páginas. El búfer de cada página finalizada se entrega al escritor y se libera, y la tabla de referencias cruzadas y las referencias del árbol de páginas /Kids se escriben en flujos temporales php://temp/maxmemory:0 que se vuelcan a disco de inmediato en lugar de acumularse en el heap de PHP. El resultado serializado es un árbol de páginas estándar cuya entrada Count es el número de nodos hoja (objetos de página) descendientes de un nodo (ISO 32000-2 §7.7.3.3) y cuya entrada Kids es un arreglo de referencias indirectas a los hijos inmediatos de ese nodo (ISO 32000-2 §7.7.3.2). El perfil de memoria exacto es una propiedad de nivel experimental y puede cambiar entre versiones menores: no se debe fijar en el código una suposición a partir de una sola medición.
La ADR-001 rige el modelo de memoria del pipeline de renderizado HTML. El tokenizador produce una lista de tokens en una sola pasada; el parser la consume de izquierda a derecha y emite operadores de flujo de contenido a un búfer de cadena. No se construye ningún árbol de elementos persistente: el parser conserva como máximo un HtmlStyleState por nivel de anidamiento, acotado por MAX_NESTING_DEPTH = 100, e impone un tope rígido MAX_ELEMENT_COUNT = 50_000. Las dos operaciones que necesitan lookahead —el dimensionamiento de columnas de tabla y la familia de selectores :has() / :last-child— usan arreglos de índices de preescaneo acotados sobre la lista plana de tokens, no un DOM retenido. El benchmark de la fase 0 (docs/architecture/adr-001-memory-benchmark.md, ejecutado el 2026-04-06, PHP 8.5.3, memory_limit=1G) midió, para un documento de 50,000 elementos, un pico de 50 MB en la ruta de stream frente a 4 MB en una simulación retenida de trabajo parcial. El análisis del informe atribuye unos 50 MB de esa cifra al flujo de contenido acumulado, independiente de la arquitectura, e identifica una ventaja del lado de entrada de 4 a 5 veces para el modelo de stream en ese fixture. Esas cifras se observaron en ese único equipo y fixture; no son una garantía.
Perfilar la memoria antes de ajustar
Sección titulada «Perfilar la memoria antes de ajustar»Conviene medir antes de cambiar cualquier cosa. El pipeline HTML está controlado por tools/perf-benchmark.php (ejecutado mediante composer ai:perf-check), que informa peak_memory_delta_bytes: el pico incremental por objetivo, que es el eje de regresión, no el pico absoluto del proceso. La línea base del Cycle 36 (docs/architecture/PERFORMANCE-BUDGETS.md §6.3, capturada el 2026-05-17 en un i9-13900K, 64 GB, PHP 8.5.3, opcache desactivado) observó un delta de pico de 0 bytes en 12 de 16 pares target/mode, con los cuatro deltas distintos de cero atribuidos a asignaciones de primer acceso de la caché de fuentes y del búfer de traza que permanecen constantes en los renders posteriores. Deben leerse como valores observados para ese equipo, no como constantes portables. Para perfilar tu propio documento ad hoc, se puede muestrear memory_get_peak_usage(true) antes y después del render y reiniciar el pico con memory_reset_peak_usage() entre iteraciones, igual que el benchmark aísla el coste por objetivo.
Ejecutar NextPDF en un worker por lotes
Sección titulada «Ejecutar NextPDF en un worker por lotes»Un worker de cola es un proceso PHP de larga duración: arranca el framework una sola vez y permanece residente, procesando jobs en un bucle. Eso aporta velocidad, pero también vuelve importante la higiene de memoria. Una fuga lenta que resulta invisible en una sola petición se acumula a lo largo de miles de jobs. PERFORMANCE-BUDGETS §1 nombra este modo de fallo explícitamente: un worker que renderiza muchos PDF de forma consecutiva puede agotar la memoria al cabo de horas, aunque los renders individuales parezcan correctos.
NextPDF admite entornos con workers. DocumentFactory permite que un worker cree un documento nuevo por job mientras comparte un FontRegistry y un ImageRegistry durante toda la vida del proceso, de modo que el parseo de fuentes e imágenes ocurre una sola vez en lugar de repetirse en cada job. La ADR-001 deja constancia de que el parser de HTML se construye por petición sin estado mutable estático, y de que los futuros objetos de contexto de formato deben seguir el mismo ámbito por petición. Los pasos siguientes permiten configurar un worker de forma segura.
Paso 1 — Compartir las registries entre jobs
Sección titulada «Paso 1 — Compartir las registries entre jobs»Crear las registries una sola vez durante el arranque del proceso y reutilizarlas en cada job, siguiendo examples/14-worker-factory.php:
<?php
declare(strict_types=1);
require_once __DIR__ . '/../vendor/autoload.php';
use NextPDF\Core\DocumentFactory;use NextPDF\Core\PdfFactory;use NextPDF\Graphics\ImageRegistry;use NextPDF\Typography\FontRegistry;
// Created once at process boot — not per job.$fontRegistry = new FontRegistry();$imageRegistry = new ImageRegistry(maxCacheBytes: 50 * 1024 * 1024);$documentFactory = new DocumentFactory($fontRegistry, $imageRegistry);
$factory = PdfFactory::new() ->withCompress(true) ->withDocumentFactory($documentFactory);
// Per job: a fresh document, shared registries.$doc = $factory->create();$doc->addPage();$doc->setFont('helvetica', '', 11);$doc->cell(0, 8, 'Rendered inside a worker.', newLine: true);$doc->save('/path/to/output.pdf');El maxCacheBytes del registry de imágenes acota la caché compartida para que no pueda crecer sin límite a lo largo de los jobs.
Paso 2 — Acotar el tiempo de vida del worker
Sección titulada «Paso 2 — Acotar el tiempo de vida del worker»Se trata de una práctica general de control de procesos para cualquier worker de PHP, no de una garantía del motor de NextPDF: reiniciar los workers de forma periódica evita que un proceso de larga duración acumule memoria o ejecute código obsoleto de forma indefinida. Los dos sistemas principales de colas en PHP proporcionan límites integrados y reinicios ordenados.
Para las colas de Laravel (https://laravel.com/docs/12.x/queues), el comando queue:work ejecuta el worker como un proceso de larga duración. Las opciones documentadas son --memory (valor predeterminado de 128 MB; el worker termina cuando su memoria supera el límite), --max-jobs (termina tras una cantidad de jobs) y --max-time (termina tras una cantidad de segundos). El comando queue:restart indica a los workers que terminen de forma ordenada después del job actual, de modo que un despliegue o un temporizador periódico pueda reciclarlos sin interrumpir un render en curso. Laravel Horizon (https://laravel.com/docs/12.x/horizon) supervisa workers de Redis con una estrategia de balanceo auto y ofrece php artisan horizon:terminate para una terminación ordenada, que finaliza los jobs en curso antes de que el monitor de procesos reinicie el supervisor.
Para Symfony Messenger (https://symfony.com/doc/current/messenger.html), el comando messenger:consume se ejecuta de forma indefinida de manera predeterminada. Las opciones de límite documentadas son --limit (procesa N mensajes y luego termina), --memory-limit (por ejemplo 128M; termina cuando la memoria alcanza el límite) y --time-limit (por ejemplo 3600; termina tras el intervalo). La documentación de Symfony recomienda ejecutar el worker bajo Supervisor o systemd para que un proceso terminado se reinicie automáticamente, y messenger:stop-workers establece una marca de caché que indica a cada worker que termine su mensaje actual y salga de forma limpia.
Paso 3 — Reiniciar en cada despliegue
Sección titulada «Paso 3 — Reiniciar en cada despliegue»En cada despliegue, indicar un reinicio ordenado para que los workers recojan el código nuevo: php artisan queue:restart (o php artisan horizon:terminate) para Laravel, php bin/console messenger:stop-workers para Symfony. El gestor de procesos —Supervisor, systemd o el supervisor de Horizon/Octane— arranca entonces un proceso nuevo con la nueva base de código. Se trata de una práctica general de despliegue para workers de PHP de larga duración y es independiente de NextPDF.
Rendimiento
Sección titulada «Rendimiento»El diseño de la ruta de streaming acota el pico de memoria vaciando cada página completada y volcando la contabilidad de referencias cruzadas y del árbol de páginas a flujos temporales respaldados en disco, de modo que el conjunto residente está pensado para no crecer con el número de páginas —observado en el motor 3.1.0 distribuido y fijado por sus pruebas de reproducibilidad de línea base dorada, pero expresado como comportamiento de diseño y no como un número fijo, porque el perfil es una propiedad de nivel experimental. La memoria del lado de entrada del pipeline HTML está acotada por MAX_NESTING_DEPTH = 100 y no por el número de elementos (ADR-001). Todas las cifras concretas de esta página están ligadas a un artefacto fechado —el benchmark de la ADR-001 del 2026-04-06 y la línea base del Cycle 36 de PERFORMANCE-BUDGETS del 2026-05-17— y se observaron en los equipos que esos documentos nombran; deben tratarse como observaciones, no como garantías portables. El performance_budget de 1500 ms / 64 MB es el margen del canvas, no un tope contractual.
Notas de seguridad
Sección titulada «Notas de seguridad»El writeContent() del cursor de streaming añade bytes al flujo de contenido de la página de forma literal: no valida la sintaxis de los operadores. En un worker que renderiza contenido influido por el llamador, nunca se debe pasar entrada no confiable a writeContent(); se debe usar writeText(), que el cursor distribuido escapa para la gramática de cadenas literales de PDF. El llamador es dueño del flujo de salida: el motor escribe en él pero nunca lo cierra ni lo vuelve a abrir, así que no puede redirigir la salida —un worker debe cerrar el handle por sí mismo después de que el close() del escritor retorne; de lo contrario, se fuga un descriptor de archivo entre jobs. Compartir las registries entre jobs es una optimización de rendimiento, no un límite de confianza: un ImageRegistry compartido cachea imágenes parseadas, así que conviene dimensionar su maxCacheBytes de forma deliberada y no asumir aislamiento de caché entre tenants en un worker multitenant.
Conformidad
Sección titulada «Conformidad»| Afirmación | Estándar | Cláusula | Evidencia |
|---|---|---|---|
El escritor de streaming emite un árbol de páginas cuya entrada Kids es un arreglo de referencias indirectas a los hijos inmediatos del nodo. | ISO 32000-2 | §7.7.3.2 | |
El escritor de streaming emite una entrada Count igual al número de objetos de página hoja descendientes del nodo del árbol de páginas. | ISO 32000-2 | §7.7.3.3 |
Las cláusulas están parafraseadas y ancladas al glosario; no se reproduce ningún texto normativo.
Consulta también
Sección titulada «Consulta también»- Contracts / Streaming —
experimentalStreamingWriterInterface,CursorInterfacey su máquina de estados. - HTML / Restricciones de streaming (ADR-001) — la decisión de una sola pasada y sin DOM retenido, y los umbrales de revisión.
- Rendimiento — la compuerta de regresión de latencia y memoria del pipeline HTML.
- Layout — los motores de mobiliario de página que no mantienen estado por página.
- PERFORMANCE-BUDGETS — el modo de fallo del worker con fugas y la línea base de la compuerta de regresión.