Повторное использование Runtime и Daemon

От жизненного цикла одной команды CLI перейдите к daemon, который владеет пулом Worker Runtime и повторно использует загруженные Units между вызовами.

В CLI обычно дорого не разбирать аргументы, а превращать Unit1 в живой Univer для чтения и записи: создать Univer, установить плагины, открыть backend совместной работы, загрузить контрольную точку, применить изменения и дождаться расчёта формул.

Глава описывает две возможности:

  • @univer-cli/univer-collaboration-runtime загружает и обрабатывает Unit в текущем процессе;
  • @univer-cli/univer-collaboration-runtime-pool размещает Runtimes в Workers, выдаёт их в аренду, повторно использует и удаляет по идентичности Unit.

Чтобы Worker Runtime2 пережил несколько вызовов CLI, нужен долговечный владелец. Здесь это локальный daemon.

Три жизненных цикла

До обсуждения повторного использования различайте объекты:

ОбъектСозданиеЗавершениеВладеет
Процесс CLIПри каждой командеПосле вывода результатаАргументы, один запрос, форматирование вывода
Процесс DaemonПервый запрос или прямой запускОстановка, перезапуск обновления, сбойПул Runtime, бизнес-обработчики, локальный socket
Worker RuntimeПервое появление ключа в пулеTTL/LRU3-вытеснение, инвалидирование, остановка daemon, сбой WorkerОдин Worker, дескриптор backend, загруженная Unit и её состояние

CLI делает один вызов, daemon владеет пулом, пул — Workers, каждый Worker — Runtime.

100%

daemon пересылает только JSON-запросы приложения. Снимки, изменения и блоки читает напрямую backend внутри Worker; через CLI или daemon они не проходят.

Шаг 1: загрузка Runtime для каждой команды

Простейшая реализация создаёт @univer-cli/univer-collaboration-runtime внутри action Commander и закрывает после операции:

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 и хранит её ревизию, ожидающие мутации, ожидающие наборы изменений и конфликты. Вызывающая сторона проверяет также итоговый статус коммита: завершение означают только confirmed и nothing-to-commit.

load() — не просто чтение файла. Он последовательно:

  1. Открывает backend совместной работы приложения;
  2. Создаёт Headless Univer и устанавливает совместную работу;
  3. Загружает UnitData или контрольную точку Snapshot;
  4. Воспроизводит изменения из контрольной точки;
  5. Получает и воспроизводит промежуток до актуальной удалённой ревизии;
  6. Ждёт применения последних результатов установленных модулей формул;
  7. Возвращает Runtime с execute(), pull() и commit().

Полный однократный вызов:

100%

Владение прямое, после ошибки состояние не остаётся, что подходит редким командам и разовым задачам. Но следующая CLI запускает новый Node.js и повторяет всю загрузку, не используя прежние Univer и Unit.

Шаг 2: размещение Runtime в пуле Workers

@univer-cli/univer-collaboration-runtime-pool держит отдельный процесс Worker и Runtime для каждого резидентного ключа. Приложение предоставляет вход Worker; backend, учётные данные и Univer создаются внутри:

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 передаётся лишь при холодном создании и должен поддерживать структурированное клонирование.

Отдельный RPC-слой проектировать не нужно: lease уже проксирует публичные getState(), execute(), fetch(), pull(), commit() и exportUnitData():

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, а не просто аргумент запроса. Один ключ всегда означает одну Unit, одну цепочку совместного состояния и совместимую семантику инициализации. Обычная Unit и черновик Worktree используют разные ключи даже при одном unitId.

У пула четыре важных свойства:

  • Один ключ выдаётся только одному lease за раз. Параллельные запросы ждут FIFO и не меняют одновременно одно живое состояние;
  • release() возвращает доверенный Runtime в кеш ожидания для следующего запроса ключа;
  • invalidate() означает недоверенное состояние. Worker и Runtime уничтожаются, следующий запрос загружает их холодно;
  • Простаивающие Runtimes вытесняются по TTL/LRU. Тайм-ауты, сбои Worker и ошибки протокола также инвалидируют экземпляр.

Если сам пул создан в короткоживущей CLI, между командами повторного использования всё равно нет:

100%

Workers обеспечивают изоляцию процессов, эксклюзивную аренду и завершение при сбое. Для использования между вызовами CLI пулу нужен владелец дольше одной команды.

Шаг 3: Daemon как долговечный владелец

daemon — локальный Node.js-процесс по требованию. CLI вызывает его через Unix socket или именованный канал Windows. Завершение CLI не останавливает daemon, поэтому пул, Workers и простаивающие Runtimes сохраняются.

Создайте пул один раз во входе daemon и получайте 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 даёт только локальный транспорт и управление жизненным циклом, не зная Units, Runtimes и протокола совместной работы. При его остановке обязателен pool.close(), чтобы у активных и простаивающих Workers был явный конечный владелец. Здесь commitWithPull() обрабатывает pull/commit после pull-required и проверяет итоговый статус. Консервативно любая необработанная ошибка инвалидирует текущий lease.

Обычная CLI теперь отправляет лишь один бизнес-запрос:

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: первый и последующие запросы

Первое обращение к ключу идёт по холодному пути: возможно, нужно запустить daemon, а пул обязан создать Worker и Runtime.

100%

Повторное обращение к ключу идёт по горячему пути. CLI снова запускается, но дорогая загрузка Runtime не повторяется:

100%

Повторно используется созданный и загруженный живой Runtime. Согласованность совместной работы не пропускается: обычно после получения lease нужен pull(), после commit() — проверка итогового статуса и обработка pull-required, retry, unknown, conflict.

Какие затраты исчезают и остаются

ЗатратыПрямая загрузка каждый разDaemon + пул Worker
Запуск CLI, аргументы, выводКаждый разКаждый раз
JSON-запрос/ответ по локальному socketНетКаждый раз
Запуск daemon и загрузка модулейНетПервый запрос или перезапуск
Запуск Worker и загрузка входаНет WorkerХолодное создание по ключу
Создание Univer и установка плагиновКаждый разХолодное создание по ключу
Контрольная точка, начальные изменения, ожидание формулКаждый разХолодное создание по ключу
pull() изменений за время простояКаждый разОбычно нужен каждый раз
execute() и commit()Каждый разКаждый раз
Память простоя, дескриптор backend, WorkerОсвобождаются с командойХранятся до вытеснения, инвалидирования или остановки daemon

Повторное использование меняет резидентную память и число Workers на меньшую задержку последующих запусков. Выбирайте idleTtlMs, maxEntries, тайм-аут открытия и операций по событиям пула и измерениям, а не держите все Units бессрочно.

Конкурентность, сбои и остановка

Четыре правила поведения:

  1. Запросы одного ключа ждут. Один Runtime принадлежит одному lease, исключая перемежающиеся изменения состояния.
  2. Разные ключи работают параллельно в разных Workers. Каждый резидентный ключ требует отдельного Worker и Runtime.
  3. Повторно используется только доверенное состояние. После успеха и очистки локальных мутаций вызывайте release(). После тайм-аута, сбоя, ошибки протокола или неопределённого состояния — invalidate().
  4. Ответственность за закрытие идёт вниз. CLI завершает запрос; daemon вызывает pool.close(); пул завершает Workers и закрывает их Runtimes и backends.

Для редких команд с допустимой разовой задержкой проще использовать @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.