Zum Inhalt springen

PDFs in einem dauerhaft laufenden Worker sicher rendern

Ein dauerhaft laufender PHP-Worker (RoadRunner, Swoole, Laravel Octane) hält den Prozess über viele Requests hinweg aktiv. Dieselben Fonts bei jedem Request neu zu parsen und dieselben Bilder erneut zu dekodieren, verschwendet CPU-Zeit und lässt die Resident-Speichernutzung wachsen. Um das zu vermeiden, trennt NextPDF zwei Lebenszyklen:

  • Prozesslebensdauer, geteilt: FontRegistry und ImageRegistry halten geparste Font-Tabellen und dekodierte Bild-Caches. Erzeugen Sie sie einmal beim Worker-Boot.
  • Requestlebensdauer, verwerfbar: das Document, das DocumentFactory::create() zurückgibt. Bauen Sie es auf, schreiben Sie es und lassen Sie es aus dem Gültigkeitsbereich fallen. Der Garbage Collector gibt dann den gesamten Objektgraphen frei.

Dieses Recipe liefert Ihnen die Worker-Boot-Sequenz, den Ablauf pro Request und den Reset pro Zyklus, der die Speicherspitze niedrig hält.

Terminal-Fenster
composer require nextpdf/core:^3

Das Worker-Muster selbst erfordert keine zusätzliche Extension, und eine Worker-Laufzeit (RoadRunner / Swoole / Octane) ist optional. Dasselbe Factory-Muster läuft auch in einer reinen CLI-for-Schleife; genau diese Variante führt der Harness aus.

Der empfohlene Einstiegspunkt für Worker ist DocumentFactory. Konstruieren Sie sie einmal mit einer geteilten FontRegistry und ImageRegistry:

  • FontRegistry::warmup() parst die Font-Dateien, die Sie angeben, und speichert die geparsten Tabellen im Cache. FontRegistry::lock() friert die Registry anschließend ein, sodass kein Code pro Request den geteilten Font-Satz verändern kann. isLocked() meldet den aktuellen Zustand. Nach dem Sperren lässt sich die Registry sicher über nebenläufige Coroutines hinweg teilen.
  • Konstruiere ImageRegistry mit einem maxCacheBytes-Budget. Wird das Budget überschritten, verdrängt sie die am längsten nicht genutzten Einträge. Ein einzelnes Bild, das größer als das Budget ist, umgeht den Cache vollständig, statt ihn zu überlasten.
  • ImageRegistry::reset() entfernt jedes gecachte Bild und hält die Registry dabei voll funktionsfähig. Der nächste Request befüllt sie bei Bedarf neu. Rufen Sie sie in einem festen Takt auf (alle N Requests oder wenn memoryUsage() einen Schwellenwert überschreitet), um den Höchststand auf die Baseline zurückzubringen.

Jedes Document, das die Factory erzeugt, ist ein eigenständiges PDF. ISO 32000-2 §7.5.5 definiert den Trailer einer nie aktualisierten Datei so, dass er keinen Prev-Eintrag enthält, und jeder Worker-Request gibt genau eine solche Datei erster Generation aus. Requests teilen sich also keinen Document-Zustand, auch wenn sie die Font- und Bild-Caches gemeinsam nutzen. Das Subset-Font-Tag BaseFont (ISO 32000-2 §9.6.4) bleibt über Requests hinweg stabil, weil der geparste Font in der geteilten Registry lebt.

Die API-Oberfläche für dieses Recipe wird aus dem PHPDoc von NextPDF\Core\DocumentFactory, NextPDF\Typography\FontRegistry, NextPDF\Graphics\ImageRegistry und NextPDF\Support\MemoryReport generiert. Die unten verwendeten zentralen Member sind DocumentFactory::create(), FontRegistry::warmup() / lock() / isLocked() / memoryUsage(), ImageRegistry::reset() / memoryUsage() und 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.

Das vollständige Beispiel berücksichtigt den Ausgabekanal des Harness. Es zeigt die Boot-Sequenz, eine begrenzte Request-Schleife, den reset() pro Zyklus und eine Assertion für den Speicherhöchststand. Das ist das Skript, das der Reproduzierbarkeits-Harness zweimal ausführt.

<?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 bleibt frei für den Harness; Fortschrittstext geht an STDERR. Das PDF wird nur in NEXTPDF_COOKBOOK_OUTPUT geschrieben, niemals ausgegeben.

  • Sperren Sie, bevor Sie teilen. Rufen Sie FontRegistry::lock() beim Boot auf. Eine Registry, die noch veränderbar ist, wenn zwei Coroutines sie verwenden, ist ein Data Race. Nutzen Sie isLocked() als Assertion in einem Health-Check.
  • reset() ist nicht unset(). ImageRegistry::reset() entfernt gecachte Binärdaten, hält die Registry aber nutzbar; daher ist es der richtige periodische Aufruf. Die Registry bei jedem Request zu zerstören und neu aufzubauen, hebt den Nutzen des geteilten Caches auf.
  • Bypass für übergroße Bilder. Ein Bild, das größer als maxCacheBytes ist, wird bei jeder Nutzung dekodiert und nie gecacht, sodass es das Working Set nicht verdrängen kann. Das ist Absicht. Dimensionieren Sie das Budget für Ihre üblichen Bilder, nicht für das seltene große.
  • Das Document muss den Gültigkeitsbereich verlassen. Das Document in einer Static-Variable, einer langlebigen Container-Bindung oder einer vom Worker erfassten Closure zu halten, hält den gesamten Objektgraphen am Leben und unterläuft die Bereinigung pro Request. Ein unset()-Aufruf oder ein Verlassen des Gültigkeitsbereichs ist zwingend.
  • Platzierung von gc_collect_cycles(). PHPs Zyklus-Collector kennt keine Request-Grenzen. Rufen Sie ihn nach dem Reset-Takt auf, nicht bei jedem Request. So bleibt der Höchststand begrenzt, ohne dass Sie die Sammlungskosten auf dem Hot Path zahlen.
  • Vorbehalt zum Determinismus. Dokumentzeitstempel und die /ID im Trailer werden bei jedem Speichern neu erzeugt (ISO 32000-2 §14.3). Das erfasste PDF wird daher mit dem semantischen Profil verglichen (struktureller AST plus Metadaten, niemals volatile Bytes). Siehe „Konformität“.
  • Die geteilte Registry wandelt wiederholtes Font-Parsing und Bild-Dekodieren in einmalige Boot-Kosten um. Die Arbeit pro Request beschränkt sich danach auf Layout und Serialisierung.
  • Die Resident-Speicherspitze ist durch maxCacheBytes plus das Working Set eines in Bearbeitung befindlichen Dokuments begrenzt. Der reset() pro Zyklus bringt den Cache auf die Baseline zurück, sodass ein dauerhaft laufender Worker keine nach oben driftende Sägezahnkurve zeigt.
  • Das performance_budget-Frontmatter (wall_ms: 4000, peak_mb: 192) begrenzt den Harness-Lauf der 12-Request-Schleife. Der Harness erzwingt es; es ist keine Garantie für beliebige Dokumente.
  • Dieses Recipe liefert die Abdeckung der §4.3-Lückenliste „Speicher/GC“ für #31. Das zugrunde liegende examples/14-worker-factory.php existiert, und das neue tests/Cookbook/Php/WorkerSafeBatchRenderingRecipeTest.php ergänzt die fehlende Speicher-/GC-Assertion (die Speicherspitze wächst nach dem Reset nicht über die Zyklen hinweg).
  • Das Worker-Muster verarbeitet ein Dokument pro Request und teilt nur Caches geparster Fonts und dekodierter Bilder. Kein Dokumentinhalt überschreitet die Request-Grenze. Ein Request kann die Dokumentdaten eines anderen Requests nicht über die geteilten Registries lesen.
  • Nicht vertrauenswürdige Eingaben fließen weiterhin durch die normalen NextPDF-Eingabegrenzen, und das Worker-Muster schwächt keine Validierung ab. Behandeln Sie die HTML-/Asset-Eingabe jedes Requests als nicht vertrauenswürdig, genau wie Sie es in einem Prozess pro Request tun würden.
AussageSpezifikationKlauselreference_id
Das Änderungsdatum des Dokuments wird bei jedem Speichern neu erzeugt, sodass die Ausgabe pro Request nicht byte-stabil ist.ISO 32000-2§14.3
Jedes Worker-Dokument ist eine nie aktualisierte Datei (kein Prev im Trailer); Requests teilen sich keinen Document-Zustand.ISO 32000-2§7.5.5
Das Präfix des Subset-Font-Tags ist über Requests hinweg stabil, weil der geparste Font in der geteilten Registry lebt.ISO 32000-2§9.6.4

Weil die /ID im Trailer und das Änderungsdatum bei jedem Speichern neu erzeugt werden, wird dieses Recipe mit dem semantischen Reproduzierbarkeitsprofil verifiziert (Gleichheit des strukturellen AST plus reiner Metadaten-Vergleich). Eine bitweise oder strukturelle Aussage wäre für Worker-Ausgabe fachlich nicht korrekt.