Ga naar inhoud

Streaming en geheugen: tutorial over profiling en batch-workers

NextPDF rendert in één doorloop en houdt nooit een Document Object Model (DOM) op documentniveau aan. Daardoor wordt het geheugen aan de invoerzijde begrensd door de nestingdiepte en niet door het aantal elementen. Deze pagina legt het streamingmodel uit, de beperkingen in Architecture Decision Record (ADR)-001 en hoe u de engine veilig uitvoert in een langlopende queue-worker.

Terminal window
composer require nextpdf/core:^3

NextPDF heeft twee schrijfpaden met verschillende geheugenprofielen.

De standaard in-memory writer stelt het volledige document samen en serialiseert het daarna. Het piekgeheugen volgt de totale uitvoergrootte. Dat werkt goed voor typische documenten, maar kan bij zeer grote documenten kostbaar worden.

De streaming writer serialiseert elke pagina terwijl die wordt samengesteld en flusht de pagina daarna voordat de volgende pagina begint. De geleverde engine — StreamingPdfWriter, StreamingCursor, DevNullWriter en de WriterState-enum in src/Writer/Streaming/ — is echt, definitief, getest en geleverd sinds 3.1.0. De engine wordt beschikbaar gemaakt via de contracten op experimental-niveau StreamingWriterInterface en CursorInterface. De engine-klassen zijn intern, dus baseer uw afhankelijkheid op de contracten en laat Core de implementatie leveren. (Een eerdere annotatie in .ai/contracts-map.md beschreef streaming ten onrechte als „alleen contract / geen implementatie”; dat verouderde annotatiedefect wordt bijgehouden in issue #610 en is gecorrigeerd in de B1-contractdocumentatie — de engine wordt geleverd sinds 3.1.0.)

De streaming-engine is zo ontworpen dat het residente geheugen niet meegroeit met het aantal pagina’s. De buffer van elke voltooide pagina wordt aan de writer doorgegeven en daarna vrijgegeven. De cross-referencetabel en de /Kids-paginaboomreferenties worden geschreven naar tijdelijke streams van php://temp/maxmemory:0, die direct naar schijf worden weggeschreven in plaats van zich op te hopen in de PHP-heap. Het geserialiseerde resultaat is een standaardpaginaboom waarvan de Count-vermelding het aantal bladknopen (pagina-objecten) is dat afstamt van een knoop (ISO 32000-2 §7.7.3.3) en waarvan de Kids-vermelding een array is van indirecte referenties naar de directe onderliggende knopen van die knoop (ISO 32000-2 §7.7.3.2). Het exacte geheugenprofiel is een eigenschap op experimental-niveau en kan tussen minor releases verschuiven, dus hardcode geen aannames op basis van één meting.

ADR-001 bepaalt het geheugenmodel van de HTML-renderpijplijn. De tokenizer produceert in één doorloop een tokenlijst. De parser verwerkt die lijst van links naar rechts en schrijft content-streamoperatoren naar een stringbuffer. Er wordt geen persistente elementenboom opgebouwd: de parser houdt ten hoogste één HtmlStyleState per nestingniveau aan, begrensd door MAX_NESTING_DEPTH = 100, en dwingt een harde limiet van MAX_ELEMENT_COUNT = 50_000 af. De twee bewerkingen waarvoor vooruitkijken nodig is — het bepalen van tabelkolombreedtes en de selectorfamilie :has() / :last-child — gebruiken begrensde indexarrays van een voorafgaande scan over de platte tokenlijst en geen behouden DOM. De Phase 0-benchmark (docs/architecture/adr-001-memory-benchmark.md, uitgevoerd op 2026-04-06, PHP 8.5.3, memory_limit=1G) mat voor een document met 50,000 elementen een piek van 50 MB voor het streampad tegenover een behouden simulatie met deelwerk van 4 MB. Het rapport schrijft ongeveer 50 MB daarvan toe aan de opgebouwde content stream, een architectuurinvariant, en isoleert op die fixture voor het streammodel een voordeel van 4–5x aan de invoerzijde. Die cijfers zijn waargenomen op die ene opstelling en fixture; ze zijn niet gegarandeerd.

Meet voordat u iets wijzigt. De HTML-pijplijn wordt bewaakt door tools/perf-benchmark.php (uitgevoerd via composer ai:perf-check), die peak_memory_delta_bytes rapporteert — de incrementele piek per doel die als regressie-as wordt gebruikt, niet de absolute procespiek. De Cycle 36-baseline (docs/architecture/PERFORMANCE-BUDGETS.md §6.3, vastgelegd op 2026-05-17 op een i9-13900K, 64 GB, PHP 8.5.3, opcache uit) nam bij 12 van de 16 target/mode-paren een piekdelta van 0 byte waar. De vier niet-nul-delta’s werden toegeschreven aan first-touch-allocaties voor de lettertypecache en de trace-buffer, die bij latere renders constant blijven. Lees die waarden als observaties voor die opstelling, niet als overdraagbare constanten. Voor een ad-hocprofiel van uw eigen document meet u memory_get_peak_usage(true) voor en na het renderen en zet u de piek tussen iteraties terug met memory_reset_peak_usage(), op dezelfde manier waarop de benchmark de kosten per doel isoleert.

Een queue-worker is een langlevend PHP-proces: het start het framework één keer op, blijft resident en verwerkt taken in een lus. Daardoor is het snel, maar dat is ook precies waarom geheugenhygiëne belangrijk is. Een langzaam lek dat in één request onzichtbaar blijft, kan zich over duizenden taken ophopen. PERFORMANCE-BUDGETS §1 benoemt deze faalmodus expliciet: een worker die veel PDF’s achter elkaar rendert, kan na uren het geheugen uitputten, zelfs wanneer afzonderlijke renders er goed uitzien.

NextPDF ondersteunt worker-omgevingen. DocumentFactory laat een worker voor elke taak een nieuw document aanmaken terwijl een proceslange FontRegistry en ImageRegistry worden gedeeld, zodat lettertypen en afbeeldingen één keer worden geparseerd in plaats van één keer per taak. ADR-001 legt vast dat de HTML-parser per request wordt geconstrueerd zonder statische muteerbare status, en dat toekomstige formatteringscontextobjecten dezelfde scoping per request moeten volgen. Met de volgende stappen configureert u een worker veilig.

Maak de registry’s één keer aan bij het opstarten van het proces en hergebruik ze voor elke taak, volgens 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');

De maxCacheBytes van de image registry begrenst de gedeelde cache, zodat die niet onbeperkt kan groeien over taken heen.

Stap 2 — Begrens de levensduur van de worker

Sectie met titel “Stap 2 — Begrens de levensduur van de worker”

Dit is algemene procesbeheerpraktijk voor elke PHP-worker, geen garantie van de NextPDF-engine: herstart workers periodiek, zodat een langlevend proces geen geheugen kan ophopen en niet onbeperkt verouderde code blijft uitvoeren. Beide grote PHP-queuesystemen bieden ingebouwde limieten en nette herstarts.

Voor Laravel queues (https://laravel.com/docs/12.x/queues) voert het commando queue:work de worker uit als langlevend proces. De gedocumenteerde opties zijn --memory (standaard 128 MB; de worker stopt wanneer zijn geheugen de limiet overschrijdt), --max-jobs (stop na een aantal taken) en --max-time (stop na een aantal seconden). Het commando queue:restart geeft workers het signaal om na de huidige taak netjes af te sluiten, zodat een deploy of periodieke timer ze kan recyclen zonder een lopende render te onderbreken. Laravel Horizon (https://laravel.com/docs/12.x/horizon) beheert Redis-workers met een auto-balanceringsstrategie en een nette php artisan horizon:terminate, die lopende taken voltooit voordat de procesmonitor de supervisor herstart.

Voor Symfony Messenger (https://symfony.com/doc/current/messenger.html) draait het commando messenger:consume standaard onbeperkt. De gedocumenteerde limietopties zijn --limit (verwerk N berichten en stop dan), --memory-limit (bijvoorbeeld 128M; stop wanneer het geheugen de limiet bereikt) en --time-limit (bijvoorbeeld 3600; stop na het interval). De Symfony-documentatie adviseert de worker onder Supervisor of systemd te draaien, zodat een afgesloten proces automatisch herstart, en messenger:stop-workers zet een cachevlag die elke worker opdraagt zijn huidige bericht af te ronden en netjes af te sluiten.

Geef bij elke deploy het signaal voor een nette herstart, zodat workers de nieuwe code oppakken: php artisan queue:restart (of php artisan horizon:terminate) voor Laravel, php bin/console messenger:stop-workers voor Symfony. De procesmanager — Supervisor, systemd of de Horizon/Octane-supervisor — start vervolgens een nieuw proces met de nieuwe codebase. Dit is algemene deploymentpraktijk voor langlevende PHP-workers en staat los van NextPDF.

Het streampad is ontworpen om het piekgeheugen te begrenzen door elke voltooide pagina te flushen en de cross-reference- en paginaboomadministratie weg te schrijven naar schijfondersteunde tijdelijke streams. Daardoor hoort de resident set niet mee te groeien met het aantal pagina’s. Dat gedrag is waargenomen in de geleverde 3.1.0-engine en vastgelegd door de golden-baseline-reproduceerbaarheidstests, maar het wordt beschreven als ontwerpgedrag en niet als een vast getal, omdat het profiel een eigenschap op experimental-niveau is. Het geheugen aan de invoerzijde van de HTML-pijplijn wordt begrensd door MAX_NESTING_DEPTH = 100 in plaats van door het aantal elementen (ADR-001). Alle concrete cijfers op deze pagina zijn gekoppeld aan een gedateerd artefact — de ADR-001-benchmark van 2026-04-06 en de PERFORMANCE-BUDGETS Cycle 36-baseline van 2026-05-17 — en zijn waargenomen op de opstellingen die in die documenten worden genoemd; behandel ze als observaties, niet als overdraagbare garanties. Het performance_budget van 1500 ms / 64 MB is de canvasmarge, geen contractuele bovengrens.

De writeContent() van de streaming cursor voegt bytes letterlijk toe aan de content stream van de pagina. De methode valideert de operatorsyntaxis niet. Geef in een worker die door de aanroeper beïnvloede inhoud rendert nooit niet-vertrouwde invoer door aan writeContent(); gebruik writeText(), die de geleverde cursor escapet voor de PDF-literal-stringgrammatica. De aanroeper is eigenaar van de uitvoerstream: de engine schrijft ernaar, maar sluit of heropent die nooit, zodat de engine de uitvoer niet kan omleiden. Een worker moet de handle zelf sluiten nadat de close() van de writer terugkeert, anders lekt er een bestandsdescriptor over taken heen. Het delen van registry’s tussen taken is een prestatieoptimalisatie, geen vertrouwensgrens: een gedeelde ImageRegistry cachet geparste afbeeldingen, dus stel de maxCacheBytes ervan weloverwogen in en ga niet uit van cache-isolatie tussen tenants in een multi-tenant worker.

BeweringStandaardClausuleBewijs
De streaming writer genereert een paginaboom waarvan de Kids-vermelding een array is van indirecte referenties naar de directe onderliggende knopen van de knoop.ISO 32000-2§7.7.3.2
De streaming writer genereert een Count-vermelding die gelijk is aan het aantal bladpaginaobjecten dat afstamt van de paginaboomknoop.ISO 32000-2§7.7.3.3

Clausules zijn geparafraseerd en glossarium-verankerd; er wordt geen normatieve tekst overgenomen.

  • Contracts / Streaming — de experimentalStreamingWriterInterface en CursorInterface, plus de bijbehorende toestandsmachine.
  • HTML / Streaming constraints (ADR-001) — de single-pass-beslissing zonder behouden DOM en de drempels voor herziening.
  • Performance — de regressiepoort voor latentie en geheugen van de HTML-pijplijn.
  • Layout — de pagina-elementengines die geen status per pagina aanhouden.
  • PERFORMANCE-BUDGETS — de faalmodus van de lekkende worker en de baseline voor de regressiepoort.