コンテンツにスキップ

長時間稼働するワーカーで PDF を安全にレンダリングする

長時間稼働する PHP ワーカー(RoadRunner、Swoole、Laravel Octane)は、多数のリクエストにわたってプロセスを維持します。リクエストごとに同じフォントを再解析し、同じイメージを再デコードすると、CPU を浪費し、常駐メモリが増大します。これを回避するために、NextPDF は 2 つのライフタイムを分離します。

  • プロセスライフタイム、共有: FontRegistryImageRegistry は、解析済みのフォントテーブルとデコード済みのイメージキャッシュを保持します。これらはワーカー起動時に一度だけ作成します。
  • リクエストライフタイム、使い捨て: Document。これは DocumentFactory::create() が返します。これを構築して書き出したら、スコープから外れるようにします。その後、ガベージコレクターがオブジェクトグラフ全体を回収します。

このレシピでは、ワーカーの起動シーケンス、リクエストごとの処理本体、およびピークメモリを一定に保つサイクルごとのリセットを示します。

Terminal window
composer require nextpdf/core:^3

ワーカーパターン自体に追加の拡張機能は不要で、ワーカーランタイム(RoadRunner / Swoole / Octane)は任意です。同じファクトリパターンは通常の CLI の for ループでも動作し、これはハーネスが実行している内容とまったく同じです。

ワーカーで推奨されるエントリポイントは DocumentFactory です。共有する FontRegistryImageRegistry を使用して、一度だけ構築します。

  • FontRegistry::warmup() は、指定したフォントファイルを解析し、解析済みのテーブルをキャッシュします。続いて FontRegistry::lock() がレジストリを凍結するため、リクエストごとのコードは共有フォントセットを変更できなくなります。isLocked() は現在の状態を報告します。ロック後は、レジストリを並行するコルーチン間で安全に共有できます。
  • 作成時に ImageRegistrymaxCacheBytes の予算を指定します。予算を超過すると、最も長く使用されていないエントリを追い出します。予算より大きな単一のイメージは、キャッシュをスラッシングさせる代わりに、キャッシュを完全にバイパスします。
  • ImageRegistry::reset() は、レジストリを完全に機能させたまま、キャッシュされたすべてのイメージを追い出します。次のリクエストが、必要に応じてキャッシュを再充填します。ハイウォーターマークをベースラインに戻すため、一定の間隔(N リクエストごと、または memoryUsage() がしきい値を超えたとき)で呼び出します。

ファクトリが作成する各ドキュメントは、独立した PDF です。ISO 32000-2 §7.5.5 は、一度も更新されていないファイルのトレーラーには Prev エントリがないと定義しており、すべてのワーカーリクエストはこの条件に該当する第一世代のファイルを生成します。したがって、リクエストはフォントキャッシュとイメージキャッシュを共有しますが、ドキュメントの状態は共有しません。サブセットフォントの BaseFont タグ(ISO 32000-2 §9.6.4)は、解析済みフォントが共有レジストリに存在するため、リクエスト間で安定したままです。

このレシピの API サーフェスは、NextPDF\Core\DocumentFactoryNextPDF\Typography\FontRegistryNextPDF\Graphics\ImageRegistry、および NextPDF\Support\MemoryReport の PHPDoc から生成されています。以下で使用する主なメンバーは、DocumentFactory::create()FontRegistry::warmup() / lock() / isLocked() / memoryUsage()ImageRegistry::reset() / memoryUsage()、および MemoryReport::$currentBytes / $peakBytes / $entryCount / utilizationPercent() です。

<?php
declare(strict_types=1);
require_once __DIR__ . '/vendor/autoload.php';
use NextPDF\Core\DocumentFactory;
use NextPDF\Graphics\ImageRegistry;
use NextPDF\Typography\FontRegistry;
// --- Worker boot (run ONCE, before the request loop) ---------------------
$fonts = new FontRegistry();
$fonts->lock(); // freeze the shared font set
$images = new ImageRegistry(maxCacheBytes: 50 * 1024 * 1024);
$factory = new DocumentFactory($fonts, $images);
// --- Per request ---------------------------------------------------------
$doc = $factory->create();
$doc->setTitle('Worker output');
$doc->addPage();
$doc->setFont('helvetica', 'B', 16);
$doc->cell(0, 12, 'Generated in a shared-registry worker', newLine: true);
$doc->save(getenv('NEXTPDF_COOKBOOK_OUTPUT') ?: __DIR__ . '/out.pdf');
// $doc leaves scope here → GC reclaims the whole document tree.

完全な例は、ハーネスの出力チャネルを尊重します。起動シーケンス、上限付きのリクエストループ、サイクルごとの reset()、およびメモリのハイウォーターのアサーションを示します。これは、再現性ハーネスが 2 回実行するスクリプトです。

<?php
declare(strict_types=1);
require_once __DIR__ . '/vendor/autoload.php';
use NextPDF\Core\DocumentFactory;
use NextPDF\Graphics\ImageRegistry;
use NextPDF\Typography\FontRegistry;
// --- Worker boot: shared, process-lifetime registries --------------------
$fonts = new FontRegistry();
$fonts->lock(); // share-safe once locked
$images = new ImageRegistry(maxCacheBytes: 50 * 1024 * 1024);
$factory = new DocumentFactory($fonts, $images);
$resetEvery = 4; // reset cadence in requests
$peakAfterReset = 0;
// --- Simulated request loop ---------------------------------------------
for ($request = 1; $request <= 12; $request++) {
$doc = $factory->create();
$doc->setTitle("Worker Request #{$request}");
$doc->addPage();
$doc->setFont('helvetica', 'B', 16);
$doc->cell(0, 12, "Worker Request #{$request}", newLine: true);
$doc->setFont('helvetica', '', 11);
$doc->cell(0, 8, 'Shared FontRegistry / ImageRegistry across requests.', newLine: true);
// The harness captures the LAST request's PDF via the side channel.
if ($request === 12) {
$doc->save(getenv('NEXTPDF_COOKBOOK_OUTPUT') ?: __DIR__ . '/out.pdf');
} else {
$doc->getPdfData(); // force render, then drop
}
unset($doc); // explicit end-of-request
// Bound the cache high-water mark on a fixed cadence.
if ($request % $resetEvery === 0) {
$images->reset();
\gc_collect_cycles();
$report = $images->memoryUsage();
$peakAfterReset = \max($peakAfterReset, $report->currentBytes);
}
}
$final = $images->memoryUsage();
fwrite(STDERR, \sprintf(
"fonts.locked=%s images.entries=%d images.current=%dB peak_after_reset=%dB\n",
$fonts->isLocked() ? 'yes' : 'no',
$final->entryCount,
$final->currentBytes,
$peakAfterReset,
));

STDOUT はハーネス用に空けたままにします。進捗テキストは STDERR に出力されます。PDF は NEXTPDF_COOKBOOK_OUTPUT にのみ書き込まれ、エコー出力されることはありません。

  • 共有する前にロックします。 起動時に FontRegistry::lock() を呼び出します。2 つのコルーチンがアクセスした時点で変更可能なままのレジストリは、データ競合になります。ヘルスチェックのアサーションとして isLocked() を使用します。
  • reset()unset() ではありません。 ImageRegistry::reset() はキャッシュされたバイナリデータを追い出しますが、レジストリは使用可能なままに保つため、これが正しい定期的な呼び出しです。リクエストごとにレジストリを破棄して再構築すると、共有キャッシュの意義そのものが失われます。
  • 特大イメージのバイパス。 maxCacheBytes より大きなイメージは使用するたびにデコードされ、キャッシュされることはないため、ワーキングセットを追い出すことはありません。これは意図的なものです。予算は、まれに現れる大きなイメージではなく、一般的なイメージに合わせてサイズを決めます。
  • ドキュメントはスコープから外れる必要があります。 Document を static、長寿命のコンテナバインディング、またはワーカーがキャプチャしたクロージャ内に保持すると、オブジェクトグラフ全体が生存し続け、リクエストごとの回収が妨げられます。unset() の呼び出し、またはスコープからの脱出が必須です。
  • gc_collect_cycles() の配置。 PHP のサイクルコレクターは、リクエストの境界を認識しません。リクエストごとではなく、リセットのタイミング後に呼び出します。これにより、ホットパスで回収コストを払うことなく、ハイウォーターマークを上限内に保てます。
  • 決定性に関する注意。 ドキュメントのタイムスタンプとトレーラーの /ID は、保存のたびに再生成されます(ISO 32000-2 §14.3)。そのため、キャプチャされた PDF は セマンティック プロファイル(構造 AST とメタデータであり、変動するバイト列ではない)と比較されます。「適合性」を参照してください。
  • 共有レジストリは、繰り返されるフォント解析とイメージデコードを、起動時の一度きりのコストに変換します。その結果、リクエストごとの作業はレイアウトとシリアライズに絞られます。
  • ピーク常駐メモリは、maxCacheBytes に、処理中の 1 つのドキュメントのワーキングセットを加えた値で上限が決まります。サイクルごとの reset() はキャッシュをベースラインに戻すため、長寿命のワーカーでも右肩上がりの鋸歯状パターンにはなりません。
  • この performance_budget フロントマター(wall_ms: 4000peak_mb: 192)は、12 リクエストループのハーネス実行に上限を設けます。ハーネスはこれを強制します。これは任意のドキュメントに対する保証ではありません。
  • このレシピは、#31 に対する §4.3 ギャップリストの「memory/GC」カバレッジを提供します。背後にある examples/14-worker-factory.php が存在し、新しい tests/Cookbook/Php/WorkerSafeBatchRenderingRecipeTest.php が、欠落していた memory/GC のアサーション(リセット後、ピークがサイクル間で増大しない)を追加します。
  • ワーカーパターンは、リクエストごとに 1 つのドキュメントを処理し、解析済みフォントとデコード済みイメージのキャッシュのみを共有します。ドキュメントの内容がリクエストの境界を越えることはありません。あるリクエストが、共有レジストリを通じて別のリクエストのドキュメントデータを読み取ることはできません。
  • 信頼できない入力は、通常の NextPDF の入力境界を通過し、ワーカーパターンは検証を一切緩和しません。各リクエストの HTML / アセット入力は、リクエストごとのプロセスの場合とまったく同じように、信頼できないものとして扱います。
記述仕様箇条リファレンス ID
ドキュメントの更新日時は保存のたびに再生成されるため、リクエストごとの出力はバイト単位では安定しません。ISO 32000-2§14.3
各ワーカードキュメントは一度も更新されていないファイルです(トレーラーに Prev がありません)。リクエストはドキュメント状態を共有しません。ISO 32000-2§7.5.5
サブセットフォントのタグプレフィックスは、解析済みフォントが共有レジストリに存在するため、リクエスト間で安定しています。ISO 32000-2§9.6.4

トレーラーの /ID と更新日時は保存のたびに再生成されるため、このレシピは セマンティック 再現性プロファイル(構造 AST の同一性とメタデータのみの比較)で検証されます。ワーカー出力について、ビット単位または構造的な主張を行うのは誠実ではありません。