Almacenamiento
Estado: COMPLETO — backend Go, consola web y workers de Python (2026-08-21). Requerimiento levantado en la reunión con Carlos del 2026-08-18.
El cierre del límite de los workers está al final.
Qué se pidió, en palabras del cliente
Sección titulada «Qué se pidió, en palabras del cliente»«grábeme 50 cámaras en X sitio y otras 50 en otro storage»
«cuando usted jale las cámaras desde un cliente, pueda jalar el tráfico de las cámaras que usted quiera, y el cliente no va a saber que unas las jala de un servidor y las otras del otro»
Y el porqué comercial, que es lo que fija la prioridad:
«es la única solución descentralizada [IndigoVision], yo creo que por eso lo compraron»
Es decir: no es una mejora de capacidad, es paridad competitiva. Es la única función por la que el cliente valora a IndigoVision por encima del resto, y hoy Orion no la tiene.
El problema de fondo
Sección titulada «El problema de fondo»Carlos lo planteó con números reales: 168-172 cámaras en Girona grabando 6
meses. Con discos de 24 TB y chasis de 12-16 bahías no cabe en una sola
máquina. No es un problema de CPU (la grabación es -c copy, casi gratis),
es de capacidad bruta de disco.
Dos funciones distintas — no confundirlas
Sección titulada «Dos funciones distintas — no confundirlas»En la reunión se hablaron ambas y conviene separarlas, porque tienen coste y valor muy distintos:
A. Tiering por antigüedad (lo “fácil”)
Sección titulada «A. Tiering por antigüedad (lo “fácil”)»Cuando el almacenamiento principal se llena, mover lo más antiguo a un almacenamiento secundario. Extiende la retención (de 180 a 200+ días) sin tocar la arquitectura: sigue habiendo un solo servidor que graba.
Carlos matizó él mismo que esto no es lo que más le importa («lo que no me gusta es que tengamos que moverlo»).
B. Reparto por cámara (lo que de verdad pidió)
Sección titulada «B. Reparto por cámara (lo que de verdad pidió)»El operador elige en qué almacenamiento graba cada cámara. Y el cliente, al pedir reproducción, obtiene el video venga de donde venga, sin saberlo.
Esto sí cambia la arquitectura y es lo que da la paridad con Indigo.
Diseño propuesto (B, con A como caso particular)
Sección titulada «Diseño propuesto (B, con A como caso particular)»1. Nuevo modelo StorageTarget
Sección titulada «1. Nuevo modelo StorageTarget»type StorageTarget struct { ID uint Name string // "NVR principal", "Storage secundario" // Ruta local o UNC: C:\orion-data, \\10.3.96.40\grabaciones, // o un punto de montaje iSCSI (Carlos mencionó iSCSI a 10 Gb, que rinde // casi como disco local — es la vía más simple y la que él sugirió). Path string Priority int // orden para el tiering (A) Active bool // Métricas del último sondeo, para la página de Salud del sistema. TotalBytes, FreeBytes int64 LastProbedAt *time.Time}2. Camera.StorageTargetID (nullable)
Sección titulada «2. Camera.StorageTargetID (nullable)»Nulo = el almacenamiento por defecto (compatibilidad hacia atrás: todo lo ya grabado sigue donde está). El operador lo asigna por cámara, o en bloque desde Grupos de cámaras.
3. Resolución de ruta centralizada — LA PIEZA CRÍTICA
Sección titulada «3. Resolución de ruta centralizada — LA PIEZA CRÍTICA»Hoy hay un único dataDir y al menos cinco sitios lo asumen:
continuous_recorder.go (graba), recording_service.go (timeline y
reproducción), export_service.go (evidencia), el renderer de sinopsis
(Python) y la retención por disco.
Si se añade multi-storage sin centralizar, cada uno de esos sitios es un bug esperando: una cámara movida de almacenamiento haría que la reproducción no encuentre el video, la exportación salga vacía y la sinopsis se renderice sin recortes — exactamente los fallos que ya costó cerrar esta semana.
Por eso: una sola función RutaDeCamara(cameraID) string, y que todos la
usen. Ningún filepath.Join(dataDir, "recordings", ...) suelto.
4. Retención, por almacenamiento
Sección titulada «4. Retención, por almacenamiento»pruneByDiskUsage mide hoy la partición de dataDir. Con varios destinos hay
que medir y podar cada uno por separado — si no, un storage lleno y otro
vacío se promedian y no se poda el que toca. Se conserva la prioridad por
niveles ya implementada (efímero antes que grabaciones).
5. Transparencia para el cliente (el requisito explícito)
Sección titulada «5. Transparencia para el cliente (el requisito explícito)»No hace falta nada nuevo en el API: GET /api/recordings/:id/play y el
timeline ya reciben cameraID. Basta con que resuelvan la ruta por la función
central. El cliente nunca sabe de qué almacenamiento vino — que es
literalmente lo que pidió Carlos.
Orden de implementación
Sección titulada «Orden de implementación»StorageTarget+Camera.StorageTargetID+RutaDeCamara()centralizada, migrando los cinco sitios que hoy asumendataDir. Sin esto, nada más es seguro.- Grabación en el destino asignado.
- Reproducción/exportación/sinopsis resolviendo por la función central.
- Retención por almacenamiento.
- UI: asignar almacenamiento por cámara y por grupo; capacidad y espacio libre en Salud del sistema.
- (Opcional, después) Tiering por antigüedad — el caso A.
Riesgo principal
Sección titulada «Riesgo principal»El movimiento de datos existente. Si una cámara cambia de almacenamiento, sus grabaciones anteriores siguen en el anterior. Dos opciones: buscar en todos los destinos al reproducir (más lento pero sin pérdida), o migrar los ficheros al reasignar (rápido después, pero un movimiento largo y arriesgado). La primera es la correcta para evidencia: nunca perder video por un cambio de configuración.
Nota sobre iSCSI
Sección titulada «Nota sobre iSCSI»Carlos sugirió iSCSI sobre 10 Gb como alternativa: el sistema operativo lo ve
como un disco local, y Path apuntaría a esa letra de unidad. Con eso, el
caso A (ampliar capacidad) se resuelve sin tocar Orion en absoluto — solo hay
que montar el disco. Conviene decirlo en la reunión: parte del problema es de
infraestructura, no de software.
Lo que iSCSI no resuelve es B (elegir qué cámara graba dónde), que es lo que da la paridad con Indigo.
Cómo quedó implementado (2026-08-21)
Sección titulada «Cómo quedó implementado (2026-08-21)»- Modelo:
models.StorageTarget(nombre único, ruta, prioridad, activo, métricas de disco del último sondeo) +Camera.StorageTargetID *uint. Ambos porAutoMigrateenmain.go. Nulo = destino por defecto (DATA_DIR). - Resolución central:
service.StorageResolver(storage_resolver.go), con caché de 30 s sobre la tabla de destinos y la asignación por cámara. Una sola instancia se comparte desdemain.goentre recorder, reproducción, exportación, salud y el Review de Indigo — con instancias separadas, cada una cachearía por su cuenta y podrían discrepar durante el TTL.RutaDeCamara(id)→ dónde graba ahora.RutasDeLectura(id)→ todos los destinos donde puede haber video suyo.RaicesDeDatos()→ una raíz por destino, la por defecto primero.
- Grabación:
continuous_recorder.goresuelve la carpeta por cámara, yreconcile()detecta que una cámara cambió de destino y relanza su ffmpeg (el patrón-strftimese fija al arrancar el proceso y no admite cambio en caliente). - Reproducción/exportación:
recording_service.gouneTimelineyDaysWithRecordingssobre todas las rutas de lectura, así que una cámara reasignada sigue reproduciendo lo que grabó en el destino anterior — la opción «buscar en todos los destinos» del apartado Riesgo principal, que es la correcta para evidencia. Exportaciones va porRecordingService, así que hereda esto sin cambios. - Retención por destino:
pruneByDiskUsagerecorreRaicesDeDatos()y llama apodarRaizcon la medición del volumen de esa raíz. Solo la raíz por defecto conserva el nivel de efímeros (exports/,synopsis/,clips/,faces/); un destino secundario solo guarda grabaciones. La poda por edad también recorre todas las raíces. - Salud del sistema:
SystemHealth.Diskstrae un medidor por destino.Diskse conserva (el por defecto) para no romper a la Station de C#. - UI: página Almacenamiento (
StorageTargets.tsx, admin) para el CRUD, y selector de destino en la ficha de cámara. Todas las rutas/api/storage-targetsson admin-only incluida la lectura: la lista expone rutas UNC y nombres de servidores internos, y el diseño pide justamente que el operador no sepa de qué almacenamiento sale el video. - Grabaciones existentes: siguen en
DATA_DIRy las cámaras arrancan constorage_target_id = NULL, así que un despliegue con datos no cambia de comportamiento hasta que un administrador asigne destinos a mano.
Límite de los workers de Python: CERRADO (2026-08-21)
Sección titulada «Límite de los workers de Python: CERRADO (2026-08-21)»dense_tracker.py, face_recorded.py y synopsis_renderer.py recorrían
siempre DATA_DIR/recordings sin conocer los destinos. Una cámara asignada a
un destino secundario se grababa y reproducía bien, pero no alimentaba
Video Synopsis ni el facial sobre grabaciones.
Solución elegida: la B del análisis original (consultar al API la raíz de
cada cámara), NO la A (migrar dense_tracks.segment_path para que lleve el
destino). Razón: segment_path queda exactamente igual
("<camId>/<día>/<archivo>.mp4", relativo a la raíz de su propia cámara) —
cero migración de datos existentes, cero cambio de contrato entre Go y
Python.
workers/synopsis/orion_storage_resolver.py: equivalente Python deStorageResolver(mismo TTL de caché de 30s, mismo criterio de “destino borrado/desactivado/sin ruta cae al por defecto, nunca deja de analizar en silencio”). ConsultaGET /api/cameras+GET /api/storage-targets./api/storage-targetses admin-only incluida la lectura (expone rutas UNC y nombres de servidores internos), pero los workers ya autentican conINTERNAL_API_KEY, que el middleware trata comorole=worker, yRequireRoleya deja pasar a los workers en cualquier ruta admin (rbac.go, “workers bypass”). No hizo falta tocar una línea del backend Go para esto.- Los tres workers agrupan sus cámaras por raíz resuelta y descubren segmentos por grupo (no por cámara suelta, para no repetir el barrido de filesystem cuando varias comparten destino).
- Los artefactos propios de la IA (no grabaciones) siguen SIEMPRE en la
raíz por defecto, a propósito:
backgrounds/<camId>.webp(lo sirveBackgroundHandler.goleyendo siempre deDATA_DIR, sin noción de destino) y el MP4 de salida de una sinopsis (synopsis/). Escribirlos en otro sitio los dejaría invisibles para el backend — no es un descuido, es la frontera correcta entre “grabación” (multi-destino) y “artefacto de IA” (siempre enDATA_DIR). - 15 tests nuevos (10 de
OrionStorageResolver, 3 desynopsis_renderer, 2 deface_recorded), incluido un bug real encontrado por el propio test:raices_de_datos()comparaba el string crudo de la ruta del JSON contra la representación dePathsin normalizar y no deduplicaba correctamente en Windows. face_recorded.pyno corre en este despliegue (module_vms=false), así que el cambio queda listo para cuando el cliente active grabación propia, pero no se pudo verificar en ejecución real — solo con tests.dense_trackerysynopsis_renderersí se reiniciaron y verificaron en producción.