Escalado
Estado: DISEÑADO; Fases 1, 2 y 3 completas y desplegadas (2026-08-21). Levantado el mismo día a partir del requisito: «el software debe soportar más de 10.000 cámaras». Es un objetivo de largo plazo: la arquitectura debe poder crecer añadiendo hardware sin reescribir código, y el trabajo se hace por fases sin romper los despliegues actuales (3-52 cámaras).
El techo real de hoy, medido, no estimado
Sección titulada «El techo real de hoy, medido, no estimado»Antes de diseñar hay que ser honesto sobre dónde está el límite. No es un ajuste de índices — son tres procesos singleton y una tabla sin particionar:
- go2rtc es un proceso único (
stream_syncer.go,GO2RTC_APIcableado a una sola URL enmain.go). Cada cámara activa registra 3 streams (crudo +_live+_live_sd). Con 36 cámaras y transcodificación por NVENC ya se rompió una vez (el cap real de GeForce es 8 sesiones de encode concurrentes — no es un límite de silicio, es del driver consumer). La mitigación actual (NVDEC decode + libx264 encode en CPU) evita ese cap pero traslada el costo a la CPU de un único host. analytic_eventsno está particionada.deploy/phase2_schema.sqllo dice explícitamente:orion_create_next_partition()es un no-op literal, comentado como “tabla NORMAL en este despliegue”. A una tasa conservadora de 1 evento/cámara/minuto, 10.000 cámaras generan ~14 millones de filas/día; con tráfico real (varios eventos/minuto en accesos), fácilmente 30-50M/día. Sin partición, cadaVACUUM, cada índice, cada refresh demv_hourly_activityse vuelve progresivamente más caro sin límite.- Un solo
orion-apipor sitio, sin estado externalizado por completo.Cacheya es Redis (compatible con horizontal). PeroStorageResolveryPlanResolvercachean en memoria de proceso (TTL 30s — tolerable),WSHubguarda las conexiones WebSocket en un mapa de proceso (no tolerable: un evento generado en la instancia A nunca llega a un cliente conectado a la instancia B), yContinuousRecorderarranca ffmpeg sin ningún tope de concurrencia — con 10.000 cámaras activas serían 10.000 procesos ffmpeg en un host, algo que ningún servidor sostiene. - Postgres es una sola instancia, sin réplicas ni sharding en ningún sitio del despliegue.
Lo que SÍ existe y ya funciona, y es la base de todo el diseño:
CAMERA_RANGE (workers/shared/camera_filter.py) reparte cámaras entre
varias instancias del mismo worker Python — probado, en producción, en 5
detectores. El backend Go no tiene el equivalente hoy. Ese es
exactamente el vacío que este diseño cierra.
Cifra defendible hoy, sin ningún cambio de código: lanzando varias
instancias de detector con CAMERA_RANGE distinto en el mismo servidor,
~30-40 cámaras por nodo con una GPU de gama alta (el caso medido: 36
cámaras en una RTX 5090). Más que eso exige multiplicar instancias (más
CPU/RAM) o aceptar el tope de 8 sesiones NVENC si se transcodifica en GPU.
El modelo: nodo + panel central
Sección titulada «El modelo: nodo + panel central»Es el patrón que ya usan los VMS grandes (Milestone, Genetec) y es consistente con lo que el cliente ya entiende de IndigoVision. No es Kubernetes ni un cluster homogéneo — encaja mal con hardware GPU dedicado por sitio, y multiplica el trabajo de reingeniería (locks distribuidos, particionado automático) sin necesidad real para este caso de uso.
┌─────────────────────────────────────────────────────────┐│ PANEL CENTRAL ││ Postgres (usuarios, cámaras↔nodo, alarmas, config global) ││ orion-api-central: dashboard, Station, auth, WS a clientes ││ Redis: cache + Pub/Sub (nuevo) para eventos cross-nodo │└──────────────────────┬──────────────────────────────────┘ │ HTTPS, INTERNAL_API_KEY por nodo ┌───────────────┼───────────────┐ │ │ │┌───────▼──────┐ ┌───────▼──────┐ ┌──────▼───────┐│ NODO 1 │ │ NODO 2 │ │ NODO N ││ go2rtc │ │ go2rtc │ │ go2rtc ││ recorder │ │ recorder │ │ recorder ││ detectores │ │ detectores │ │ detectores ││ (CAMERA_RANGE)│ │ (CAMERA_RANGE)│ │ (CAMERA_RANGE)││ ~30-500 cams │ │ ~30-500 cams │ │ ~30-500 cams │└──────────────┘ └──────────────┘ └──────────────┘Cada nodo es, en esencia, una instalación completa de Orion hoy (go2rtc + recorder + detectores), apuntando a un rango de cámaras propio. El panel central agrega: base de datos de eventos, usuarios, alarmas, configuración, y sirve el dashboard/Station a los operadores.
Pieza 1: Camera sabe a qué nodo pertenece — COMPLETA Y DESPLEGADA (2026-08-21)
Sección titulada «Pieza 1: Camera sabe a qué nodo pertenece — COMPLETA Y DESPLEGADA (2026-08-21)»Camera ya tiene el patrón exacto para esto: StorageTargetID *uint y
RecordingPlanID *uint, ambos nullable, nil = comportamiento legacy (nodo
local por defecto). Se añade el mismo patrón:
type Node struct { ID uint Name string // "Nodo Torre 3", "Nodo Girardot" BaseURL string // https://10.3.x.x:4091/api — cómo lo alcanza el central APIKey string // clave PROPIA de este nodo, no la INTERNAL_API_KEY global Active bool LastSeenAt *time.Time}Camera.NodeID *uint— nil = nodo local (el propio proceso central también puede servir cámaras directamente; un despliegue de 3-52 cámaras no necesita crear ni un soloNode).- No se amplía
Device(confirmado:Devicees explícitamente un NVR/DVR de terceros, no un nodo de cómputo Orion — mezclarlos rompería una frontera de diseño ya documentada). service.NodeScope(node_scope.go) es el equivalente Go deCAMERA_RANGE: leeORION_NODE_IDuna sola vez al arrancar (vacío o no numérico = nodo local, nunca tumba el arranque por un typo) y exponeAplicar(q), que añadenode_id = ?onode_id IS NULLa una consulta. Deliberadamente NO es un resolver con caché (a diferencia deStorageResolver/PlanResolver): el nodo de una instancia no cambia en caliente, no hay nada que refrescar.- Aplicado en los 4 sitios que son cómputo real de este nodo
(
continuous_recorder.go,postercache.go,stream_syncer.go×3:PruneOrphans,syncAllActiveBlocking,EnsureSync) — todos hablan con el go2rtc/ffmpeg de ESTE proceso, así que procesar la cámara de otro nodo sería directamente incorrecto, no solo redundante. - Deliberadamente NO aplicado en
CameraService.List()(el listado administrativo que ve el panel central necesita ver todas las cámaras de todos los nodos) ni enRefreshNames(acción disparada por un operador, pertenece a la Pieza 2 —llamadas central→nodo— que aún no existe). - CRUD admin-only en
/api/nodes(lectura incluida, mismo criterio questorage-targets: exponeBaseURL, direcciones de red internas).Node.APIKeynunca viaja en JSON (json:"-") y se genera automáticamente al crear el nodo — una clave por nodo, no laINTERNAL_API_KEYglobal, para poder revocar un nodo comprometido sin apagar los demás. - Verificado en producción:
ORION_NODE_IDno está puesto en este sitio (nodo local), y tras el despliegue el sistema completo sigue funcionando idéntico — 52 cámaras, 9 en línea, 12.740 eventos, los 7 workers a una instancia cada uno. Cero regresión de comportamiento, exactamente la garantía que ya se dio conStorageTargetID/RecordingPlanID. - 11 tests nuevos (
node_scope_test.go,node_service_test.go) cubren: nodo local por defecto, nodo remoto filtra solo sus cámaras, valor no numérico cae a local, duplicados, el bug de GORM conActive:falsey valores cero (mismo patrón que StorageTarget/RecordingPlan), borrado desasigna cámaras. Un fallo real se atrapó en el camino: dos tests destream_syncer_test.goconstruíanGo2RTCSyncer{}a mano (sin pasar por el constructor) ynodeScopequedaba nil — panic al primerAplicar(). Corregido en el helper de test, no en producción (el constructor real siempre inicializanodeScope).
Pieza 2: autenticación nodo↔central, en las dos direcciones — COMPLETA Y DESPLEGADA (2026-08-21)
Sección titulada «Pieza 2: autenticación nodo↔central, en las dos direcciones — COMPLETA Y DESPLEGADA (2026-08-21)»Antes INTERNAL_API_KEY solo cubría worker→backend, con una clave
global compartida que otorgaba bypass total de RBAC sin distinguir
quién la usaba.
- Nodo→central (subir eventos, marcar segmentos procesados):
middleware.NodeAuthValidator(mismo patrón de caché TTL 30s queSessionValidator, indexado por clave en vez de por ID) identifica quéNodehizo la llamada por suAPIKeypropia.authRequired()la prueba solo si la clave no coincidió con la global, y solo por cabecera — nunca por?token=, mismo criterio que la clave global. Un nodo desactivado deja de autenticar en ≤30s (mismo TTL, misma garantía que la revocación de sesión de usuario).LastSeenAtse actualiza en cada llamada autenticada (best-effort, en goroutine, nunca bloquea la petición real). - Central→nodo (pedir salud, forzar resync):
NodeInternalHandler, dos rutas nuevas bajo/api/internal(ya protegido conRequireRole(worker), que ahora también acepta la clave de un nodo):GET /internal/node/health— cuántas cámaras sirve este proceso, si su go2rtc responde, yauthenticated_as_node_id(la identidad con la que se autenticó la llamada — útil para depurar si no coincide con elnode_idque este proceso cree ser).POST /internal/node/resync— fuerza una resincronización inmediata con go2rtc (reutilizaGo2RTCSyncer.EnsureSync, no duplica lógica), para no esperar hasta 60s tras reasignar una cámara a este nodo.- No hizo falta un esquema de auth nuevo: el central llama a estas rutas
usando la
APIKeydel nodo al que pregunta — la misma identidad que ese nodo ya usa para subir sus propios eventos.
POST /api/nodesexpone la clave EN TEXTO PLANO, solo esa vez — un hallazgo real detectado al probar:Node.APIKeyllevajson:"-"(nunca sale enList/Get, correcto), pero eso también lo ocultaba en la respuesta de creación, dejando al admin sin forma de copiarla para configurar el.envdel nodo remoto.Createdevuelve un JSON explícito con la clave (patrón “se muestra una vez”, como un token personal de GitHub); listar/leer después nunca la vuelve a mostrar.- Verificado en producción de punta a punta: la clave global sigue
autenticando a los workers reales (52 cámaras, 13k+ eventos, 7 workers a
una instancia, sin ninguna afectación); un nodo de prueba creado,
autenticado con su propia clave contra
/internal/node/health(authenticated_as_node_idcoincide exacto),resyncfunciona, una clave inventada da 401, y el listado de nodos nunca incluye la clave. - 20 tests nuevos entre
node_validator_test.go(el validador aislado) yauth_node_test.go(el middleware completo montado sobre gin). Dos fallos reales atrapados en el camino: el mismo bug de GORM conActive:false+default:trueque ya salió con StorageTarget/ RecordingPlan/Node (crear un nodo desactivado directo condb.Createlo guardaba activo), y una condición de carrera con SQLite:memory:— el pool de conexiones por defecto abre más de una conexión física, y cada una es una base en memoria DISTINTA;MarcarVisto()escribe desde una goroutine aparte y podía tomar una conexión que el test nunca releía. Arreglado limitando el pool de prueba a una sola conexión.
Pieza 3: WebSocket cross-nodo — el hueco más caro de cerrar
Sección titulada «Pieza 3: WebSocket cross-nodo — el hueco más caro de cerrar»WSHub es 100% estado de proceso: un evento detectado en el Nodo 2 nunca
llegará a un operador con el navegador abierto contra el central, porque
Broadcast/BroadcastForCamera solo iteran conexiones locales.
No hay ningún Redis Pub/Sub en el repo hoy — Cache solo implementa
Get/Set/Del, cero Publish/Subscribe. Se construye desde cero, pero la
mitad del trabajo ya existe: la lógica de fan-out a clientes
(WSHub.BroadcastForCamera) sirve tal cual; solo falta la fuente de
entrada.
Nodo detecta evento → POST /api/internal/analytics (como hoy) → el central, al insertar el evento, además hace redis.Publish("orion:events:camera:<id>", evento)Central: una goroutine Subscribe(orion:events:*) que recibe y llama directamente a WSHub.BroadcastForCamera(evento, cameraID)Esto también resuelve, de paso, el problema de correr N instancias del
propio orion-api-central detrás de un balanceador (necesario mucho
antes de llegar a 10.000 cámaras, porque el propio dashboard y las
consultas de Forense/Analíticas tienen su propio techo de CPU/conexiones):
sin Pub/Sub, dos instancias de orion-api-central ya tendrían el mismo
problema entre sí, aunque hubiera un solo nodo.
Pieza 4: particionar analytic_events
Sección titulada «Pieza 4: particionar analytic_events»El cascarón ya existe (orion_create_next_partition, hoy no-op) y el
nombre de la función ya delata la intención original: partición por
RANGE de detected_at, no por camera_id.
Se confirmó revisando analytics_service.go: todo filtro real incluye
un rango de fecha como predicado (detected_at >= / <= / BETWEEN,
DATE_TRUNC para series temporales); camera_id aparece siempre como
filtro adicional dentro de ese rango, nunca solo. Eso descarta la
partición compuesta (RANGE fecha + HASH camera_id): añadiría complejidad
sin beneficio medible, porque ninguna consulta de hoy se beneficiaría de
la sub-partición por cámara.
Diseño: partición mensual por detected_at, con creación automática
adelantada (un job que crea el mes siguiente antes de que falte, igual
criterio que ya existe para las carpetas de grabación del recorder — “crear
la carpeta del día de mañana antes de que llegue la medianoche”). Los
paneles como mv_hourly_activity (WHERE detected_at >= now() - interval '24 hours') pasan a tocar como mucho 2 particiones (mes actual + anterior
en el borde), en vez de escanear una tabla monolítica creciente sin fin.
Orden de migración (tabla con datos reales en producción, no se puede
tocar a la ligera): crear la tabla particionada en paralelo, migrar por
lotes con un INSERT ... SELECT acotado por fecha, verificar conteos,
renombrar dentro de una transacción corta. Mismo patrón de cautela que ya
se siguió con StorageTarget/RecordingPlan (AutoMigrate no rompe
producción porque solo añade, nunca reescribe una tabla existente — una
partición sí reescribe, así que esta migración es la única pieza de este
documento que no es “CREATE-IF-NOT-EXISTS” y merece su propia ventana de
mantenimiento).
Pieza 5: tope de concurrencia en ContinuousRecorder
Sección titulada «Pieza 5: tope de concurrencia en ContinuousRecorder»Hoy no tiene ninguno (a diferencia del Recorder de clips por evento, que
sí tiene RECORDER_MAX_CONCURRENT=20). Con un nodo sirviendo, por
ejemplo, 500 cámaras, 500 ffmpeg concurrentes en ese host es razonable
si el host está dimensionado para ello — pero el recorder debe tener un
semáforo configurable (RECORDING_MAX_CONCURRENT, por nodo) para que un
error de asignación (alguien pone 5.000 cámaras en un nodo pensado para
500) degrade con un aviso claro en vez de agotar descriptores de archivo y
tumbar el proceso entero sin explicación.
Fases (ninguna rompe el despliegue actual)
Sección titulada «Fases (ninguna rompe el despliegue actual)»Fase 1 — Particionar analytic_events: COMPLETA Y DESPLEGADA
(2026-08-21). Migración deploy/phase3_particionar_analytic_events.sql,
probada dos veces en un schema aislado (clonando estructura+datos reales)
antes de tocar producción — la prueba encontró y corrigió dos bugs reales
(el rename de la PK vieja chocaba con la nueva; la migración no era
idempotente porque un RETURN dentro de un bloque DO separado no corta
los bloques siguientes) que de otro modo habrían fallado en producción o
peor, a medias. Aplicada con copia de seguridad fresca previa
(db_orion-20260821-171253.dump) y los 3 workers que escriben en la tabla
pausados brevemente para evitar una condición de carrera con el INSERT
de copia (el primer intento SIN pausarlos abortó de forma segura por una
discrepancia de 1 fila — la verificación de conteos hizo exactamente lo
que debía). Verificado: 12.595 filas migradas sin pérdida, 3 particiones
mensuales creadas, EXPLAIN confirma partition pruning real (la consulta
de últimas 24h solo toca las particiones existentes), orion_create_next_partition()
ya no es no-op, y el pipeline completo (detectores → API → dashboard) sigue
funcionando sin cambios de comportamiento. La tabla original se conserva
como analytic_events_old — borrarla es una decisión manual posterior.
Fase 2 — Node + Camera.NodeID + filtro por nodo en el backend Go:
COMPLETA Y DESPLEGADA (2026-08-21). Siguió el patrón ya probado de
StorageTarget. Verificado en producción con NodeID siempre nil (el
100% de los despliegues actuales): cero cambio de comportamiento — misma
garantía que ya se dio con StorageTargetID/RecordingPlanID. Ver la
sección “Pieza 1” más arriba para el detalle completo.
Fase 3 — Auth nodo↔central + tope de concurrencia en el recorder:
COMPLETA Y DESPLEGADA (2026-08-21). Ver la sección “Pieza 2” más arriba
para la auth. El tope de concurrencia: ContinuousRecorder gana
RECORDING_MAX_CONCURRENT (default 500, sin variable de entorno —
generoso frente a lo medido en producción, bajo frente a agotar
descriptores/hilos de un host razonable; un nodo real dimensionado para
cientos de cámaras debe fijarlo explícito según su propio hardware). Al
alcanzar el tope, reconcile() arranca hasta el límite (orden estable
por ID, no aleatorio — el mismo subconjunto queda sin grabar tick tras
tick, para que el problema sea diagnosticable en vez de repartirse entre
cámaras distintas cada minuto) y avisa una sola vez por ciclo cuántas
cámaras se quedaron sin grabar, distinguiendo explícitamente “omitida por
el tope” de “no le tocaba grabar por su franja horaria” (mismo criterio de
RecordingPlan) — mezclarlas habría hecho el aviso engañoso.
La lógica de selección se extrajo a seleccionarParaArrancar(), una
función pura (sin exec.Cmd, sin mutex, sin BD) para poder probar el
tope sin lanzar ffmpeg de verdad ni depender de que el binario esté
instalado en la máquina de pruebas. 6 tests nuevos cubren: dentro del
tope arrancan todas, por encima arranca hasta el tope y avisa el resto,
orden estable (verificado en 5 iteraciones seguidas), una cámara ya
corriendo no compite por un hueco nuevo, y que “fuera de franja” nunca se
cuente como “omitida por el tope”.
Una regresión real, ajena a este cambio, atrapada al correr el suite
completo antes de desplegar: al reescribir recording_plan_test.go
horas antes (para arreglar un problema de codificación de caracteres) se
perdió sin querer una función auxiliar (writeBytes) de la que dependían
7 tests de storage_target_test.go — un archivo que esa reescritura ni
tocó. Reconstruida con la firma exacta que sus llamadas ya esperaban;
los 11 tests de StorageTarget, incluidos los de reasignación de destino
(la pieza más delicada de la Fase de almacenamiento distribuido), vuelven
a pasar. Lección: correr el suite completo (go test ./internal/...), no
solo los tests del área que se acaba de tocar, antes de dar por cerrada
una sesión de reescritura.
Verificado en producción: con 52 cámaras reales muy por debajo del default de 500, cero cambio de comportamiento — los 7 workers a una instancia, sin ningún aviso de tope en el log (no puede aparecer con este volumen).
Fase 4 — Redis Pub/Sub + WSHub cross-proceso. La pieza más cara y la
que más tarde hace falta: solo se vuelve necesaria en cuanto haya más de
un nodo, o más de una instancia de orion-api-central.
Fuera de alcance de este documento: el reparto de streams de go2rtc DENTRO de un mismo nodo si algún día un solo nodo necesitara más de un proceso go2rtc (no se ha visto esa necesidad con las cifras de hoy: el límite de 30-40 cámaras/nodo ya viene de CPU/NVENC, no de go2rtc en sí).
Qué NO hace falta tocar
Sección titulada «Qué NO hace falta tocar»StorageResolver/PlanResolver: su cache de 30s en memoria de proceso ya es tolerable — no son estado por-cliente como el WebSocket, son resoluciones de configuración que convergen solas dentro del TTL.CAMERA_RANGEen Python: ya funciona, no se reescribe. El backend Go solo necesita replicar el mismo criterio de filtrado que Python ya demostró en producción.- go2rtc en sí: no se toca su código. La estrategia es un proceso por nodo, no repartir streams dentro de un mismo proceso go2rtc.
Ver docs/almacenamiento-distribuido.md (mismo patrón de FK nullable +
resolver centralizado, precedente directo de Node/NodeID) y
docs/vms-ia-separacion.md (mismo criterio de “un cambio de arquitectura
por fases, sin romper lo que funciona hoy”).