Streaming e memória: tutorial de profiling e batch worker
Visão geral
Seção intitulada “Visão geral”O NextPDF renderiza em uma única passagem e nunca mantém um Document Object Model (DOM) no nível do documento. Assim, a memória do lado da entrada é limitada pela profundidade de aninhamento, não pela contagem de elementos. Esta página explica o modelo de streaming, as restrições do Architecture Decision Record (ADR)-001 e como executar o motor com segurança em um queue worker de longa duração.
Instalação
Seção intitulada “Instalação”composer require nextpdf/core:^3Visão conceitual
Seção intitulada “Visão conceitual”O NextPDF tem dois caminhos de escrita com perfis de memória diferentes.
O writer padrão em memória compõe o documento inteiro e, depois, o serializa. O pico de memória acompanha o tamanho total da saída. Isso funciona bem para documentos típicos, mas pode sair caro em documentos muito grandes.
O writer de streaming serializa cada página à medida que ela é composta e, depois, faz o flush dela antes que a próxima página comece. O motor distribuído — StreamingPdfWriter, StreamingCursor, DevNullWriter e o enum WriterState em src/Writer/Streaming/ — é real, definitivo, testado e distribuído desde a 3.1.0. Ele é exposto pelos contratos de nível experimental StreamingWriterInterface e CursorInterface. As classes do motor são internas; portanto, dependa dos contratos e deixe o Core fornecer a implementação. (Uma anotação anterior em .ai/contracts-map.md descreveu incorretamente o streaming como “contract-only / no implementation”; esse defeito de anotação desatualizada está registrado na issue #610 e foi corrigido na documentação de contratos B1 — o motor é distribuído desde a 3.1.0.)
O motor de streaming foi projetado para que a memória residente não cresça com a contagem de páginas. O buffer de cada página finalizada é entregue ao writer e liberado. A tabela de referência cruzada e as referências da árvore de páginas /Kids são gravadas em streams temporários php://temp/maxmemory:0, que transbordam para o disco imediatamente em vez de se acumularem no heap do PHP. O resultado serializado é uma árvore de páginas padrão cuja entrada Count é o número de nós folha (objetos de página) descendentes de um nó (ISO 32000-2 §7.7.3.3) e cuja entrada Kids é um array de referências indiretas aos filhos imediatos desse nó (ISO 32000-2 §7.7.3.2). O perfil exato de memória é uma propriedade de nível experimental e pode mudar entre releases menores; por isso, não fixe uma suposição com base em uma única medição.
A ADR-001 rege o modelo de memória do pipeline de renderização HTML. O tokenizer produz uma lista de tokens em uma única passagem. O parser consome essa lista da esquerda para a direita e emite operadores de content stream em um buffer de string. Nenhuma árvore de elementos persistente é construída: o parser mantém no máximo um HtmlStyleState por nível de aninhamento, limitado por MAX_NESTING_DEPTH = 100, e impõe o limite rígido MAX_ELEMENT_COUNT = 50_000. As duas operações que precisam de lookahead — o dimensionamento de colunas de tabela e a família de seletores :has() / :last-child — usam arrays de índice de pré-varredura limitados sobre a lista plana de tokens, não um DOM retido. O benchmark da Fase 0 (docs/architecture/adr-001-memory-benchmark.md, executado em 2026-04-06, PHP 8.5.3, memory_limit=1G) mediu um documento de 50,000 elementos com pico de 50 MB no caminho de stream, contra uma simulação de trabalho parcial retido de 4 MB. O relatório atribui cerca de 50 MB disso ao content stream acumulado, invariante de arquitetura, e isola uma vantagem de 4–5x no lado da entrada para o modelo de stream nesse fixture. Esses números foram observados naquela única máquina e nesse fixture; não são garantidos.
Faça profiling de memória antes de ajustar
Seção intitulada “Faça profiling de memória antes de ajustar”Meça antes de mudar qualquer coisa. O pipeline HTML é controlado por tools/perf-benchmark.php (executado via composer ai:perf-check), que reporta peak_memory_delta_bytes — o pico incremental por alvo usado como eixo de regressão, não o pico absoluto do processo. A baseline do Cycle 36 (docs/architecture/PERFORMANCE-BUDGETS.md §6.3, capturada em 2026-05-17 em um i9-13900K, 64 GB, PHP 8.5.3, opcache desligado) observou um delta de pico de 0 byte em 12 de 16 pares target/mode. Os quatro deltas diferentes de zero foram atribuídos a alocações de cache de fontes e de trace buffer no primeiro acesso, que permanecem constantes em renderizações posteriores. Leia esses dados como valores observados naquela máquina, não como constantes portáveis. Para um profile ad hoc do seu próprio documento, colete memory_get_peak_usage(true) antes e depois da renderização e redefina o pico com memory_reset_peak_usage() entre as iterações, do mesmo modo que o benchmark isola o custo por alvo.
Execute o NextPDF em um batch worker
Seção intitulada “Execute o NextPDF em um batch worker”Um queue worker é um processo PHP de longa duração: ele inicializa o framework uma vez, permanece residente e processa jobs em um loop. É isso que o torna rápido e também é por isso que a higiene de memória importa. Um vazamento lento, invisível em uma única requisição, pode se acumular ao longo de milhares de jobs. A PERFORMANCE-BUDGETS §1 nomeia esse modo de falha explicitamente: um worker que renderiza muitos PDFs em sequência pode esgotar a memória depois de horas, mesmo quando as renderizações individuais parecem saudáveis.
O NextPDF oferece suporte a ambientes de worker. O DocumentFactory permite que um worker crie um documento novo para cada job e, ao mesmo tempo, compartilhe um FontRegistry e um ImageRegistry durante toda a vida do processo, de modo que o parsing de fontes e imagens aconteça uma vez, não uma vez por job. A ADR-001 registra que o parser HTML é construído por requisição, sem estado mutável estático, e que futuros objetos de contexto de formatação devem seguir o mesmo escopo por requisição. As etapas a seguir configuram um worker com segurança.
Etapa 1 — Compartilhe os registries entre os jobs
Seção intitulada “Etapa 1 — Compartilhe os registries entre os jobs”Crie os registries uma vez na inicialização do processo e reutilize-os em cada job, seguindo 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');O maxCacheBytes do image registry limita o cache compartilhado, impedindo que ele cresça sem limite ao longo dos jobs.
Etapa 2 — Limite o tempo de vida do worker
Seção intitulada “Etapa 2 — Limite o tempo de vida do worker”Esta é uma prática geral de controle de processos para qualquer worker PHP, não uma garantia do motor do NextPDF: reinicie os workers periodicamente para impedir que um processo de longa duração acumule memória ou continue executando código desatualizado indefinidamente. Os dois principais sistemas de filas PHP fornecem limites integrados e reinicializações graciosas.
Para as filas do Laravel (https://laravel.com/docs/12.x/queues), o comando queue:work executa o worker como um processo de longa duração. As opções documentadas são --memory (padrão de 128 MB; o worker encerra quando sua memória excede o limite), --max-jobs (encerra após um número de jobs) e --max-time (encerra após um número de segundos). O comando queue:restart sinaliza aos workers que eles devem encerrar graciosamente após o job atual, permitindo que um deploy ou um timer periódico os recicle sem interromper uma renderização em andamento. O Laravel Horizon (https://laravel.com/docs/12.x/horizon) supervisiona workers Redis com uma estratégia de balanceamento auto e um php artisan horizon:terminate gracioso, que finaliza os jobs em andamento antes que o monitor de processos reinicie o supervisor.
Para o Symfony Messenger (https://symfony.com/doc/current/messenger.html), o comando messenger:consume executa indefinidamente por padrão. As opções de limite documentadas são --limit (processa N mensagens e então encerra), --memory-limit (por exemplo 128M; encerra quando a memória atinge o limite) e --time-limit (por exemplo 3600; encerra após o intervalo). A documentação do Symfony recomenda executar o worker sob o Supervisor ou o systemd, para que um processo encerrado reinicie automaticamente, e o messenger:stop-workers define um flag de cache que instrui cada worker a finalizar sua mensagem atual e encerrar de forma limpa.
Etapa 3 — Reinicie no deploy
Seção intitulada “Etapa 3 — Reinicie no deploy”Em cada deploy, sinalize uma reinicialização graciosa para que os workers carreguem o novo código: php artisan queue:restart (ou php artisan horizon:terminate) para o Laravel, php bin/console messenger:stop-workers para o Symfony. O gerenciador de processos — Supervisor, systemd ou o supervisor do Horizon/Octane — então inicia um novo processo com a nova base de código. Esta é uma prática geral de deploy para workers PHP de longa duração e é independente do NextPDF.
Performance
Seção intitulada “Performance”O caminho de streaming foi projetado para limitar o pico de memória fazendo o flush de cada página concluída e transbordando o controle de referência cruzada e da árvore de páginas para streams temporários apoiados em disco. Como resultado, o resident set foi pensado para não crescer com a contagem de páginas. Esse comportamento é observado no motor 3.1.0 distribuído e fixado por seus testes de reprodutibilidade de golden baseline, mas é declarado como comportamento de design, e não como um número fixo, porque o perfil é uma propriedade de nível experimental. A memória do lado da entrada do pipeline HTML é limitada por MAX_NESTING_DEPTH = 100, e não pela contagem de elementos (ADR-001). Todos os números concretos nesta página estão atrelados a um artefato datado — o benchmark da ADR-001 de 2026-04-06 e a baseline do Cycle 36 da PERFORMANCE-BUDGETS de 2026-05-17 — e foram observados nas máquinas nomeadas por esses documentos; trate-os como observações, não como garantias portáveis. O performance_budget de 1500 ms / 64 MB é o envelope do canvas, não um limite contratual.
Notas de segurança
Seção intitulada “Notas de segurança”O writeContent() do cursor de streaming anexa bytes ao content stream da página de forma literal. Ele não valida a sintaxe dos operadores. Em um worker que renderiza conteúdo influenciado pelo chamador, nunca passe entrada não confiável para writeContent(); use writeText(), para o qual o cursor distribuído faz o escape de acordo com a gramática de literal-string do PDF. O chamador é dono do stream de saída: o motor grava nele, mas nunca o fecha nem o reabre, portanto não pode redirecionar a saída. Um worker deve fechar o handle por conta própria depois que o close() do writer retornar; caso contrário, ele vaza um file descriptor entre os jobs. Compartilhar registries entre jobs é uma otimização de performance, não uma fronteira de confiança: um ImageRegistry compartilhado armazena imagens já processadas em cache, então dimensione seu maxCacheBytes de forma deliberada e não presuma isolamento de cache entre tenants em um worker multitenant.
Conformidade
Seção intitulada “Conformidade”| Afirmação | Padrão | Cláusula | Evidência |
|---|---|---|---|
O writer de streaming emite uma árvore de páginas cuja entrada Kids é um array de referências indiretas aos filhos imediatos do nó. | ISO 32000-2 | §7.7.3.2 | |
O writer de streaming emite uma entrada Count igual ao número de objetos de página folha descendentes do nó da árvore de páginas. | ISO 32000-2 | §7.7.3.3 |
As cláusulas são parafraseadas e fixadas a um glossário; nenhum texto normativo foi reproduzido.
Veja também
Seção intitulada “Veja também”- Contratos / Streaming — a
experimentalStreamingWriterInterfacee aCursorInterface, além da respectiva máquina de estados. - HTML / Restrições de streaming (ADR-001) — a decisão de passagem única, sem DOM retido, e os limiares de reavaliação.
- Performance — o gate de regressão de latência e de memória do pipeline HTML.
- Layout — os motores de mobiliário de página que não mantêm estado por página.
- PERFORMANCE-BUDGETS — o modo de falha do worker com vazamento e a baseline do gate de regressão.