跳到內容
getnextpdf.com

Pro 版本

串流

Stream 模組會以可持久且並行的方式算繪批次文件,並以**恰好一次的本機提交(exactly-once local commit)**寫入單主機的可持久儲存(跨主機的 exactly-once 是 Enterprise Stream 的邊界)。它把工作切分為兩項清楚分離的職責:一個把已驗證 manifest 轉成位元組(除此之外別無其他)的算繪引擎,以及一組可持久的儲存——committer、checkpoint、idempotency、dead-letter——它們安全地發佈那些位元組,並讓一次執行能在當機後恢復,而不會重新發佈已提交的輸出。

此能力隨 NextPDF Pronextpdf/pro)出貨,並以 Pro 層級的授權封套啟用。缺少該權利(entitlement)的部署不會載入此能力的類別。比較版本並取得授權

並沒有獨立的個別功能授權旗標。並行度(worker 數量)、批次大小、重試預算,以及儲存後端(記憶體內相對於可持久檔案系統)都是執行階段參數,而非授權開關。

Terminal window
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 個 php worker 子行程,由它們並行算繪,再蒐集並對其結果做完整性檢查。

OutputCommitterInterface::commit() 會將算繪後的位元組恰好一次地發佈到其最終目的地:原子地(絕不會觀察到部分物件)、idempotent 地(重新提交逐位元組相同的內容不會進行任何寫入,並回傳一個 idempotentReuse = trueCommitReceipt——一張全新的收據,而非原始那張)、不靜默覆蓋(在未指定 overwrite 的情況下,把分歧的位元組寫入一個已被占用的鍵會引發衝突),且經完整性檢查(committer 會在寫入前重新計算摘要)。LocalFilesystemCommitter 為本機檔案系統實作此項。

RunCheckpoint 是一道可持久的屏障,記錄一次執行已提交多少項目,加上一份具鍵狀態(keyed state)的快照。在復原時,處理器會快轉越過已提交的偏移並還原具鍵狀態,因此一次在執行途中當機,能在不重新發佈已提交輸出的情況下恢復。FilesystemCheckpointStore 會原子地持久化每一道屏障。

idempotency 儲存是快速路徑,讓處理器在算繪一個被重播的 manifest 之前就能短路;committer 的摘要比對仍是可持久的 exactly-once 保證,因此一筆遺失的去重記錄至多造成一次浪費的重新算繪,而 committer 會將其去重。RetryPolicy 為暫時性(逾時)失敗提供有界、確定性的指數退避;一個耗盡其預算的工作會被擷取進一個 DeadLetterStoreInterface,而非遺失。每個儲存都隨附一個記憶體內變體(單次執行/測試範圍)與一個可持久檔案系統變體。

凡其狀態能在行程重啟後存活的儲存,皆實作 DurableCapability 標記。一次當機安全的執行要求每個協作者都是可持久的,因此它會快速失敗,而非承諾某個記憶體內儲存在重啟後無法保有的 exactly-once 語意。

算繪一個 manifest 並將其位元組恰好一次提交。引擎回傳位元組加上一份摘要;committer 將它們發佈出去。

stream-quickstart.php
<?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。提交會拒絕覆蓋分歧的位元組,因此一次鍵衝突會被捕捉並擷取,而非遺失。

stream-production.php
<?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 數量擴展(受 maxWorkersmaxBatchSize 限制),同時引擎讓算繪輸出與循序基準逐位元組相同。一個 wall-clock 逾時為每個並行批次設上限,使一個卡死的 worker 無法永遠阻塞。沒有已發布的固定吞吐量數字;它取決於文件複雜度與主機的並行能力。請以具代表性的文件量測。

Manifest 會在算繪之前先 fail-closed 驗證。committer 會拒絕路徑遍歷(path traversal)、null 位元組、stream-wrapper 方案、符號連結目標,以及 NTFS 替代資料串流(colon)向量,並將每個鍵解析至單一設定好的根目錄之下。跨行程的 worker 結果會被重新雜湊並與 worker 回報的摘要比對,因此一個損毀的 worker 無法靜默地破壞輸出。本模組不會記錄任何文件內容。

Stream 此處的可持久儲存以檔案系統為後端且為單主機。對同一個鍵的跨主機並行 exactly-once,以及跨執行的可持久去重,是 Enterprise 物件儲存 committer 與儲存的職責;驅動這些協作者的 document-job 串流處理器則是 Enterprise 的範疇。Pro 提供引擎、合約,以及本機的可持久實作。

沒有 Pro 時,請以 NextPDF Core 的 writer 一次算繪一份文件;可持久的批次串流、並行執行與 exactly-once 提交都是 Pro 的新增項目。請參閱 /modules/writer/

本頁僅記載外部可觀察的行為與受支援的公開 API 表面。內部命名空間路徑、輔助類別、機制表格、runbook 檔名與工單前綴皆不在範圍內。