--- issue: 0003 title: Descentralización / alta disponibilidad — cluster NATS + JetStream replicado + control plane sin SPOF status: spec created: 2026-06-07 domain: infra scope: unibus (pkg/embeddednats, pkg/membership, pkg/blobstore, pkg/client, cmd/membershipd) + despliegue multi-nodo depends_on: 0001 (la auth de cluster y de clientes va junto con el endurecimiento) --- # Objetivo Que la caída de un servidor **no deje el bus sin servicio**. Hoy unibus es un único `membershipd` (con NATS embebido + SQLite local): si ese host muere, no hay bus. Este issue lleva unibus a un modelo **descentralizado / alta disponibilidad** usando las capacidades nativas de NATS: cluster multi-nodo, JetStream replicado (RAFT), y el estado del control plane fuera de la SQLite local. **No es federación** (multi-operador con dominios distintos); es eliminar el punto único de fallo dentro de un único dominio administrativo controlado por nosotros. # Requisito clave de quorum (decisión de infraestructura) JetStream replica con RAFT, que necesita **mayoría (quorum)** para confirmar escrituras. Las consecuencias son duras y hay que asumirlas desde el diseño: | Nodos | Réplica | Tolera caída de | Nota | |---|---|---|---| | 1 | R1 | 0 | situación actual (SPOF) | | 2 | R2 | **0** | si cae uno se pierde quorum: las escrituras se bloquean. NO sirve para HA | | **3** | **R3** | **1** | mínimo real para "si un server cae, seguimos" | | 5 | R5 | 2 | mayor tolerancia | **Por tanto el objetivo del usuario ("si mi server falla, no nos quedamos sin servicio") exige 3 nodos JetStream.** Servers disponibles hoy: **magnus** y **homer** (ambos VPS OVH). El tercero está pendiente de conseguir. | Nodo | IP pública | Estado | Notas | |---|---|---|---| | magnus | (en pass: `MAGNUS_ovh_ssh_ROOT`) | disponible, **cargado** | corre coolify, minio, postgres, authentik, portainer, dagu — revisar recursos antes | | homer | `141.94.69.66` | disponible, vivo | creds en pass (`vps_ovhcloud_SSH_SERVER_HOMER_-_root`, `vps_SSH_SERVER_HOMER_dataherrero`); tenía coolify | | nodo 3 | — | **pendiente** | conseguir un tercer VPS siempre-on, o reusar om/datardos si se liberan | Preparación previa al deploy de cada nodo: alta del alias SSH + clave, integración en la WireGuard, y revisar/aligerar la carga existente (coolify, etc.). ## Rollout R1 → R3: funcionar con 2 nodos hoy, HA con 3 mañana No se "desactiva el quorum"; se controla el **número de réplicas** de cada stream/KV: | Réplicas | Quorum | Tolera | Sirve con | |---|---|---|---| | R1 | ninguno (1 copia) | 0 caídas | 1-2 nodos, sin bloqueo | | R3 | 2 de 3 | 1 caída | 3 nodos | - **Fase actual (magnus + homer):** desplegar con streams/KV en **R1** (flag `decentralized: off`). El bus funciona al 100% para operar, sin tolerancia a fallo todavía. Opción: streams en **R2** para duplicar los datos en ambos nodos (durabilidad/backup vivo), asumiendo que la escritura necesita los dos hasta el 3er nodo. - **Cuando entre el nodo 3:** escalar en caliente `nats stream update --replicas 3` (idem KV/Object Store) + añadir el nodo al cluster + flag `decentralized: on`. **HA real, sin downtime, sin reescritura, sin migrar datos.** - **Aviso de 2 nodos:** NO montar el meta-group de JetStream con 2 nodos como si fuera HA — su quorum es 2, y la caída de uno bloquea la gestión de streams. Con 2 servers, modelo recomendado: **magnus principal (R1) + homer 2º nodo/réplica**, y escalar a R3 al tener el tercero. Mientras solo haya 2 nodos: el **data plane efímero** (core-NATS, rooms `ModeNATS`) sí tolera la caída de uno (los clientes reconectan al otro), pero las **rooms persistentes y el control plane** (que necesitan quorum) no. El issue se despliega de verdad cuando haya 3 nodos. # Contexto — por qué hoy es un SPOF - `pkg/embeddednats` arranca un NATS **standalone** (sin cluster). - `pkg/membership` guarda rooms/members/room_keys/users en una **SQLite local** al proceso. - `pkg/blobstore` guarda los blobs en el **disco local** del proceso. - El cliente (`pkg/client`) conecta a **una** URL de NATS y **una** de control plane. Todo vive en un host. Ese host es el punto único de fallo. # Diseño ## Pieza 1 — Cluster NATS (data plane replicado) `pkg/embeddednats` gana opciones de cluster: `server.Options.Cluster` (nombre + host/puerto de routes) y `Routes` (los otros nodos). Cada `membershipd` arranca su NATS embebido en cluster con los demás. JetStream se habilita con `Replicas: 3` en streams y KV. Auth entre nodos (routes) con credenciales propias (no las de clientes), y TLS también en las routes (reusa la CA del issue 0001). ## Pieza 2 — Control plane sin estado local (SQLite → JetStream KV) Es el corazón del issue. Hoy `pkg/membership.Store` es SQLite. Se introduce, por **branch-by-abstraction**, una interfaz `Store` con dos implementaciones: - `sqliteStore` — la actual (sigue siendo el default mientras el flag está off; útil para un solo nodo / desarrollo). - `jetstreamStore` — nueva: rooms, members, room_keys y users (la tabla del issue 0001) viven en **JetStream KV** (buckets replicados R3). Cualquier nodo lee/escribe el mismo estado; RAFT garantiza consistencia. El HTTP control plane pasa a ser efectivamente **stateless**: cualquier `membershipd` sirve cualquier request porque el estado está en el KV replicado. Flag `decentralized` (off → on). Migración inicial de datos SQLite → KV con un comando `membershipd migrate-to-kv` (idempotente). Las claves de room siguen selladas igual; solo cambia **dónde se guardan**, no el cifrado. ## Pieza 3 — Blobs replicados (object store → NATS Object Store) `pkg/blobstore` gana una implementación sobre **NATS Object Store** (encima de JetStream, replicado R3) además de la de disco local. Los blobs (ya ciphertext, E2E) quedan disponibles desde cualquier nodo. Encaja con el GC del issue 0002. ## Pieza 4 — Cliente con failover `pkg/client`: aceptar **lista** de seeds de NATS y **lista** de URLs de control plane. `nats.go` ya hace reconnect/failover entre servidores del cluster nativamente (`nats.Servers([...])`, `nats.MaxReconnects(-1)`). El control plane HTTP se prueba en orden con reintento. Así, si un nodo cae, el cliente reconecta a otro de forma transparente. ## Pieza 5 — Despliegue multi-nodo 3 nodos `membershipd`, cada uno con su NATS embebido en cluster, JetStream R3, mismo `ca.crt`/credenciales de routes. systemd en cada VPS. Los clientes reciben la lista de los 3 endpoints. Health/observabilidad por nodo (`/healthz` + métricas de JetStream: líder RAFT, lag de réplica). # Decisiones técnicas | Decisión | Elegido | Alternativa | Razón | |---|---|---|---| | Nº de nodos de quorum | 3 (R3) | 2 (R2) | 2 no tolera caída de uno; 3 es el mínimo real de HA | | Estado del control plane | JetStream KV replicado | SQLite replicada a mano / Postgres externo | KV ya viene con NATS, mismo RAFT que JetStream, cero infra extra | | Migración del store | branch-by-abstraction (interfaz `Store`, dos impls, flag) | reescritura directa | master nunca se rompe; sqlite sigue para 1 nodo/dev | | Blobs | NATS Object Store | disco compartido / S3 | replicado nativamente, sin dependencia externa | | Failover de cliente | lista de seeds + reconnect nativo nats.go | balanceador externo | menos infra, nats.go ya lo hace | | Federación multi-operador | **fuera de alcance** | — | no es el objetivo; es otra liga (trust entre dominios) | # Fases (TBD, ramas `issue/0003x-*`) 1. **0003a — cluster NATS** — opciones de cluster/routes + TLS de routes en `pkg/embeddednats`; arrancar 2-3 nodos locales en tests e2e y verificar que un subject publicado en uno llega a un suscriptor en otro. 2. **0003b — interfaz Store + jetstreamStore (KV)** — abstraer `pkg/membership.Store`; implementar rooms/members/room_keys/users sobre JetStream KV R3; tests de consistencia. Flag `decentralized: off`. 3. **0003c — migrate-to-kv** — comando idempotente SQLite → KV + test de paridad (mismo estado antes/después). 4. **0003d — blobs en Object Store** — impl `pkg/blobstore` sobre NATS Object Store replicado. 5. **0003e — cliente failover** — lista de seeds + lista de ctrl-urls + reconnect; test que mata el nodo al que está conectado y verifica que sigue operando. 6. **0003f — despliegue 3 nodos** (humano) — 3 VPS en cluster, JetStream R3, flag `decentralized: on`. Chaos test real: matar un nodo en producción y comprobar que el servicio sigue. # Definition of Done (evidencia ejecutable) - **Golden:** 3 nodos en cluster; un cliente publica en un nodo y otro cliente suscrito a otro nodo lo recibe; crear room + invitar funciona desde cualquier nodo. - **Edge:** un cliente conectado al nodo A; se **mata el nodo A**; el cliente reconecta a B automáticamente y sigue publicando/recibiendo sin perder la sesión. - **Error path (chaos):** matar 1 de 3 nodos → el control plane sigue aceptando escrituras (quorum 2/3); matar 2 de 3 → las escrituras se bloquean (quorum perdido, comportamiento esperado y documentado, no corrupción). - `CGO_ENABLED=0 go test ./...` verde, incluido un test e2e multi-nodo en proceso. # Riesgos y mitigaciones | Riesgo | Mitigación | |---|---| | Solo 2 nodos disponibles → sin quorum real | prerequisito explícito de 3 nodos antes de 0003f; hasta entonces, despliegue queda en standalone | | Latencia inter-VPS afecta RAFT | nodos en la misma región o con buena red; medir; R3 tolera latencias moderadas | | Migración SQLite→KV pierde datos | comando idempotente + test de paridad + backup de la SQLite antes | | Partición de red (split-brain) | RAFT lo previene: el lado sin quorum se bloquea para escritura, no diverge | | Complejidad operativa de 3 nodos | observabilidad de JetStream (líder, lag) + `/healthz` por nodo + runbook en deploy/ | # Orden recomendado respecto a otros issues 1. **0001 (seguridad)** primero: la auth de clientes (nkey) y la CA/TLS se reutilizan para las routes del cluster. Desplegar descentralizado sin auth sería abrir varios puntos públicos sin protección. 2. **0003 (este)** después: una vez el bus es seguro, replicarlo en 3 nodos. 3. **0002 (media v2)** es ortogonal; su object store encaja con la pieza 3 (blobs replicados) cuando ambos estén. # Fuera de alcance - Federación entre operadores/dominios distintos (otra liga; requiere protocolo de trust entre dominios). - Multi-tenant / accounts de NATS por organización. - Auto-escalado dinámico de nodos.