Ir al contenido

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).

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:

  1. go2rtc es un proceso único (stream_syncer.go, GO2RTC_API cableado a una sola URL en main.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.
  2. analytic_events no está particionada. deploy/phase2_schema.sql lo 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, cada VACUUM, cada índice, cada refresh de mv_hourly_activity se vuelve progresivamente más caro sin límite.
  3. Un solo orion-api por sitio, sin estado externalizado por completo. Cache ya es Redis (compatible con horizontal). Pero StorageResolver y PlanResolver cachean en memoria de proceso (TTL 30s — tolerable), WSHub guarda 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), y ContinuousRecorder arranca 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.
  4. 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.

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 solo Node).
  • No se amplía Device (confirmado: Device es 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 de CAMERA_RANGE: lee ORION_NODE_ID una sola vez al arrancar (vacío o no numérico = nodo local, nunca tumba el arranque por un typo) y expone Aplicar(q), que añade node_id = ? o node_id IS NULL a una consulta. Deliberadamente NO es un resolver con caché (a diferencia de StorageResolver/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 en RefreshNames (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 que storage-targets: expone BaseURL, direcciones de red internas). Node.APIKey nunca viaja en JSON (json:"-") y se genera automáticamente al crear el nodo — una clave por nodo, no la INTERNAL_API_KEY global, para poder revocar un nodo comprometido sin apagar los demás.
  • Verificado en producción: ORION_NODE_ID no 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 con StorageTargetID/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 con Active:false y valores cero (mismo patrón que StorageTarget/RecordingPlan), borrado desasigna cámaras. Un fallo real se atrapó en el camino: dos tests de stream_syncer_test.go construían Go2RTCSyncer{} a mano (sin pasar por el constructor) y nodeScope quedaba nil — panic al primer Aplicar(). Corregido en el helper de test, no en producción (el constructor real siempre inicializa nodeScope).

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 que SessionValidator, indexado por clave en vez de por ID) identifica qué Node hizo la llamada por su APIKey propia. 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). LastSeenAt se 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 con RequireRole(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, y authenticated_as_node_id (la identidad con la que se autenticó la llamada — útil para depurar si no coincide con el node_id que este proceso cree ser).
    • POST /internal/node/resync — fuerza una resincronización inmediata con go2rtc (reutiliza Go2RTCSyncer.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 APIKey del nodo al que pregunta — la misma identidad que ese nodo ya usa para subir sus propios eventos.
  • POST /api/nodes expone la clave EN TEXTO PLANO, solo esa vez — un hallazgo real detectado al probar: Node.APIKey lleva json:"-" (nunca sale en List/Get, correcto), pero eso también lo ocultaba en la respuesta de creación, dejando al admin sin forma de copiarla para configurar el .env del nodo remoto. Create devuelve 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_id coincide exacto), resync funciona, 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) y auth_node_test.go (el middleware completo montado sobre gin). Dos fallos reales atrapados en el camino: el mismo bug de GORM con Active:false+default:true que ya salió con StorageTarget/ RecordingPlan/Node (crear un nodo desactivado directo con db.Create lo 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.

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.

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í).

  • 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_RANGE en 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”).