Análisis: Implementación de Redis en Nexus Platform¶
Fecha: 2026-01-30 Estado: Análisis completado Decisión recomendada: ✅ SÍ implementar Redis
Índice¶
- Resumen Ejecutivo
- Estado Actual del Sistema
- Casos de Uso para Redis
- Análisis de Beneficios
- Comparativa: Redis vs Alternativas
- Plan de Implementación
- Riesgos y Mitigaciones
- Conclusión y Recomendación
Resumen Ejecutivo¶
Veredicto: ✅ SÍ IMPLEMENTAR REDIS¶
Redis es altamente recomendado para la plataforma Nexus por las siguientes razones críticas:
| Problema Actual | Impacto | Solución con Redis |
|---|---|---|
| SignalR sin backplane | No escala horizontalmente | Redis backplane |
| Memory leaks en diccionarios estáticos | Acumulación de memoria | Redis como store externo |
| Sin caché distribuido | Cada instancia duplica estado | Redis cache compartido |
| Rate limiting inexistente | Vulnerable a abuso | Redis rate limiter |
| Sesiones WhatsApp en memoria | Se pierden al reiniciar | Redis session store |
Esfuerzo Estimado¶
| Fase | Componente | Esfuerzo |
|---|---|---|
| 1 | SignalR backplane | 2-4 horas |
| 2 | IDistributedCache | 4-8 horas |
| 3 | Session store | 1-2 días |
| 4 | Rate limiting | 4-8 horas |
| Total | 3-5 días |
Estado Actual del Sistema¶
Almacenamiento en Memoria (Problemático)¶
Se identificaron 9 estructuras estáticas que almacenan estado en memoria:
┌─────────────────────────────────────────────────────────────┐
│ ESTADO EN MEMORIA │
├─────────────────────────────────────────────────────────────┤
│ │
│ RemoteAgentHub (5 diccionarios estáticos) │
│ ├── _connectionToAgent: ConnectionId → AgentId │
│ ├── _agentToConnection: AgentId → ConnectionId │
│ ├── _pendingCommands: CommandId → TaskCompletionSource │
│ ├── _commandLocks: CommandId → SemaphoreSlim ⚠️ LEAK │
│ └── _chunkSequences: CommandId → int ⚠️ LEAK │
│ │
│ DevSessionService │
│ └── _sessionLocks: SessionId → SemaphoreSlim ✓ Cleanup │
│ │
│ CopilotService │
│ └── _executionLocks: ExecutionId → SemaphoreSlim ⚠️ LEAK │
│ │
│ ConversationStore │
│ └── _conversations: PhoneNumber → List<Message> ⚠️ NO TTL │
│ │
│ TunnelService │
│ ├── _pendingResults: RequestId → TCS │
│ └── _outputChannels: RequestId → Channel<T> │
│ │
│ DocumentContextService │
│ └── IMemoryCache: doc_content:{projectId}:{path} │
│ TTL: 15 minutos ✓ │
│ │
└─────────────────────────────────────────────────────────────┘
Problemas de Escalabilidad¶
Sin SignalR Backplane¶
Situación Actual (Single Instance):
┌─────────────────────────────────┐
│ Orchestrator.Api :5001 │
│ ┌───────────────────────────┐ │
│ │ SignalR Hub (en memoria) │ │
│ │ └── Groups │ │
│ │ └── Connections │ │
│ └───────────────────────────┘ │
│ ↓ ↓ ↓ │
│ Todos los clientes conectados │
└─────────────────────────────────┘
✅ Funciona correctamente
Con Múltiples Instancias (SIN Redis):
┌──────────────────┐ ┌──────────────────┐
│ Instancia 1 │ │ Instancia 2 │
│ ┌──────────────┐ │ │ ┌──────────────┐ │
│ │ SignalR Hub │ │ │ │ SignalR Hub │ │
│ │ Clientes A,B │ │ │ │ Clientes C,D │ │
│ └──────────────┘ │ │ └──────────────┘ │
└──────────────────┘ └──────────────────┘
✗ ✗
Mensaje a grupo NO llega a C,D
"devsession:123" (están en otra instancia)
❌ ROTO: Mensajes no llegan entre instancias
Memory Leaks Identificados¶
| Componente | Leak | Impacto Estimado |
|---|---|---|
| RemoteAgentHub._commandLocks | SemaphoreSlim nunca disposed | ~8KB por comando |
| RemoteAgentHub._chunkSequences | Entradas nunca limpiadas | ~100B por comando |
| CopilotService._executionLocks | SemaphoreSlim nunca disposed | ~8KB por ejecución |
| ConversationStore._conversations | Sin TTL | ~500B por mensaje |
Proyección: Con 1000 comandos/día → ~8MB/día de leak en RemoteAgentHub.
RabbitMQ: Uso Actual¶
┌─────────────────────────────────────────────────────────────┐
│ COLAS RABBITMQ │
├─────────────────────────────────────────────────────────────┤
│ │
│ events.whatsapp.incoming ← WebhooksController │
│ ↓ │
│ WhatsappMessageWorker │
│ ↓ │
│ events.whatsapp.outgoing → OutgoingMessageConsumer │
│ │
│ events.maintenance.scheduled ← WhatsApp, Scheduler, Alerts │
│ ↓ │
│ MaintenanceWorker │
│ ↓ │
│ events.maintenance.completed │
│ │
│ events.alerts.external ← Sentry, GitHub webhooks │
│ ↓ │
│ ExternalAlertWorker │
│ │
└─────────────────────────────────────────────────────────────┘
Problemas detectados:
⚠️ Conexiones no reutilizadas en WebhooksController
⚠️ Sin Dead Letter Queues (mensajes se pierden)
⚠️ Sin Publisher Confirms
⚠️ Sin reintentos a nivel de cola
Casos de Uso para Redis¶
1. SignalR Backplane (CRÍTICO)¶
Problema: SignalR usa backplane en memoria, no funciona con múltiples instancias.
Solución:
// Program.cs
builder.Services.AddSignalR()
.AddStackExchangeRedis(options => {
options.Configuration = builder.Configuration["Redis:ConnectionString"];
});
Beneficios: - ✅ Mensajes llegan a todos los clientes en todas las instancias - ✅ Grupos compartidos entre instancias - ✅ Failover automático de conexiones
Esfuerzo: 2-4 horas
2. Distributed Cache (IDistributedCache)¶
Problema: DocumentContextService usa IMemoryCache, cada instancia tiene su propia caché.
Solución:
// Program.cs
builder.Services.AddStackExchangeRedisCache(options =>
{
options.Configuration = builder.Configuration["Redis:ConnectionString"];
options.InstanceName = "nexus:";
});
// DocumentContextService.cs
public class DocumentContextService
{
private readonly IDistributedCache _cache;
public async Task<string?> LoadDocumentContentAsync(Guid projectId, string path)
{
var cacheKey = $"doc:{projectId}:{path}";
var cached = await _cache.GetStringAsync(cacheKey);
if (cached != null) return cached;
var content = await LoadFromDiskAsync(path);
await _cache.SetStringAsync(cacheKey, content, new DistributedCacheEntryOptions
{
AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(15)
});
return content;
}
}
Datos a cachear:
| Dato | TTL | Tamaño Est. |
|---|---|---|
| Contenido de documentos | 15 min | 1-100 KB |
| AIProviderConfig | 5 min | 1 KB |
| Agentes activos | 1 min | 10 KB |
| Proyectos del usuario | 2 min | 5 KB |
Esfuerzo: 4-8 horas
3. Session Store (Reemplazar ConcurrentDictionary)¶
Problema: Estado de sesiones en diccionarios estáticos se pierde al reiniciar.
Solución:
// ConversationStore → Redis
public class RedisConversationStore : IConversationStore
{
private readonly IConnectionMultiplexer _redis;
private readonly TimeSpan _ttl = TimeSpan.FromHours(24);
public async Task AddMessageAsync(string phone, ConversationMessage message)
{
var db = _redis.GetDatabase();
var key = $"conversation:{NormalizePhone(phone)}";
var json = JsonSerializer.Serialize(message);
await db.ListRightPushAsync(key, json);
await db.ListTrimAsync(key, -20, -1); // Mantener últimos 20
await db.KeyExpireAsync(key, _ttl);
}
}
// RemoteAgentHub → Redis
// Mapeo de conexiones
await db.HashSetAsync("agent:connections", agentId, connectionId);
await db.HashSetAsync("connection:agents", connectionId, agentId);
Beneficios: - ✅ Persistencia entre reinicios - ✅ Compartido entre instancias - ✅ TTL automático (evita memory leaks)
Esfuerzo: 1-2 días
4. Rate Limiting¶
Problema: No hay rate limiting, vulnerable a abuso.
Solución:
// Program.cs
builder.Services.AddRateLimiter(options =>
{
options.AddRedisFixedWindowLimiter("webhook", opts =>
{
opts.ConnectionMultiplexerFactory = () =>
ConnectionMultiplexer.Connect(config["Redis:ConnectionString"]);
opts.PermitLimit = 100;
opts.Window = TimeSpan.FromMinutes(1);
});
});
// Controller
[EnableRateLimiting("webhook")]
[HttpPost("/webhooks/sentry")]
public async Task<IActionResult> HandleSentry(...)
Endpoints a proteger:
| Endpoint | Límite Sugerido |
|---|---|
| /webhooks/sentry | 100/min |
| /webhooks/ultramsg | 200/min |
| /webhooks/clickup | 100/min |
| /api/dev-sessions/*/messages | 30/min |
| /api/executions | 20/min |
Esfuerzo: 4-8 horas
5. Distributed Locks (Reemplazar SemaphoreSlim)¶
Problema: SemaphoreSlim estáticos no funcionan entre instancias.
Solución:
// Usando RedLock.net
public class CopilotService
{
private readonly IDistributedLockFactory _lockFactory;
public async Task ExecuteNextStepAsync(Guid executionId, CancellationToken ct)
{
var lockKey = $"copilot:lock:{executionId}";
await using var redLock = await _lockFactory.CreateLockAsync(
lockKey,
expiryTime: TimeSpan.FromMinutes(5),
waitTime: TimeSpan.FromSeconds(10),
retryTime: TimeSpan.FromMilliseconds(500));
if (!redLock.IsAcquired)
{
_logger.LogWarning("Could not acquire lock for execution {Id}", executionId);
return;
}
// Proceso con lock...
}
}
Beneficios: - ✅ Funciona entre instancias - ✅ Auto-expira (sin memory leaks) - ✅ Retry automático
Esfuerzo: 4-8 horas
6. Pub/Sub para Eventos Internos¶
Problema: Algunos eventos no necesitan persistencia de RabbitMQ.
Ejemplo: Notificaciones de UI en tiempo real
// Publisher
await _redis.GetSubscriber().PublishAsync(
"nexus:ui:execution:update",
JsonSerializer.Serialize(new { ExecutionId = id, Status = "running" }));
// Subscriber (en cada instancia)
_redis.GetSubscriber().Subscribe("nexus:ui:*", (channel, message) =>
{
// Broadcast via SignalR local
_hubContext.Clients.Group("dashboard").SendAsync("Update", message);
});
Cuándo usar Redis Pub/Sub vs RabbitMQ:
| Escenario | Redis Pub/Sub | RabbitMQ |
|---|---|---|
| Notificaciones UI efímeras | ✅ | ❌ |
| Eventos que deben persistir | ❌ | ✅ |
| Broadcast a todos los servidores | ✅ | ✅ |
| Procesamiento con reintentos | ❌ | ✅ |
| Rate < 10K/s | ✅ | ✅ |
| Rate > 10K/s | ✅ | ⚠️ |
Análisis de Beneficios¶
Matriz de Impacto¶
| Beneficio | Sin Redis | Con Redis | Impacto |
|---|---|---|---|
| Escalabilidad horizontal | ❌ Imposible | ✅ Ilimitada | CRÍTICO |
| Memory leaks | ⚠️ Acumulan | ✅ TTL automático | ALTO |
| Failover | ❌ Pérdida de estado | ✅ Estado preservado | ALTO |
| Rate limiting | ❌ Inexistente | ✅ Distribuido | MEDIO |
| Latencia de caché | ⚠️ 10-50ms (BD) | ✅ <1ms | MEDIO |
| Complejidad ops | ✅ Simple | ⚠️ +1 servicio | BAJO |
ROI Estimado¶
Situación actual:
- 1 instancia de API (no escala)
- Memory leaks requieren restart cada ~1 semana
- Sin HA (downtime en deploys)
Con Redis:
- N instancias (escala horizontal)
- Sin memory leaks (TTL automático)
- Zero-downtime deploys (estado compartido)
- Rate limiting protege contra abuso
Ahorro estimado:
- 2-4 horas/semana en mantenimiento (restarts, debugging leaks)
- Capacidad de manejar 10x más carga sin cambios de código
- Reducción de riesgo de downtime
Comparativa: Redis vs Alternativas¶
Opción 1: Redis (RECOMENDADA)¶
Pros: - Madurez y estabilidad probada - Excelente integración con .NET (StackExchange.Redis) - SignalR backplane oficial - Latencia sub-milisegundo - Rico en estructuras de datos (strings, hashes, lists, sets, sorted sets) - Pub/Sub nativo - Clustering y replicación
Contras: - +1 servicio para operar - Memoria limitada (todo en RAM por defecto) - Persistencia requiere configuración (RDB/AOF)
Costo: Gratis (self-hosted) o ~$15-50/mes (managed)
Opción 2: Memcached¶
Pros: - Más simple que Redis - Ligeramente más rápido para cache puro - Multi-threaded
Contras: - ❌ Sin SignalR backplane oficial - ❌ Sin Pub/Sub - ❌ Sin estructuras de datos complejas - ❌ Sin persistencia
Veredicto: No adecuado para este caso de uso.
Opción 3: SQL Server (distributed cache)¶
Pros: - Ya existe PostgreSQL en el stack - Sin servicios adicionales
Contras: - ❌ Latencia 10-100x mayor que Redis - ❌ Sin SignalR backplane - ❌ Sin Pub/Sub eficiente - ❌ No escala tan bien
Veredicto: Solo para casos simples de cache.
Opción 4: NCache / Hazelcast¶
Pros: - Soluciones enterprise - Más features que Redis
Contras: - ❌ Más complejos de operar - ❌ Licencias costosas - ❌ Overkill para este tamaño
Veredicto: No necesario actualmente.
Decisión Final¶
┌─────────────────────────────────────────────────────────────┐
│ DECISIÓN: REDIS │
├─────────────────────────────────────────────────────────────┤
│ │
│ ✅ SignalR backplane oficial │
│ ✅ IDistributedCache integración nativa │
│ ✅ Rate limiting con AspNetCoreRateLimit.Redis │
│ ✅ Distributed locks con RedLock.net │
│ ✅ Pub/Sub para eventos efímeros │
│ ✅ Comunidad activa y documentación abundante │
│ ✅ Docker image oficial liviana │
│ │
│ Alternativas evaluadas: │
│ ❌ Memcached: Sin SignalR, sin Pub/Sub │
│ ❌ PostgreSQL: Latencia alta, sin Pub/Sub eficiente │
│ ❌ NCache/Hazelcast: Overkill, costoso │
│ │
└─────────────────────────────────────────────────────────────┘
Plan de Implementación¶
Fase 0: Infraestructura (Día 1)¶
# docker-compose.yml - Agregar Redis
services:
redis:
image: redis:7-alpine
container_name: nexus-redis
ports:
- "6379:6379"
volumes:
- redis_data:/data
command: redis-server --appendonly yes
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 10s
timeout: 5s
retries: 5
volumes:
redis_data:
// appsettings.json
{
"Redis": {
"ConnectionString": "localhost:6379,abortConnect=false",
"InstanceName": "nexus:"
}
}
Fase 1: SignalR Backplane (Día 1-2)¶
// Orchestrator.Api/Program.cs
// Agregar paquete: Microsoft.AspNetCore.SignalR.StackExchangeRedis
builder.Services.AddSignalR()
.AddStackExchangeRedis(builder.Configuration["Redis:ConnectionString"], options =>
{
options.Configuration.ChannelPrefix = RedisChannel.Literal("nexus-signalr");
});
Validación: 1. Levantar 2 instancias de la API en puertos diferentes 2. Conectar cliente a instancia 1 3. Enviar mensaje desde instancia 2 4. Verificar que cliente lo recibe
Fase 2: IDistributedCache (Día 2-3)¶
// Orchestrator.Api/Program.cs
// Agregar paquete: Microsoft.Extensions.Caching.StackExchangeRedis
builder.Services.AddStackExchangeRedisCache(options =>
{
options.Configuration = builder.Configuration["Redis:ConnectionString"];
options.InstanceName = builder.Configuration["Redis:InstanceName"];
});
// Migrar DocumentContextService
public class DocumentContextService
{
private readonly IDistributedCache _cache;
// Reemplazar IMemoryCache con IDistributedCache
// Misma interfaz, diferente backend
}
Fase 3: Session Store (Día 3-4)¶
// Nuevo: RedisConversationStore.cs
public class RedisConversationStore : IConversationStore
{
private readonly IConnectionMultiplexer _redis;
private readonly ILogger<RedisConversationStore> _logger;
private readonly TimeSpan _conversationTtl = TimeSpan.FromHours(24);
private readonly int _maxMessages = 20;
public async Task<List<ConversationMessage>> GetHistoryAsync(string phone)
{
var db = _redis.GetDatabase();
var key = $"conversation:{NormalizePhone(phone)}";
var messages = await db.ListRangeAsync(key);
return messages
.Select(m => JsonSerializer.Deserialize<ConversationMessage>(m!))
.Where(m => m != null)
.ToList()!;
}
public async Task AddMessageAsync(string phone, ConversationMessage message)
{
var db = _redis.GetDatabase();
var key = $"conversation:{NormalizePhone(phone)}";
var json = JsonSerializer.Serialize(message);
await db.ListRightPushAsync(key, json);
await db.ListTrimAsync(key, -_maxMessages, -1);
await db.KeyExpireAsync(key, _conversationTtl);
}
}
// DI Registration
builder.Services.AddSingleton<IConnectionMultiplexer>(sp =>
ConnectionMultiplexer.Connect(builder.Configuration["Redis:ConnectionString"]));
builder.Services.AddSingleton<IConversationStore, RedisConversationStore>();
Fase 4: Rate Limiting (Día 4-5)¶
// Agregar paquete: AspNetCoreRateLimit.Redis
builder.Services.AddRedisRateLimiting();
builder.Services.Configure<IpRateLimitOptions>(options =>
{
options.EnableEndpointRateLimiting = true;
options.GeneralRules = new List<RateLimitRule>
{
new RateLimitRule
{
Endpoint = "*:/webhooks/*",
Period = "1m",
Limit = 100
},
new RateLimitRule
{
Endpoint = "POST:/api/dev-sessions/*/messages",
Period = "1m",
Limit = 30
}
};
});
app.UseIpRateLimiting();
Riesgos y Mitigaciones¶
Riesgo 1: Redis como Single Point of Failure¶
Mitigación: - Usar Redis Sentinel para HA - O Redis Cluster para horizontal scaling - Fallback a IMemoryCache si Redis no disponible
builder.Services.AddStackExchangeRedisCache(options =>
{
options.Configuration = "sentinel:26379,serviceName=mymaster";
});
Riesgo 2: Pérdida de Datos en Crash¶
Mitigación: - Habilitar AOF (Append Only File):
- Datos críticos siguen en PostgreSQLRiesgo 3: Complejidad Operativa¶
Mitigación: - Usar managed Redis (Azure Cache, AWS ElastiCache, Railway) - O Redis en Docker con volúmenes persistentes - Monitoreo con Redis Insight o Prometheus exporter
Riesgo 4: Latencia de Red¶
Mitigación: - Redis en misma red/zona que aplicación - Connection pooling (StackExchange.Redis lo hace automáticamente) - Latencia típica: <1ms en misma red
Conclusión y Recomendación¶
Recomendación Final: ✅ IMPLEMENTAR REDIS¶
Redis resuelve problemas críticos actuales de la plataforma:
- SignalR no escala → Redis backplane lo soluciona
- Memory leaks → TTL automático los previene
- Estado perdido en restart → Redis persiste el estado
- Sin rate limiting → Redis rate limiter distribuido
- Caché por instancia → IDistributedCache compartido
Prioridades de Implementación¶
CRÍTICO (Semana 1):
├── 1. Docker + Redis 7 Alpine
├── 2. SignalR backplane
└── 3. IDistributedCache para DocumentContextService
IMPORTANTE (Semana 2):
├── 4. RedisConversationStore
├── 5. Rate limiting en webhooks
└── 6. Distributed locks para CopilotService
NICE-TO-HAVE (Futuro):
├── 7. Redis Pub/Sub para eventos UI
├── 8. Métricas con Redis exporter
└── 9. Redis Sentinel/Cluster para HA
Siguiente Paso¶
Agregar Redis al docker-compose.yml y comenzar con el SignalR backplane como primer paso. Es el cambio de mayor impacto con menor esfuerzo.
Apéndice: Paquetes NuGet Necesarios¶
<!-- Orchestrator.Api.csproj -->
<PackageReference Include="Microsoft.AspNetCore.SignalR.StackExchangeRedis" Version="9.0.0" />
<PackageReference Include="Microsoft.Extensions.Caching.StackExchangeRedis" Version="9.0.0" />
<PackageReference Include="AspNetCoreRateLimit.Redis" Version="5.0.0" />
<!-- Shared.Admin.csproj -->
<PackageReference Include="StackExchange.Redis" Version="2.8.0" />
<PackageReference Include="RedLock.net" Version="2.5.0" />
Documento generado como parte de la auditoría de arquitectura de Nexus Platform