Runtime 重用與 Daemon

從單一 CLI 命令的 Runtime 生命週期開始,瞭解 daemon 如何管理 Worker Runtime pool 並跨呼叫重用已載入的 Unit。

CLI 命令執行時,耗時的通常不是引數解析,而是將 Unit1 轉為可讀寫的即時 Univer:建立 Univer、安裝外掛、開啟協同後端、載入檢查點、套用變更集,以及等待公式計算完成。

本章介紹兩種 Runtime2 能力:

  • @univer-cli/univer-collaboration-runtime:在目前程序載入並操作 Unit;
  • @univer-cli/univer-collaboration-runtime-pool:將 Runtime 放入 Worker,依 Unit 身分租用、重用及淘汰。

Worker Runtime 要跨多次 CLI 呼叫存活,需要長期存在的管理者。本章由本機 daemon 承擔此角色。

先區分三種生命週期

考慮重用前,先區分以下物件:

物件建立時機結束時機管理的資源
CLI 程序每次執行命令命令輸出結果後引數、單次請求、輸出格式
Daemon 程序首次請求或直接啟動停止、升級重啟或崩潰Runtime pool、業務處理器、本機 socket
Worker Runtimepool 首次遇到某個 keyTTL/LRU3 淘汰、失效、daemon 關閉或 Worker 崩潰一個 Worker、一個後端 handle、一個已載入 Unit 與協同狀態

核心關係是:CLI 發起一次呼叫,daemon 管理 pool,pool 管理 Worker,每個 Worker 管理一個 Runtime。

100%

daemon 只轉送應用定義的 JSON 請求。快照、變更集與區塊由 Worker 內的後端直接讀取,不經過 CLI 或 daemon。

步驟 1:每個 CLI 命令直接載入 Runtime

最簡單的實作在 Commander action 中建立 @univer-cli/univer-collaboration-runtime,操作完成後關閉:

TypeScript
const runtime = await runtimeFactory.load(unitId, unitType);try {  const pulled = await runtime.pull();  if (pulled.status === "conflict") throw new Error(pulled.conflict.message);  const execution = await runtime.execute({ mode: "write", code });  let commit = await runtime.commit();  if (commit.status === "pull-required") {    const repulled = await runtime.pull();    if (repulled.status === "conflict") throw new Error(repulled.conflict.message);    commit = await runtime.commit();  }  return { execution, commit };} finally {  await runtime.close();}

runtimeFactory 由 createUniverCollaborationRuntimeFactory() 建立。Runtime 從載入到關閉始終綁定一個 Unit,保留其目前修訂版本、待處理 mutation、等待中的變更集及衝突狀態。呼叫端還必須檢查最終提交狀態:只有 confirmed 與 nothing-to-commit 表示操作完成。

load() 不只是「讀取檔案」,它依序執行:

  1. 開啟應用提供的協同後端;
  2. 建立 Headless Univer 並安裝協同能力;
  3. 載入 UnitData 或 Snapshot 檢查點;
  4. 重播檢查點攜帶的變更集;
  5. 取得並重播檢查點與遠端最新修訂版本之間的差距;
  6. 等待已安裝公式模組的最新結果套用完成;
  7. 傳回可執行 execute()、pull() 和 commit() 的 Runtime。

完整的一次性呼叫如下:

100%

此方式的資源歸屬明確,失敗後不殘留狀態,適合低頻命令與一次性任務。代價是下一個 CLI 命令要啟動新的 Node.js 程序並重複完整載入流程,無法重用前一命令建立的 Univer 與 Unit 狀態。

步驟 2:將 Runtime 放入 Worker pool

@univer-cli/univer-collaboration-runtime-pool 為每個常駐 key 維護獨立 Worker 程序與 Collaboration Runtime。應用先提供 Worker 入口;後端、憑證與 Univer 都在 Worker 內建立:

TypeScript
import { defineUniverCollaborationRuntimeWorker } from "@univer-cli/univer-collaboration-runtime-pool";export default defineUniverCollaborationRuntimeWorker({  async createRuntime(init: { unitId: string; unitType: number }) {    return createRuntimeFromApplicationInit(init);  },});

Worker 入口必須指向建置完成的 JavaScript ESM 檔案。init 僅在冷建立時傳送到 Worker,必須支援結構化複製。

呼叫端不必另設 Runtime RPC 層。lease 已代理 getState()、execute()、fetch()、pull()、commit()、exportUnitData() 等公開 API:

TypeScript
import { createUniverCollaborationRuntimePool } from "@univer-cli/univer-collaboration-runtime-pool";const pool = createUniverCollaborationRuntimePool({  entry: new URL("./collaboration-worker.js", import.meta.url),});const lease = await pool.acquire({  key: targetKey,  init: { unitId, unitType },});try {  await lease.pull();  const result = await lease.execute({ mode: "read", code });  console.log(result.value);} catch (error) {  await lease.invalidate();  throw error;} finally {  await lease.release();}

key 識別要重用的 Runtime,不只是查詢引數。相同 key 必須始終代表相同 Unit、相同協同狀態鏈及相容的初始化語意。一般 Unit 與 Worktree 草稿即使 unitId 相同,也應使用不同 key。

pool 有四個重要行為:

  • 同一 key 一次只授予一個 lease。並行請求依 FIFO 等待,避免兩次呼叫同時修改相同即時狀態;
  • release() 將可信 Runtime 放回閒置快取,下次相同 key 可重用;
  • invalidate() 表示狀態不再可信。Worker 與 Runtime 被銷毀,下次請求重新冷載入;
  • 閒置 Runtime 可依 TTL 和 LRU 淘汰。逾時、Worker 崩潰及協定錯誤也會使目前執行個體失效。

但若 pool 本身建立在短期 CLI 程序內,仍不能跨命令重用:

100%

Worker 提供 Runtime 程序隔離、獨占租用及故障終止。跨 CLI 呼叫重用仍需要比單一命令存活更久的 pool 管理者。

步驟 3:讓 Daemon 長期管理 pool

daemon 是按需啟動的本機 Node.js 程序。CLI 透過 Unix socket 或 Windows 具名管道呼叫。CLI 結束不會終止 daemon,因此 pool、Worker 與閒置 Runtime 可保持存活。

在 daemon 入口建立一次 pool,再於各業務處理器內取得 lease:

TypeScript
import { createDaemonServer, DAEMON_SOCKET_ENV } from "@univer-cli/daemon";import { createUniverCollaborationRuntimePool } from "@univer-cli/univer-collaboration-runtime-pool";const socketPath = process.env[DAEMON_SOCKET_ENV];if (!socketPath) throw new Error(`${DAEMON_SOCKET_ENV} is required`);const pool = createUniverCollaborationRuntimePool({  entry: new URL("./collaboration-worker.js", import.meta.url),  cache: { idleTtlMs: 5 * 60_000, maxEntries: 20 },});const server = createDaemonServer({  identity: { id: "my-cli", version: "1.0.0" },  socketPath,  onShutdown: () => pool.close(),});server.handle("unit.execute", async (payload) => {  const input = parseExecuteRequest(payload);  const lease = await pool.acquire({    key: input.targetKey,    init: { unitId: input.unitId, unitType: input.unitType },  });  try {    const pulled = await lease.pull();    if (pulled.status === "conflict") throw new Error(pulled.conflict.message);    const execution =      input.mode === "read"        ? await lease.execute({ mode: "read", code: input.code })        : await lease.execute({ mode: "write", code: input.code });    const commit = input.mode === "write" ? await commitWithPull(lease) : null;    return { commitStatus: commit?.status ?? null, value: execution.value };  } catch (error) {    await lease.invalidate();    throw error;  } finally {    await lease.release();  }});await server.listen();

應用定義 parseExecuteRequest() 和 targetKey 規則。daemon 只提供本機傳輸與生命週期管理,不理解 Unit、Runtime 或協同協定。daemon 關閉時必須呼叫 pool.close(),讓閒置及使用中的 Worker 有明確的最終管理者。本例 commitWithPull() 處理 pull-required 後的 pull/commit 流程並檢查最終提交狀態。為保持保守,任何未處理錯誤都會使目前 lease 失效。

一般 CLI 命令現在只向 daemon 傳送一次業務請求:

TypeScript
import { createDaemonClient } from "@univer-cli/daemon";const client = createDaemonClient({  entry: new URL("./daemon-entry.js", import.meta.url),  identity: { id: "my-cli", version: "1.0.0" },  socketPath,});const result = await client.request("unit.execute", {  targetKey,  unitId,  unitType,  mode,  code,});

socket 不存在時,用戶端啟動設定的 daemon 入口,等待身分與協定握手後再傳送請求。已有相容 daemon 執行時直接重用。

步驟 4:比較首次與後續請求

首次存取 key 走冷路徑:daemon 可能需要啟動,pool 必須建立 Worker 和 Runtime。

100%

後續存取相同 key 走熱路徑。CLI 程序仍重新啟動,但不重複昂貴的 Runtime 載入:

100%

重用的是已建立並載入的即時 Runtime。重用不略過協同一致性。取得 lease 後通常仍應 pull(),commit() 後檢查最終狀態,並處理 pull-required、retry、unknown 或 conflict。

哪些成本消失,哪些保留

成本每次直接載入 RuntimeDaemon + Worker pool
CLI 啟動、引數解析、結果輸出每次每次
本機 socket JSON 請求/回應無每次
Daemon 啟動與模組載入無首次請求或重啟後
Worker 啟動與入口載入無 Worker每個 key 冷建立時
建立 Univer 與安裝外掛每次每個 key 冷建立時
載入檢查點、重播初始變更、等待公式每次每個 key 冷建立時
pull() 閒置期間產生的變更每次通常仍每次需要
execute() 與 commit()每次每次
閒置記憶體、後端 handle、Worker 程序命令結束時釋放保留至淘汰、失效或 daemon 關閉

重用以常駐記憶體與 Worker 數量換取後續呼叫較低的啟動延遲。依 pool 事件與實測延遲選擇 idleTtlMs、maxEntries、開啟逾時及操作逾時,不要無限期保留每個 Unit。

並行、故障與關閉

四項規則概括系統行為:

  1. 同一 key 的並行請求等待。 即時 Runtime 一次只屬於一個 lease,避免交錯呼叫修改協同狀態。
  2. 不同 key 可在不同 Worker 並行執行。 每個常駐 key 都需獨立 Worker 與 Runtime 資源。
  3. 只重用可信狀態。 操作成功且本機 mutation 清除後呼叫 release()。逾時、崩潰、協定錯誤或無法確認本機狀態時呼叫 invalidate()。
  4. 關閉責任逐層向下。 CLI 完成一次請求;daemon 關閉時呼叫 pool.close();pool 終止 Worker 並關閉 Runtime 與後端。

低頻命令且可接受一次性延遲時,直接使用 @univer-cli/univer-collaboration-runtime 最簡單。只有實測顯示重複載入是主要成本,且應用可接受常駐程序及記憶體用量時,才組合 daemon 與 @univer-cli/univer-collaboration-runtime-pool。

完整套件清單請參閱套件目錄。

Footnotes

  1. Unit 指一個文件實例,例如一個活頁簿或一篇文件。工作表是活頁簿的一部分,不是獨立的 Unit。 ↩

  2. 此處的 Runtime 指已載入文件的 Univer 實例及其操作 Unit 所需的執行資源,不是 Node.js 執行檔,也不是儲存至磁碟的文件快照。 ↩

  3. TTL 在達到設定的存活時間後回收閒置快取 Runtime;LRU 在容量受限時優先回收最久未使用的實例。這些策略釋放執行資源,不會刪除持久化的 Unit。 ↩

你覺得這篇文檔如何?

© 2026 DreamNum Co., Ltd.