Runtime の再利用と Daemon
単一 CLI コマンドの Runtime ライフサイクルから、daemon が Worker Runtime pool を管理して読み込み済み Unit を呼び出し間で再利用する仕組みを学びます。
CLI で重いのは通常、引数解析ではなく Unit1 を読み書きできるライブ Univer にする処理です。Univer 作成、プラグイン導入、共同編集バックエンドの開始、チェックポイント読み込み、変更セット適用、数式計算の完了待ちが必要です。
本章は 2 つの Runtime2 機能を扱います。
@univer-cli/univer-collaboration-runtime:現在のプロセスで Unit を読み込み操作します。@univer-cli/univer-collaboration-runtime-pool:Runtime を Worker に配置し、Unit の識別情報で貸与・再利用・破棄します。
Worker Runtime を複数の CLI 呼び出し間で維持するには、長寿命の所有者が必要です。ここではローカル daemon が担当します。
3 つのライフサイクル
再利用を考える前に、次のオブジェクトの寿命を区別します。
| オブジェクト | 作成時点 | 終了時点 | 所有するもの |
|---|---|---|---|
| CLI プロセス | コマンド実行ごと | 結果出力後 | 引数、1 回のリクエスト、出力形式 |
| Daemon プロセス | 初回リクエストまたは直接起動 | 停止、更新による再起動、クラッシュ | Runtime pool、業務ハンドラー、ローカル socket |
| Worker Runtime | pool が初めて key に遭遇 | TTL/LRU3 削除、無効化、daemon 終了、Worker クラッシュ | 1 Worker、1 バックエンドハンドル、1 読み込み済み Unit、共同編集状態 |
CLI は 1 回呼び出し、daemon が pool を所有し、pool が Worker を所有し、各 Worker が Runtime を所有します。
daemon が転送するのはアプリケーション定義の JSON リクエストだけです。スナップショット、変更セット、ブロックは Worker 内のバックエンドが直接読み、CLI や daemon を通りません。
手順 1:コマンドごとに直接 Runtime を読み込む
最も単純な実装は Commander action 内で @univer-cli/univer-collaboration-runtime を作り、操作完了後に閉じます。
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 は常に 1 Unit に結び付き、現在のリビジョン、保留 mutation、待機中の変更セット、競合状態を保持します。呼び出し側は最終コミット状態も確認します。完了を意味するのは confirmed と nothing-to-commit だけです。
load() は単なるファイル読み取りではなく、順に次を行います。
- アプリケーション提供の共同編集バックエンドを開く;
- Headless Univer を作り共同編集機能を導入;
- UnitData または Snapshot チェックポイントを読み込む;
- チェックポイントに含まれる変更セットを再生;
- チェックポイントから最新リモートリビジョンまでの差分を取得・再生;
- 導入済み数式モジュールの最新結果が適用されるまで待つ;
execute()、pull()、commit()が使える Runtime を返す。
単発呼び出し全体は次のとおりです。
所有関係が明確で失敗後に状態を残さないため、低頻度コマンドや単発タスクに向きます。一方、次の CLI は新たな Node.js プロセスを起動して全読み込みを繰り返し、以前の Univer と Unit 状態を再利用できません。
手順 2:Worker pool に Runtime を置く
@univer-cli/univer-collaboration-runtime-pool は常駐 key ごとに別の Worker プロセスと Runtime を維持します。アプリケーションが Worker 入口を提供し、バックエンド、認証情報、Univer はすべて Worker 内で作成します。
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 はコールド作成時のみ送られ、構造化複製に対応している必要があります。
Runtime 用の RPC 層を別途設計する必要はありません。lease が getState()、execute()、fetch()、pull()、commit()、exportUnitData() などの公開 API を代理します。
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 の重要な動作は 4 つです。
- 同じ key は同時に 1 lease のみ。並行要求は FIFO で待ち、同じライブ状態への同時変更を防ぎます。
release()は信頼できる Runtime をアイドルキャッシュへ戻し、次の同じ key で再利用します。invalidate()は状態が信頼できないことを示します。Worker と Runtime を破棄し、次回はコールド読み込みします。- アイドル Runtime は TTL/LRU で削除できます。タイムアウト、Worker クラッシュ、プロトコルエラーも現在のインスタンスを無効化します。
ただし pool 自体を短命な CLI 内に作ると、コマンド間では再利用できません。
Worker はプロセス隔離、排他的 lease、障害時終了を提供します。CLI 間で再利用するには、単一コマンドより長寿命の pool 所有者が必要です。
手順 3:Daemon を長寿命の所有者にする
daemon は必要時に起動するローカル Node.js プロセスです。CLI は Unix socket または Windows 名前付きパイプで呼びます。CLI 終了では daemon は終わらないため、pool、Worker、アイドル Runtime を維持できます。
daemon 入口で pool を一度作成し、各業務ハンドラーで lease を取得します。
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 は 1 つの業務リクエストを送るだけになります。
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 入口を起動し、ID とプロトコルのハンドシェイクを待ってから送信します。互換 daemon が既にあれば再利用します。
手順 4:初回と後続リクエストの比較
初回 key アクセスはコールド経路です。daemon 起動が必要な場合があり、pool は Worker と Runtime を作ります。
同じ key の後続アクセスはホット経路です。CLI プロセスは再起動しますが、重い Runtime 読み込みは繰り返しません。
再利用するのは作成・読み込み済みのライブ Runtime です。共同編集の整合性処理は省略されません。通常は lease 取得後も pull()、commit() 後の最終状態確認、pull-required、retry、unknown、conflict の処理が必要です。
減るコストと残るコスト
| コスト | 毎回直接読み込み | Daemon + Worker pool |
|---|---|---|
| CLI 起動、引数解析、出力 | 毎回 | 毎回 |
| ローカル socket JSON 往復 | なし | 毎回 |
| Daemon 起動・モジュール読み込み | なし | 初回または再起動後 |
| Worker 起動・入口読み込み | Worker なし | key ごとのコールド作成 |
| Univer 作成・プラグイン導入 | 毎回 | key ごとのコールド作成 |
| チェックポイント・初期変更再生・数式待ち | 毎回 | key ごとのコールド作成 |
アイドル中の変更を pull() | 毎回 | 通常は毎回必要 |
execute() と commit() | 毎回 | 毎回 |
| アイドルメモリ、バックエンドハンドル、Worker | CLI 終了で解放 | 削除・無効化・daemon 終了まで保持 |
再利用は常駐メモリと Worker 数を使い、後続呼び出しの起動遅延を減らします。すべての Unit を無期限に保持せず、pool イベントと実測遅延で idleTtlMs、maxEntries、開始・操作タイムアウトを選びます。
並行処理、障害、終了
動作は 4 つの規則にまとめられます。
- 同じ key の並行要求は待機します。 1 Runtime は同時に 1 lease だけに属し、交錯した呼び出しによる状態変更を防ぎます。
- 異なる key は別 Worker で並行実行できます。 常駐 key ごとに別 Worker と Runtime を割り当てます。
- 信頼できる状態だけ再利用します。 操作成功後、ローカル mutation を解消して
release()。タイムアウト、クラッシュ、プロトコルエラー、状態不明時はinvalidate()を呼びます。 - 終了責任は下へ伝わります。 CLI が 1 リクエストを終え、daemon 終了が
pool.close()を呼び、pool が Worker を終了して Runtime とバックエンドを閉じます。
単発遅延を許容できる低頻度コマンドには @univer-cli/univer-collaboration-runtime の直接利用が最も簡単です。反復読み込みが主なコストと実測で分かり、常駐プロセスとメモリを許容できる場合だけ daemon と @univer-cli/univer-collaboration-runtime-pool を組み合わせます。
全パッケージはパッケージ一覧を参照してください。
Footnotes
このガイドはいかがですか?