Salta ai contenuti

Streaming e memoria: guida alla profilazione e ai batch worker

NextPDF esegue il rendering in un singolo passaggio e non mantiene mai un DOM a livello di documento; di conseguenza, la memoria lato input resta limitata dalla profondità di annidamento, non dal numero di elementi. Questa pagina illustra il modello di streaming, i vincoli imposti da ADR-001 e come eseguire il motore in modo sicuro all’interno di un queue worker a lunga esecuzione.

Terminal window
composer require nextpdf/core:^3

NextPDF offre due percorsi di scrittura con profili d’uso della memoria diversi.

Il writer in memoria predefinito compone l’intero documento e poi lo serializza. La memoria di picco cresce in funzione della dimensione totale dell’output: è una scelta adeguata per documenti tipici, ma onerosa per quelli molto grandi.

Il writer in streaming serializza ogni pagina man mano che viene composta e ne esegue il flush prima dell’inizio della pagina successiva. Il motore distribuito — StreamingPdfWriter, StreamingCursor, DevNullWriter e l’enum WriterState in src/Writer/Streaming/ — è reale, definitivo e testato, ed è disponibile sin dalla versione 3.1.0. È esposto tramite i contratti di livello experimental StreamingWriterInterface e CursorInterface. Le classi del motore sono interne; occorre quindi dipendere dal contratto e lasciare che sia Core a fornire l’implementazione. (Una precedente annotazione in .ai/contracts-map.md descriveva erroneamente lo streaming come «solo contratto / nessuna implementazione»; si tratta di un difetto di annotazione obsoleta tracciato nell’issue #610 e corretto nei documenti di contratto B1 — il motore è distribuito sin dalla versione 3.1.0.)

L’obiettivo di progettazione del motore di streaming è evitare che la memoria residente cresca con il numero di pagine. Il buffer di ogni pagina finalizzata viene passato al writer e rilasciato, mentre la tabella di riferimenti incrociati e i riferimenti dell’albero delle pagine /Kids vengono scritti in flussi temporanei php://temp/maxmemory:0, che riversano subito su disco invece di accumularsi nell’heap di PHP. Il risultato serializzato è un albero delle pagine standard la cui voce Count è il numero di nodi foglia (oggetti pagina) discendenti da un nodo (ISO 32000-2 §7.7.3.3) e la cui voce Kids è un array di riferimenti indiretti ai figli immediati di tale nodo (ISO 32000-2 §7.7.3.2). Il profilo di memoria esatto è una proprietà di livello experimental e può variare tra le release minori: non fissare nel codice un’ipotesi basata su una singola misurazione.

ADR-001 disciplina il modello di memoria della pipeline di rendering HTML. Il tokenizer produce un elenco di token in un singolo passaggio; il parser lo consuma da sinistra a destra ed emette operatori del flusso di contenuto in un buffer di stringa. Non viene costruito alcun albero di elementi persistente: il parser mantiene al massimo un HtmlStyleState per livello di annidamento, limitato da MAX_NESTING_DEPTH = 100, e applica un limite rigido MAX_ELEMENT_COUNT = 50_000. Le due operazioni che richiedono lookahead — il dimensionamento delle colonne di tabella e la famiglia di selettori :has() / :last-child — utilizzano array di indici di pre-scansione limitati sull’elenco piatto di token, non un DOM mantenuto in memoria. Il benchmark di Fase 0 (docs/architecture/adr-001-memory-benchmark.md, eseguito il 2026-04-06, PHP 8.5.3, memory_limit=1G) ha misurato un documento di 50,000 elementi con un picco di 50 MB per il percorso in streaming, a fronte dei 4 MB di una simulazione con lavoro parziale mantenuto. L’analisi del report attribuisce circa 50 MB di tale valore al flusso di contenuto accumulato, invariante rispetto all’architettura, e isola un vantaggio lato input di 4–5x per il modello in streaming su quella fixture. Queste cifre sono state osservate su quella specifica configurazione e fixture; non costituiscono una garanzia.

Misurare prima di modificare qualsiasi cosa. La pipeline HTML è regolata da tools/perf-benchmark.php (eseguito tramite composer ai:perf-check), che riporta peak_memory_delta_bytes: il picco incrementale per target usato come asse di regressione, non il picco assoluto del processo. La baseline del Ciclo 36 (docs/architecture/PERFORMANCE-BUDGETS.md §6.3, acquisita il 2026-05-17 su un i9-13900K, 64 GB, PHP 8.5.3, opcache disattivato) ha osservato un delta di picco di 0 byte su 12 delle 16 coppie target/mode; i quattro delta non nulli sono attribuiti ad allocazioni di cache dei font e di buffer di trace al primo accesso, che rimangono costanti nei rendering successivi. Interpretare questi valori come osservazioni relative a quella configurazione, non come costanti trasferibili. Per una profilatura ad hoc del proprio documento, campionare memory_get_peak_usage(true) prima e dopo il rendering e reimpostare il picco con memory_reset_peak_usage() tra un’iterazione e l’altra, come fa il benchmark per isolare il costo per target.

Un queue worker è un processo PHP a lunga esecuzione: avvia il framework una sola volta e rimane residente, gestendo i job in un ciclo. È questo a renderlo veloce e, allo stesso tempo, a rendere essenziale l’igiene della memoria. Una perdita lenta, invisibile in una singola richiesta, si accumula nell’arco di migliaia di job. PERFORMANCE-BUDGETS §1 indica esplicitamente questa modalità di guasto: un worker che esegue il rendering di molti PDF in successione può esaurire la memoria dopo ore, anche quando i singoli rendering sembrano regolari.

NextPDF supporta gli ambienti worker. DocumentFactory consente a un worker di creare un nuovo documento per ogni job condividendo un FontRegistry e un ImageRegistry per l’intera durata del processo, in modo che l’analisi di font e immagini avvenga una sola volta anziché per ogni job. ADR-001 documenta che il parser HTML viene costruito per ogni richiesta senza stato statico mutabile e che i futuri oggetti di contesto di formattazione dovranno seguire lo stesso scoping per richiesta. I passaggi seguenti configurano un worker in modo sicuro.

Creare i registry una sola volta all’avvio del processo e riutilizzarli per ogni job, seguendo 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');

Il maxCacheBytes del registry delle immagini limita la cache condivisa, impedendole di crescere senza controllo tra un job e l’altro.

Si tratta di una pratica generale di controllo dei processi per qualsiasi worker PHP, non di una garanzia del motore NextPDF: riavviare periodicamente i worker impedisce a un processo a lunga esecuzione di accumulare memoria o di eseguire codice obsoleto a tempo indefinito. Entrambi i principali sistemi di code PHP forniscono limiti integrati e riavvii controllati.

Per le code di Laravel (https://laravel.com/docs/12.x/queues), il comando queue:work esegue il worker come processo a lunga esecuzione. Le opzioni documentate sono --memory (predefinito 128 MB; il worker termina quando la sua memoria supera il limite), --max-jobs (termina dopo un certo numero di job) e --max-time (termina dopo un certo numero di secondi). Il comando queue:restart segnala ai worker di terminare in modo controllato al completamento del job corrente, così un deploy o un timer periodico può riciclarli senza interrompere un rendering in corso. Laravel Horizon (https://laravel.com/docs/12.x/horizon) supervisiona i worker Redis con una strategia di bilanciamento auto e un php artisan horizon:terminate controllato, che porta a termine i job in corso prima che il monitor di processo riavvii il supervisore.

Per Symfony Messenger (https://symfony.com/doc/current/messenger.html), il comando messenger:consume viene eseguito indefinitamente per impostazione predefinita. Le opzioni di limite documentate sono --limit (gestisce N messaggi e poi termina), --memory-limit (ad esempio 128M; termina quando la memoria raggiunge il limite) e --time-limit (ad esempio 3600; termina dopo l’intervallo). La documentazione di Symfony consiglia di eseguire il worker sotto Supervisor o systemd, in modo che un processo terminato venga riavviato automaticamente; messenger:stop-workers imposta un flag di cache che indica a ogni worker di terminare il messaggio corrente e uscire in modo pulito.

A ogni deploy, segnalare un riavvio controllato affinché i worker recepiscano il nuovo codice: php artisan queue:restart (o php artisan horizon:terminate) per Laravel, php bin/console messenger:stop-workers per Symfony. Il gestore dei processi — Supervisor, systemd o il supervisore Horizon/Octane — avvia quindi un nuovo processo sulla nuova codebase. È una pratica generale di deploy per i worker PHP a lunga esecuzione ed è indipendente da NextPDF.

La progettazione del percorso in streaming limita la memoria di picco eseguendo il flush di ogni pagina completata e riversando le informazioni di gestione dei riferimenti incrociati e dell’albero delle pagine in flussi temporanei basati su disco, così che l’insieme residente non sia destinato a crescere con il numero di pagine. Questo comportamento è stato osservato nel motore 3.1.0 distribuito ed è fissato dai relativi test di riproducibilità con baseline golden, ma va letto come comportamento di progettazione anziché come valore fisso, poiché il profilo è una proprietà di livello experimental. La memoria lato input della pipeline HTML è limitata da MAX_NESTING_DEPTH = 100 anziché dal numero di elementi (ADR-001). Tutti i valori concreti in questa pagina sono associati a un artefatto datato — il benchmark ADR-001 del 2026-04-06 e la baseline del Ciclo 36 di PERFORMANCE-BUDGETS del 2026-05-17 — e sono stati osservati sulle configurazioni indicate in tali documenti; interpretarli come osservazioni, non come garanzie trasferibili. Il performance_budget di 1500 ms / 64 MB è l’intervallo previsto per il canvas, non un limite contrattuale.

Il metodo writeContent() del cursore in streaming aggiunge i byte al flusso di contenuto della pagina così come sono: non convalida la sintassi degli operatori. In un worker che esegue il rendering di contenuti influenzati dal chiamante, non passare mai input non attendibili a writeContent(); usare writeText(), per cui il cursore distribuito esegue l’escape secondo la grammatica delle stringhe letterali PDF. Il flusso di output è di proprietà del chiamante: il motore vi scrive ma non lo chiude né lo riapre mai, pertanto non può reindirizzare l’output. Un worker deve chiudere autonomamente l’handle dopo il ritorno del metodo close() del writer, altrimenti perde un file descriptor tra un job e l’altro. La condivisione dei registry tra i job è un’ottimizzazione delle prestazioni, non un confine di attendibilità: un ImageRegistry condiviso memorizza nella cache le immagini analizzate; pertanto, dimensionare deliberatamente il relativo maxCacheBytes e non presumere l’isolamento della cache tra tenant in un worker multi-tenant.

AsserzioneStandardClausolaEvidenza
Il writer in streaming produce un albero delle pagine la cui voce Kids è un array di riferimenti indiretti ai figli immediati del nodo.ISO 32000-2§7.7.3.2
Il writer in streaming produce una voce Count pari al numero di oggetti pagina foglia discendenti dal nodo dell’albero delle pagine.ISO 32000-2§7.7.3.3

Le clausole sono parafrasate e ancorate al glossario; non viene riprodotto alcun testo normativo.

  • Contratti / StreamingexperimentalStreamingWriterInterface, CursorInterface e la relativa macchina a stati.
  • HTML / Vincoli di streaming (ADR-001) — la decisione a passaggio singolo, senza DOM mantenuto, e le soglie di revisione.
  • Prestazioni — il gate di regressione per latenza e memoria della pipeline HTML.
  • Layout — i motori degli elementi di pagina che non mantengono stato per pagina.
  • PERFORMANCE-BUDGETS — la modalità di guasto del worker con perdite di memoria e la baseline del gate di regressione.