ストリーミングとメモリ:プロファイリングとバッチワーカーのチュートリアル
NextPDF はシングルパスでレンダリングし、ドキュメントレベルの DOM を一切保持しないため、入力側のメモリ使用量は要素数ではなくネストの深さによって抑えられます。このページでは、ストリーミングモデル、ADR-001 が何を制約するか、長時間稼働するキューワーカー内でエンジンを安全に実行する方法を解説します。
インストール
「インストール」という見出しのセクションcomposer require nextpdf/core:^3概念の概観
「概念の概観」という見出しのセクションNextPDF には、メモリプロファイルが異なる 2 つの書き込みパスがあります。
デフォルトのインメモリライターは、ドキュメント全体を組み立ててからシリアライズします。ピークメモリは出力全体のサイズに比例します。一般的なドキュメントでは問題ありませんが、非常に大きなドキュメントではコストが高くなります。
ストリーミングライターは、各ページの組み立てと同時にシリアライズし、次のページが始まる前にフラッシュします。出荷済みのエンジンである StreamingPdfWriter、StreamingCursor、DevNullWriter、および WriterState 列挙型(src/Writer/Streaming/ 内)は、実在し、最終版で、テスト済みであり、3.1.0 以降出荷されています。これらは experimental ティアの StreamingWriterInterface および CursorInterface コントラクトを通じて公開されています。エンジンのクラスは内部実装であるため、コントラクトに依存し、実装は Core に任せてください。(以前の .ai/contracts-map.md の注釈では、ストリーミングを「コントラクトのみ/実装なし」と誤って記述していました。これは issue #610 で追跡されている古い注釈の不具合であり、B1 コントラクトドキュメントで修正済みです。エンジンは 3.1.0 以降出荷されています。)
ストリーミングエンジンの設計目標は、常駐メモリがページ数に応じて増加しないことです。確定した各ページのバッファはライターに引き渡されて解放され、クロスリファレンステーブルと /Kids ページツリー参照は、PHP ヒープに蓄積されるのではなく、即座にディスクへ書き出される php://temp/maxmemory:0 の一時ストリームへ書き込まれます。シリアライズされた結果は標準的なページツリーであり、その Count エントリはあるノードの子孫であるリーフノード(ページオブジェクト)の数(ISO 32000-2 §7.7.3.3)を表し、その Kids エントリはそのノードの直接の子への間接参照の配列(ISO 32000-2 §7.7.3.2)です。正確なメモリプロファイルは experimental ティアのプロパティであり、マイナーリリース間で変動する可能性があります。1 回の計測から得た想定をハードコードしないでください。
ADR-001 は、HTML レンダリングパイプラインのメモリモデルを規定します。トークナイザーは 1 回のパスでトークンリストを生成し、パーサーはそれを左から右へ消費して、コンテンツストリームオペレーターを文字列バッファへ出力します。永続的な要素ツリーは構築されません。パーサーが保持するのはネストレベルごとに最大 1 つの HtmlStyleState のみで、MAX_NESTING_DEPTH = 100 によって制限され、MAX_ELEMENT_COUNT = 50_000 のハードキャップを強制します。先読みを必要とする 2 つの操作(テーブルのカラム幅計算と :has() / :last-child セレクターファミリー)では、保持された DOM ではなく、フラットなトークンリスト上の境界付き事前スキャンインデックス配列を使用します。Phase 0 ベンチマーク(docs/architecture/adr-001-memory-benchmark.md、2026-04-06 実行、PHP 8.5.3、memory_limit=1G)では、50,000 要素のドキュメントについて、ストリームパスでピーク 50 MB、部分作業を保持するシミュレーションで 4 MB を計測しました。レポートの分析では、そのうち約 50 MB をアーキテクチャ上の不変要素である累積コンテンツストリームに起因するものとし、そのフィクスチャにおけるストリームモデルの入力側の優位性を 4~5 倍として切り分けています。これらの数値は 1 つの環境とフィクスチャで観測されたものであり、保証ではありません。
チューニング前にメモリをプロファイリングする
「チューニング前にメモリをプロファイリングする」という見出しのセクション何かを変更する前に、必ず計測してください。HTML パイプラインは tools/perf-benchmark.php(composer ai:perf-check 経由で実行)によってゲートされ、peak_memory_delta_bytes(プロセス全体の絶対ピークではなく、リグレッションの基準軸となるターゲットごとの増分ピーク)を報告します。Cycle 36 ベースライン(docs/architecture/PERFORMANCE-BUDGETS.md §6.3、i9-13900K、64 GB、PHP 8.5.3、opcache オフで 2026-05-17 に取得)では、16 組の target/mode ペアのうち 12 組でピークデルタが 0 バイトと観測され、0 ではない 4 件のデルタは、その後のレンダリングでは一定に保たれる初回タッチ時のフォントキャッシュおよびトレースバッファの割り当てに起因するとされています。これらは、移植可能な定数ではなく、その環境で観測された値として読み取ってください。独自のドキュメントをアドホックにプロファイリングするには、ベンチマークがターゲットごとのコストを切り分けるのと同じ方法で、レンダリングの前後に memory_get_peak_usage(true) をサンプリングし、イテレーションの間に memory_reset_peak_usage() でピークをリセットします。
NextPDF をバッチワーカーで実行する
「NextPDF をバッチワーカーで実行する」という見出しのセクションキューワーカーは、長時間稼働する PHP プロセスです。フレームワークを 1 度だけ起動して常駐し、ループ内でジョブを処理します。これが高速な理由であり、同時にメモリ衛生が重要になる理由でもあります。単一のリクエストでは見えないわずかなリークでも、数千のジョブにわたって蓄積されます。PERFORMANCE-BUDGETS §1 は、この障害モードを明示的に挙げています。多数の PDF を連続してレンダリングするワーカーは、単一のレンダリングでは問題なく見えても、数時間後にメモリを使い果たす可能性があります。
NextPDF はワーカー環境をサポートしています。DocumentFactory を使うと、ワーカーはプロセス存続期間中の FontRegistry と ImageRegistry を共有しながら、ジョブごとに新しいドキュメントを作成できます。そのため、フォントと画像の解析はジョブごとではなく 1 度だけ行われます。ADR-001 は、HTML パーサーが静的な可変状態を持たずリクエストごとに構築されること、そして将来のフォーマッティングコンテキストオブジェクトも同じリクエスト単位のスコープに従わなければならないことを記録しています。以下の手順に従って、ワーカーを安全に構成してください。
ステップ 1 — ジョブ間でレジストリを共有する
「ステップ 1 — ジョブ間でレジストリを共有する」という見出しのセクションプロセス起動時にレジストリを 1 度だけ作成し、すべてのジョブで再利用します。手順は 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');画像レジストリの maxCacheBytes は共有キャッシュを制限し、ジョブをまたいで無制限に増えないようにします。
ステップ 2 — ワーカーの寿命を制限する
「ステップ 2 — ワーカーの寿命を制限する」という見出しのセクションこれは NextPDF エンジンの保証ではなく、あらゆる PHP ワーカーに対する一般的なプロセス制御のプラクティスです。長時間稼働するプロセスがメモリを蓄積したり、古いコードを無期限に実行し続けたりしないように、ワーカーを定期的に再起動してください。主要な 2 つの PHP キューシステムはいずれも、組み込みの上限値とグレースフルな再起動を提供しています。
まず Laravel queues(https://laravel.com/docs/12.x/queues)では、queue:work コマンドがワーカーを長時間稼働するプロセスとして実行します。ドキュメントに記載されているオプションは、--memory(デフォルト 128 MB。ワーカーはメモリが上限を超えると終了します)、--max-jobs(一定数のジョブの後に終了)、および --max-time(一定秒数の後に終了)です。queue:restart コマンドは、現在のジョブの完了後にグレースフルに終了するようワーカーへ通知します。そのため、デプロイや定期タイマーは、処理中のレンダリングを中断することなくワーカーをリサイクルできます。Laravel Horizon(https://laravel.com/docs/12.x/horizon)は、auto バランシング戦略と、グレースフルな php artisan horizon:terminate で Redis ワーカーを監督します。これにより、プロセスモニターがスーパーバイザーを再起動する前に、処理中のジョブを完了できます。
次に Symfony Messenger(https://symfony.com/doc/current/messenger.html)では、messenger:consume コマンドはデフォルトで永続的に実行されます。ドキュメントに記載されている上限オプションは、--limit(N 件のメッセージを処理して終了)、--memory-limit(たとえば 128M。メモリが上限に達したら終了)、および --time-limit(たとえば 3600。指定した間隔の後に終了)です。Symfony のドキュメントでは、終了したプロセスが自動的に再起動されるように、ワーカーを Supervisor または systemd 配下で実行することを推奨しています。また、messenger:stop-workers はキャッシュフラグを設定し、各ワーカーに現在のメッセージを完了してクリーンに終了するよう指示します。
ステップ 3 — デプロイ時に再起動する
「ステップ 3 — デプロイ時に再起動する」という見出しのセクションデプロイのたびに、ワーカーが新しいコードを取り込めるようにグレースフルな再起動を通知します。Laravel では php artisan queue:restart(または php artisan horizon:terminate)、Symfony では php bin/console messenger:stop-workers を使用します。するとプロセスマネージャー(Supervisor、systemd、または Horizon/Octane スーパーバイザー)が、新しいコードベースに対して新しいプロセスを起動します。これは長時間稼働する PHP ワーカーに対する一般的なデプロイプラクティスであり、NextPDF とは独立しています。
パフォーマンス
「パフォーマンス」という見出しのセクションストリーミングパスは、完了した各ページをフラッシュし、クロスリファレンスとページツリーの管理情報をディスクバックされた一時ストリームへ書き出すことで、ピークメモリを抑えるよう設計されています。そのため、常駐セットはページ数に応じて増加しないことを意図しています。これは出荷済みの 3.1.0 エンジンで観測され、ゴールデンベースラインの再現性テストで固定されていますが、プロファイルは experimental ティアのプロパティであるため、固定値ではなく設計上の挙動として記載しています。HTML パイプラインの入力側メモリは、要素数ではなく MAX_NESTING_DEPTH = 100 によって制限されます(ADR-001)。このページのすべての具体的な数値は、日付付きの成果物(2026-04-06 の ADR-001 ベンチマークと 2026-05-17 の PERFORMANCE-BUDGETS Cycle 36 ベースライン)に紐づいており、それらのドキュメントが示す環境で観測されたものです。移植可能な保証ではなく、観測値として扱ってください。1500 ms / 64 MB の performance_budget はキャンバスのエンベロープであり、契約上の上限ではありません。
セキュリティに関する注意
「セキュリティに関する注意」という見出しのセクションストリーミングカーソルの writeContent() は、バイト列をページコンテンツストリームへそのまま追記します。オペレーターの構文は検証しません。呼び出し元の影響を受けるコンテンツをレンダリングするワーカーでは、信頼できない入力を writeContent() に決して渡さないでください。PDF のリテラル文字列文法向けにエスケープする、出荷済みカーソルの writeText() を使用してください。出力ストリームは呼び出し元が所有します。エンジンはそこへ書き込みますが、クローズも再オープンも決してしないため、出力をリダイレクトできません。ワーカーはライターの close() が返った後に、自身でハンドルをクローズしなければなりません。そうしないと、ジョブをまたいでファイルディスクリプタがリークします。ジョブ間でレジストリを共有することはパフォーマンスの最適化であり、信頼境界ではありません。共有された ImageRegistry は解析済みの画像をキャッシュするため、その maxCacheBytes のサイズは意図的に設定し、マルチテナントワーカーではテナント間のキャッシュ分離を前提としないでください。
| 主張 | 規格 | 箇条 | エビデンス |
|---|---|---|---|
ストリーミングライターは、Kids エントリがノードの直接の子への間接参照の配列であるページツリーを出力します。 | ISO 32000-2 | §7.7.3.2 | |
ストリーミングライターは、ページツリーノードの子孫であるリーフページオブジェクトの数に等しい Count エントリを出力します。 | ISO 32000-2 | §7.7.3.3 |
箇条は言い換えられ、用語集にピン留めされています。規定文は一切再現していません。
- Contracts / Streaming —
experimentalStreamingWriterInterfaceとCursorInterface、およびそれらの状態マシンについて。 - HTML / Streaming constraints (ADR-001) — DOM を保持しないシングルパスの決定と、再検討のしきい値について。
- Performance — HTML パイプラインのレイテンシとメモリのリグレッションゲートについて。
- Layout — ページごとの状態を保持しないページファニチャーエンジンについて。
- PERFORMANCE-BUDGETS — リークするワーカーの障害モードと、リグレッションゲートのベースラインについて。