Reutilización de Runtime y Daemon
Empieza por el ciclo de una orden CLI y descubre cómo un daemon posee un pool de Worker Runtimes y reutiliza Units cargadas entre llamadas.
Lo costoso de una orden CLI suele ser convertir una Unit1 en un Univer activo que pueda leerse y escribirse, no analizar argumentos: crear Univer, instalar plugins, abrir el backend colaborativo, cargar el punto de control, aplicar cambios y esperar el cálculo de fórmulas.
Este capítulo trata dos funciones:
@univer-cli/univer-collaboration-runtime: carga y opera una Unit en el proceso actual;@univer-cli/univer-collaboration-runtime-pool: coloca Runtimes en Workers y los presta, reutiliza y retira según la identidad de Unit.
Para sobrevivir a varias invocaciones CLI, un Worker Runtime2 necesita un propietario duradero. Aquí lo proporciona un daemon local.
Tres ciclos de vida
Antes de reutilizar, distingue estos objetos:
| Objeto | Creación | Fin | Posee |
|---|---|---|---|
| Proceso CLI | Cada ejecución de una orden | Después de imprimir el resultado | Argumentos, una solicitud y formato de salida |
| Proceso Daemon | Primera solicitud o inicio directo | Parada, reinicio por actualización o fallo | Pool Runtime, handlers de negocio y socket local |
| Worker Runtime | Primer encuentro de una clave en el pool | Expulsión TTL/LRU3, invalidación, cierre daemon o fallo Worker | Un Worker, un handle backend, una Unit cargada y estado colaborativo |
La CLI hace una llamada; el daemon posee el pool; el pool posee Workers; cada Worker posee un Runtime.
El daemon solo reenvía solicitudes JSON definidas por la aplicación. Instantáneas, cambios y bloques se leen directamente desde el backend del Worker, sin pasar por CLI ni daemon.
Paso 1: cargar Runtime en cada orden
La implementación más simple crea @univer-cli/univer-collaboration-runtime dentro de una acción Commander y lo cierra al terminar:
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 se crea con createUniverCollaborationRuntimeFactory(). Runtime permanece ligado a una Unit desde carga hasta cierre y conserva revisión, mutaciones pendientes, cambios en espera y conflictos. El llamador también debe inspeccionar el commit final: solo confirmed y nothing-to-commit significan finalización.
load() hace más que leer un archivo. Sigue este orden:
- Abrir el backend colaborativo de la aplicación;
- Crear Headless Univer e instalar colaboración;
- Cargar UnitData o un punto de control Snapshot;
- Reproducir sus cambios;
- Obtener y reproducir el intervalo hasta la última revisión remota;
- Esperar la aplicación de los resultados más recientes de los módulos de fórmulas instalados;
- Devolver un Runtime listo para
execute(),pull()ycommit().
Una invocación puntual completa es:
La propiedad es directa y un fallo no deja estado, por lo que sirve para órdenes poco frecuentes y tareas puntuales. A cambio, la siguiente orden inicia otro proceso Node.js y repite toda la carga, sin reutilizar Univer ni el estado Unit anterior.
Paso 2: alojar Runtime en un pool de Workers
@univer-cli/univer-collaboration-runtime-pool mantiene un proceso Worker y Runtime separados por clave residente. La aplicación aporta la entrada Worker; backend, credenciales y Univer se crean dentro:
import { defineUniverCollaborationRuntimeWorker } from "@univer-cli/univer-collaboration-runtime-pool";export default defineUniverCollaborationRuntimeWorker({ async createRuntime(init: { unitId: string; unitType: number }) { return createRuntimeFromApplicationInit(init); },});La entrada debe apuntar a JavaScript ESM compilado. init se envía solo durante la creación en frío y debe admitir clonación estructurada.
No hace falta diseñar otra capa RPC: el lease ya representa las API públicas getState(), execute(), fetch(), pull(), commit() y exportUnitData():
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 identifica el Runtime reutilizable, no es solo un argumento de consulta. La misma clave siempre debe representar la misma Unit, cadena de estado colaborativo e inicialización compatible. Una Unit normal y un borrador Worktree usan claves distintas aunque compartan unitId.
El pool tiene cuatro comportamientos principales:
- Una clave se concede a un solo lease cada vez. Las solicitudes concurrentes esperan en FIFO y no modifican simultáneamente el mismo estado;
release()devuelve un Runtime fiable a la caché inactiva para la siguiente solicitud de la misma clave;invalidate()indica un estado no fiable. Destruye Worker y Runtime y la siguiente solicitud carga en frío;- TTL y LRU pueden expulsar Runtimes inactivos. Tiempos agotados, fallos Worker y errores de protocolo también invalidan la instancia.
Si el pool se crea dentro de una CLI de corta vida, la reutilización sigue sin cruzar órdenes:
Workers proporcionan aislamiento de procesos, préstamo exclusivo y terminación ante fallos. Para reutilizar entre invocaciones, el pool necesita un propietario más duradero que una orden.
Paso 3: usar Daemon como propietario duradero
El daemon es un proceso Node.js local iniciado a demanda. La CLI lo llama mediante socket Unix o canalización con nombre de Windows. Terminar la CLI no lo detiene, por lo que pool, Workers y Runtimes inactivos permanecen.
Crea el pool una vez en la entrada daemon y adquiere un lease en cada handler de negocio:
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();La aplicación define parseExecuteRequest() y las reglas de targetKey. El daemon solo aporta transporte local y ciclo de vida; no conoce Units, Runtimes ni protocolo colaborativo. Su cierre debe llamar a pool.close() para mantener un propietario final claro de Workers activos e inactivos. Aquí commitWithPull() gestiona pull/commit tras pull-required y comprueba el estado final. Conservadoramente, cualquier error no manejado invalida el lease.
Una orden normal ahora envía una sola solicitud de negocio:
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,});Si no existe el socket, el cliente inicia la entrada daemon, espera el enlace de identidad y protocolo y envía la solicitud. Si hay un daemon compatible, lo reutiliza directamente.
Paso 4: comparar primera solicitud y siguientes
El primer acceso sigue la ruta fría: puede necesitar iniciar el daemon y el pool debe crear Worker y Runtime.
Los accesos posteriores a la misma clave siguen la ruta caliente. El proceso CLI vuelve a arrancar, pero no repite la carga costosa:
Se reutiliza un Runtime activo ya creado y cargado. No se omite la consistencia colaborativa. Normalmente debes ejecutar pull() al adquirir el lease, revisar el commit final y manejar pull-required, retry, unknown o conflict.
Costes que desaparecen y permanecen
| Coste | Cargar directamente cada vez | Daemon + pool Worker |
|---|---|---|
| Inicio CLI, argumentos, salida | Cada vez | Cada vez |
| Solicitud/respuesta JSON por socket | Ninguno | Cada vez |
| Inicio daemon y módulos | Ninguno | Primera solicitud o reinicio |
| Inicio Worker y entrada | Sin Worker | Creación fría por clave |
| Crear Univer e instalar plugins | Cada vez | Creación fría por clave |
| Punto de control, cambios iniciales, espera de fórmulas | Cada vez | Creación fría por clave |
pull() de cambios durante inactividad | Cada vez | Normalmente cada vez |
execute() y commit() | Cada vez | Cada vez |
| Memoria inactiva, handle backend, proceso Worker | Liberados al salir | Retenidos hasta expulsión, invalidación o cierre daemon |
La reutilización intercambia memoria residente y cantidad de Workers por menor latencia posterior. Elige idleTtlMs, maxEntries, tiempo de apertura y de operaciones según eventos del pool y mediciones, en lugar de guardar toda Unit indefinidamente.
Concurrencia, fallos y cierre
Cuatro reglas resumen el comportamiento:
- Las solicitudes de la misma clave esperan. Un Runtime pertenece a un solo lease y evita cambios entrelazados en el estado.
- Claves distintas trabajan en paralelo en Workers distintos. Cada clave residente requiere su propio Worker y Runtime.
- Solo se reutiliza estado fiable. Usa
release()después del éxito y de limpiar mutaciones locales;invalidate()tras timeout, fallo, error de protocolo o estado incierto. - La responsabilidad de cierre baja por niveles. La CLI termina su solicitud; el cierre daemon llama a
pool.close(); el pool termina Workers y cierra Runtimes y backends.
Para órdenes poco frecuentes con latencia puntual aceptable, lo más simple es usar directamente @univer-cli/univer-collaboration-runtime. Combina daemon y @univer-cli/univer-collaboration-runtime-pool solo cuando las mediciones muestren que recargar domina el coste y se acepte el proceso residente y su memoria.
Consulta el catálogo de paquetes para la lista completa.
Footnotes
-
Una Unit es una instancia de documento, como un libro de cálculo o un documento de texto. Una hoja forma parte de un libro y no es una Unit independiente. ↩
-
Aquí, Runtime es la instancia activa de Univer y los recursos necesarios para operar sobre una Unit cargada, no el ejecutable de Node.js ni una instantánea guardada. ↩
-
TTL retira un Runtime inactivo al vencer su tiempo de vida; LRU retira los menos usados recientemente cuando falta capacidad. Liberan recursos sin eliminar la Unit persistida. ↩
¿Cómo es esta guía?