Ejecuta cualquier número de réplicas del servidor tenant detrás de un balanceador de carga sin restricciones de "debe estar en el mismo pod". Cada componente del servicio está diseñado para ser sin estado — el estado compartido reside en Postgres y Redis.
Apunta TENANT_BASE_URL a tu balanceador de carga. Todas las réplicas comparten el mismo Postgres y Redis. No se requieren sesiones persistentes para la voz. El worker del agente de personalización puede acceder a cualquier réplica.
El servidor tenant no mantiene ningún estado en memoria que deba sobrevivir a una redirección a otra réplica. Las claves de firma de JWT residen en Postgres (en el espacio de nombres livekit_server) y en tu passwords.yaml de despliegue. Cada réplica lee desde la misma fuente, por lo que cualquier pod puede emitir un JWT de LiveKit para cualquier usuario.
El agente de personalización (worker de voz/avatar) llama de vuelta a las rutas /internal/customization-agent/*. Estas rutas buscan al usuario de la organización en Postgres, validan el tenant y continúan — no se consulta ningún estado en memoria por réplica. Apunta TENANT_BASE_URL al balanceador de carga y cualquier réplica puede responder.
En cualquier lugar donde las réplicas podrían interferir entre sí bajo balanceo de carga, YOffice coordina a través de Redis. Cada caso de uso se asigna a un primitivo distribuido que mantiene todas las réplicas sincronizadas:
| Caso de coordinación | Por qué importa entre réplicas | Cómo lo gestiona YOffice |
|---|---|---|
| Barridos periódicos en server.dart | Sin coordinación, cada réplica ejecutaría cada barrido → N× el trabajo y disparadores duplicados | LeaderOnlyTimer.periodic(taskName: …) — un líder elegido por Redis ejecuta el barrido — un líder elegido por Redis ejecuta el barrido |
| Limitación de solicitudes por organización | Un contador en memoria permitiría que cada réplica aplicara el límite en su propia porción, de modo que N réplicas permitirían N× el límite | DistributedRateLimiter — INCRBY atómico en Redis por tenant + segmento de tiempo |
| Límite de solicitudes por IP para formularios públicos | Las solicitudes podrían eludir el límite al llegar a réplicas distintas | DistributedRateLimiter con clave IP + segmento |
| Concurrencia de embeddings | Un semáforo por réplica se multiplicaría por N réplicas y superaría las cuotas del proveedor | DistributedSemaphore con capacidad global del clúster |
| Invalidación de caché de directivas | Una invalidación solo local dejaría a otras réplicas sirviendo directivas obsoletas hasta 5 minutos | Contador de generación en Redis — la invalidación de cualquier réplica lo incrementa; todas las réplicas ven el cambio |
| Distribución de pub/sub en tiempo real | postMessage sin solo llega a los suscriptores de la réplica que publicóglobal: true reaches only the publishing replica's subscribers | Los 137 puntos de llamada usan ClusterPubsub.broadcast (global forzado) |
Todos los primitivos están en command_center_tenant_server/lib/src/scaling/horizontal_scaling.dart. Solo requieren un clúster de Redis compartido — no se necesita ningún servicio de coordinación adicional.
Bloqueo exclusivo Redis SET NX PX. Usado para coordinar la exclusión mutua entre réplicas. Expira automáticamente tras un TTL configurable para que un propietario caído no bloquee el clúster indefinidamente. Los titulares de larga duración deben llamar a renew() dentro del período TTL.
Un reemplazo directo de Timer.periodic que usa un contrato de líder Redis por tarea. Solo la réplica líder elegida ejecuta el cuerpo; las demás réplicas permanecen inactivas. Cada tarea elige a su propio líder de forma independiente, distribuyendo la carga de forma natural entre los pods.
Un semáforo contador que limita la concurrencia global del clúster con claves de ranura en Redis. Reemplaza los semáforos por aislamiento que solo aplican límites localmente — importante para los embeddings masivos de carpetas de conocimiento y operaciones similares sensibles a cuotas de proveedor.
INCRBY atómico en Redis con clave por tenant + segmento de tiempo. Aplica los límites de solicitudes por organización en todas las réplicas simultáneamente. Las configuraciones por organización se escriben desde la pantalla de administración de límites y se envían a Redis en ~30 segundos.
Un envoltorio ligero sobre session.messages de Serverpod que pasa global: true para que los eventos pub/sub lleguen a los suscriptores de todas las réplicas, no solo de la que publicó. Todos los eventos de chat en tiempo real, presencia y flujos de trabajo usan este envoltorio.
Una configuración de producción típica tiene este aspecto:
Clients (Flutter / browser)
│ HTTPS / WSS
Load balancer (nginx / Cloudflare / ALB)
│ │
tenant-server pod tenant-server pod … × N
│ │
├── Postgres (shared)
├── Redis (shared)
├── LiveKit Cloud
└── MinIO / Supabase Storage
customization-agent worker(s)
└── HTTP callbacks → TENANT_BASE_URL/internal/…
(any pod can answer)Establece TENANT_BASE_URL con el nombre de host de tu balanceador de carga (p. ej. https://tenant.tu-dominio). Cada callback del worker del agente de personalización — /internal/customization-agent/* — incluye tenantId, organizationUserId y una cabecera secreta. Cualquier réplica puede responder; no se requiere ningún estado en memoria de la réplica que emitió el JWT.
Todas las réplicas deben leer desde la misma instancia de Postgres y el mismo clúster de Redis. Los primitivos distribuidos (bloqueos, elecciones de líder, semáforos, límites de solicitudes, pub/sub) solo funcionan correctamente cuando todos los pods comparten un único espacio de claves de Redis.
Las sesiones persistentes no son necesarias para la voz — LiveKit Cloud conecta a los clientes directamente con su propio plano de medios tras la emisión del JWT. Las sesiones persistentes SÍ son útiles para el WebSocket de Serverpod, para que los clientes de chat no se reconecten repetidamente al reiniciar un pod. Usa ip_hash en nginx o una política de afinidad de sesión en un balanceador de carga gestionado.
Cada pod deriva una identidad estable de CC_INSTANCE_ID → HOSTNAME → nombre de host de la plataforma → fallback aleatorio. Establecer CC_INSTANCE_ID explícitamente en tu manifiesto de despliegue te da nombres de pod coherentes en los registros, tokens de propietario de bloqueos distribuidos y la ruta de depuración /internal/instance-info.
LiveKit Cloud conecta a los clientes directamente con su propio plano de medios WebRTC después de que el servidor tenant emite un JWT. El servidor tenant no está en la ruta de medios tras la emisión del token, por lo que la afinidad de sesión no afecta a la calidad de voz.
El worker del agente de personalización se registra en LiveKit Cloud usando el identificador agent_name=customization. Envía callbacks a TENANT_BASE_URL — tu balanceador de carga — y cualquier réplica puede gestionarlos. El propio worker también puede ejecutarse como múltiples instancias; cada instancia se registra en LiveKit de forma independiente.
Los administradores de la organización pueden ajustar el límite de solicitudes por organización en Ajustes → Límites de solicitudes. El nuevo valor se escribe en Postgres y se envía a Redis en ~30 segundos, por lo que todas las réplicas lo recogen sin necesidad de reinicio. El valor por defecto es 240 solicitudes por minuto.Settings → Rate Limits
Los primitivos distribuidos (bloqueos, elecciones de líder, límites de solicitudes, pub/sub) requieren Redis. Si Redis no está disponible, el servidor vuelve al comportamiento en proceso, que es seguro para despliegues de una sola réplica pero mostrará los errores descritos anteriormente bajo balanceo de carga.