Pro 版本
串流
Stream 模組會以可持久且並行的方式算繪批次文件,並以**恰好一次的本機提交(exactly-once local commit)**寫入單主機的可持久儲存(跨主機的 exactly-once 是 Enterprise Stream 的邊界)。它把工作切分為兩項清楚分離的職責:一個把已驗證 manifest 轉成位元組(除此之外別無其他)的算繪引擎,以及一組可持久的儲存——committer、checkpoint、idempotency、dead-letter——它們安全地發佈那些位元組,並讓一次執行能在當機後恢復,而不會重新發佈已提交的輸出。
可用性與授權
標題為「可用性與授權」的區段此能力隨 NextPDF Pro(nextpdf/pro)出貨,並以 Pro 層級的授權封套啟用。缺少該權利(entitlement)的部署不會載入此能力的類別。比較版本並取得授權。
並沒有獨立的個別功能授權旗標。並行度(worker 數量)、批次大小、重試預算,以及儲存後端(記憶體內相對於可持久檔案系統)都是執行階段參數,而非授權開關。
composer require nextpdf/pro:^3程式碼位於 NextPDF\Pro\Stream 命名空間下。
概念總覽
標題為「概念總覽」的區段Stream 圍繞一道凍結接縫——NextPDF\Pro\Stream\Engine\RenderEngineInterface——而組織,它將吞吐量引擎與串流語意分離:
- 算繪引擎掌管並行度與有界記憶體。它透過
renderBatch()算繪一個由預先驗證、預先去重的 manifest 所組成的視窗,並依輸入順序為每個 manifest 回傳一個EngineRenderResult。關鍵在於,引擎對最終輸出而言是無副作用的:它回傳算繪後的位元組加上其 sha-256 摘要,絕不寫入最終物件鍵。正是這份純粹性,使 exactly-once 交付成為可能。 - **串流協作者掌管交付。**committer、checkpoint 儲存、idempotency(去重)儲存與 dead-letter 儲存,決定位元組落在何處、一次執行如何恢復、哪些工作屬於重播,以及終結性失敗會如何處理。
單一 manifest 的算繪失敗會以逐項 Failed(或 Timeout)結果回報;它絕不會中止整個批次。批次封套永遠成功,並帶有逐項結果。
關鍵概念
標題為「關鍵概念」的區段算繪引擎與執行器
標題為「算繪引擎與執行器」的區段InProcessRenderEngine是同步、單行程的正確性基準。它會在透過 Core 的SingleDocumentRenderer算繪之前,先以隨附的RenderManifestValidator對每個 manifest 進行 fail-closed 驗證,因此一個不良的 manifest 會成為一個逐項失敗(錯誤代碼SPEC-MANIFEST-INVALID),而不會抵達算繪器。ConcurrentRenderEngine會把一個批次扇出(fan out)給一個RenderUnitExecutorInterface,並依單元索引還原確定性的批次順序。無論完成順序為何,輸出都與循序算繪逐位元組相同;缺漏、重複或未知的完成都是硬性失敗,絕不會靜默丟棄。- 執行器是並行接縫。
InlineRenderUnitExecutor是確定性基準;ProcessPoolRenderUnitExecutor會把一個批次分配到至多 N 個phpworker 子行程,由它們並行算繪,再蒐集並對其結果做完整性檢查。
可持久、無副作用的提交
標題為「可持久、無副作用的提交」的區段OutputCommitterInterface::commit() 會將算繪後的位元組恰好一次地發佈到其最終目的地:原子地(絕不會觀察到部分物件)、idempotent 地(重新提交逐位元組相同的內容不會進行任何寫入,並回傳一個 idempotentReuse = true 的 CommitReceipt——一張全新的收據,而非原始那張)、不靜默覆蓋(在未指定 overwrite 的情況下,把分歧的位元組寫入一個已被占用的鍵會引發衝突),且經完整性檢查(committer 會在寫入前重新計算摘要)。LocalFilesystemCommitter 為本機檔案系統實作此項。
檢查點復原
標題為「檢查點復原」的區段RunCheckpoint 是一道可持久的屏障,記錄一次執行已提交多少項目,加上一份具鍵狀態(keyed state)的快照。在復原時,處理器會快轉越過已提交的偏移並還原具鍵狀態,因此一次在執行途中當機,能在不重新發佈已提交輸出的情況下恢復。FilesystemCheckpointStore 會原子地持久化每一道屏障。
Idempotency 去重、重試與 dead-letter
標題為「Idempotency 去重、重試與 dead-letter」的區段idempotency 儲存是快速路徑,讓處理器在算繪一個被重播的 manifest 之前就能短路;committer 的摘要比對仍是可持久的 exactly-once 保證,因此一筆遺失的去重記錄至多造成一次浪費的重新算繪,而 committer 會將其去重。RetryPolicy 為暫時性(逾時)失敗提供有界、確定性的指數退避;一個耗盡其預算的工作會被擷取進一個 DeadLetterStoreInterface,而非遺失。每個儲存都隨附一個記憶體內變體(單次執行/測試範圍)與一個可持久檔案系統變體。
可持久性標記
標題為「可持久性標記」的區段凡其狀態能在行程重啟後存活的儲存,皆實作 DurableCapability 標記。一次當機安全的執行要求每個協作者都是可持久的,因此它會快速失敗,而非承諾某個記憶體內儲存在重啟後無法保有的 exactly-once 語意。
程式碼範例 — 快速開始
標題為「程式碼範例 — 快速開始」的區段算繪一個 manifest 並將其位元組恰好一次提交。引擎回傳位元組加上一份摘要;committer 將它們發佈出去。
<?php
declare(strict_types=1);
use NextPDF\Manifest\OutputObjectKey;use NextPDF\Manifest\Render\SingleDocumentRenderer;use NextPDF\Manifest\RenderManifestBuilder;use NextPDF\Manifest\TemplateRef;use NextPDF\Pro\Stream\Commit\LocalFilesystemCommitter;use NextPDF\Pro\Stream\Engine\InProcessRenderEngine;
$outputRoot = __DIR__ . '/out';\is_dir($outputRoot) || \mkdir($outputRoot, 0o775, true);
// The engine renders bytes only — it never writes the final object.$engine = new InProcessRenderEngine(SingleDocumentRenderer::standalone());
$target = OutputObjectKey::file('out', 'invoices/1001.pdf');
$manifest = RenderManifestBuilder::create('invoice-1001') ->withInlineInput('<h1>Invoice 1001</h1><p>Amount due: 42.00</p>') ->withTemplate(TemplateRef::html()) ->withOutputKey($target) ->build();
$result = $engine->renderBatch([$manifest])[0];
// A durable committer publishes the rendered bytes exactly once.$committer = new LocalFilesystemCommitter($outputRoot);
if ($result->isRendered()) { $receipt = $committer->commit($result->jobId, $target, $result->bytes, $result->sha256); echo $receipt->target->toUri(), ' (', $receipt->bytesWritten, " bytes)\n";}程式碼範例 — 生產環境
標題為「程式碼範例 — 生產環境」的區段算繪一個批次,把逾時導向重試策略,並將終結性失敗送入 dead-letter。提交會拒絕覆蓋分歧的位元組,因此一次鍵衝突會被捕捉並擷取,而非遺失。
<?php
declare(strict_types=1);
use DateTimeImmutable;use NextPDF\Manifest\OutputObjectKey;use NextPDF\Manifest\Render\SingleDocumentRenderer;use NextPDF\Manifest\RenderManifest;use NextPDF\Manifest\RenderManifestBuilder;use NextPDF\Manifest\TemplateRef;use NextPDF\Pro\Stream\Commit\LocalFilesystemCommitter;use NextPDF\Pro\Stream\Engine\EngineRenderStatus;use NextPDF\Pro\Stream\Engine\InProcessRenderEngine;use NextPDF\Pro\Stream\Exception\OutputCommitConflictException;use NextPDF\Pro\Stream\Retry\DeadLetterRecord;use NextPDF\Pro\Stream\Retry\InMemoryDeadLetterStore;use NextPDF\Pro\Stream\Retry\RetryPolicy;
$outputRoot = __DIR__ . '/out';\is_dir($outputRoot) || \mkdir($outputRoot, 0o775, true);
$engine = new InProcessRenderEngine(SingleDocumentRenderer::standalone(), maxBatchSize: 64);$committer = new LocalFilesystemCommitter($outputRoot);$deadLetter = new InMemoryDeadLetterStore();$retry = RetryPolicy::default(); // 3 attempts, 100ms base, 30s cap.
/** * Build one manifest and remember its output target for the commit stage. * * @return array{RenderManifest, OutputObjectKey} */$makeJob = static function (string $jobId, string $html): array { $target = OutputObjectKey::file('out', 'invoices/' . $jobId . '.pdf'); $manifest = RenderManifestBuilder::create($jobId) ->withInlineInput($html) ->withTemplate(TemplateRef::html()) ->withOutputKey($target) ->build();
return [$manifest, $target];};
/** @var array<non-empty-string, OutputObjectKey> $targets */$targets = [];$manifests = [];foreach (['inv-2001' => '<h1>2001</h1>', 'inv-2002' => '<h1>2002</h1>'] as $id => $html) { [$manifest, $target] = $makeJob($id, $html); $manifests[] = $manifest; $targets[$id] = $target;}
foreach ($engine->renderBatch($manifests) as $result) { // A timeout is transient — the policy decides whether to re-enqueue it. if ($result->status === EngineRenderStatus::Timeout && $retry->shouldRetry(1)) { // Re-enqueue on the caller's work queue after delayMsForAttempt(1) ms. continue; }
if (!$result->isRendered()) { $deadLetter->add(new DeadLetterRecord( jobId: $result->jobId, idempotencyKeyValue: $result->jobId, attempts: $retry->maxAttempts, lastErrorCode: $result->errorCode ?? 'SPEC-RENDER-EXCEPTION', lastErrorMessage: $result->errorMessage ?? '', failedAt: new DateTimeImmutable(), ));
continue; }
try { // overwrite=false: identical bytes are an idempotent no-op; divergent // bytes to an occupied key raise SPEC-COMMIT-409 instead of clobbering. $receipt = $committer->commit( $result->jobId, $targets[$result->jobId], $result->bytes, $result->sha256, ); } catch (OutputCommitConflictException $e) { $deadLetter->add(new DeadLetterRecord( jobId: $result->jobId, idempotencyKeyValue: $result->jobId, attempts: 1, lastErrorCode: $e->specCode(), lastErrorMessage: $e->getMessage(), failedAt: new DateTimeImmutable(), ));
continue; }
echo $receipt->idempotentReuse ? "reused {$receipt->target->toUri()}\n" : "committed {$receipt->target->toUri()}\n";}
if ($deadLetter->count() > 0) { \fwrite(\STDERR, $deadLetter->count() . " job(s) dead-lettered\n");}何時使用
標題為「何時使用」的區段- 大量批次算繪,其吞吐量受益於並行(process-pool)執行。
- 必須在當機後存活並在不重複發佈輸出的情況下恢復的長時間執行。
- 必須保證將每份算繪後文件恰好一次交付至其目標的管線。
對於單一臨時文件,請直接以 Writer 模組算繪;Stream 的價值在於可持久、可恢復、可並行的批次。
吞吐量會隨 ProcessPoolRenderUnitExecutor 中的 worker 數量擴展(受 maxWorkers 與 maxBatchSize 限制),同時引擎讓算繪輸出與循序基準逐位元組相同。一個 wall-clock 逾時為每個並行批次設上限,使一個卡死的 worker 無法永遠阻塞。沒有已發布的固定吞吐量數字;它取決於文件複雜度與主機的並行能力。請以具代表性的文件量測。
安全注意事項
標題為「安全注意事項」的區段Manifest 會在算繪之前先 fail-closed 驗證。committer 會拒絕路徑遍歷(path traversal)、null 位元組、stream-wrapper 方案、符號連結目標,以及 NTFS 替代資料串流(colon)向量,並將每個鍵解析至單一設定好的根目錄之下。跨行程的 worker 結果會被重新雜湊並與 worker 回報的摘要比對,因此一個損毀的 worker 無法靜默地破壞輸出。本模組不會記錄任何文件內容。
Enterprise 邊界註記
標題為「Enterprise 邊界註記」的區段Stream 此處的可持久儲存以檔案系統為後端且為單主機。對同一個鍵的跨主機並行 exactly-once,以及跨執行的可持久去重,是 Enterprise 物件儲存 committer 與儲存的職責;驅動這些協作者的 document-job 串流處理器則是 Enterprise 的範疇。Pro 提供引擎、合約,以及本機的可持久實作。
Core 回退/替代方案
標題為「Core 回退/替代方案」的區段沒有 Pro 時,請以 NextPDF Core 的 writer 一次算繪一份文件;可持久的批次串流、並行執行與 exactly-once 提交都是 Pro 的新增項目。請參閱 /modules/writer/。
發佈邊界
標題為「發佈邊界」的區段本頁僅記載外部可觀察的行為與受支援的公開 API 表面。內部命名空間路徑、輔助類別、機制表格、runbook 檔名與工單前綴皆不在範圍內。