From 7108907e0e7e3dd5c605770df6b483a383469cd7 Mon Sep 17 00:00:00 2001 From: cryptooda Date: Wed, 7 Jan 2026 16:59:10 +0700 Subject: [PATCH] Add Redis support for SignalR backplane and caching - Introduced Redis configuration in appsettings.json to enable SignalR backplane functionality. - Updated Program.cs to conditionally configure SignalR with Redis if a connection string is provided. - Added Redis connection service registration in ApiBootstrap for distributed scenarios. - Included necessary package references for StackExchange.Redis and Microsoft.Extensions.Caching.StackExchangeRedis in project files. - Implemented password masking for Redis connection strings to enhance security. --- REDIS_SIGNALR_DEPLOYMENT.md | 243 +++++++++++++ src/Managing.Api/Managing.Api.csproj | 1 + src/Managing.Api/Program.cs | 27 +- src/Managing.Api/appsettings.json | 3 + .../Managing.Application.Abstractions.csproj | 1 + .../Services/IRedisConnectionService.cs | 35 ++ src/Managing.Bootstrap/ApiBootstrap.cs | 47 +++ .../Managing.Infrastructure.Storage.csproj | 2 + .../README-REDIS.md | 329 ++++++++++++++++++ .../RedisConnectionService.cs | 160 +++++++++ 10 files changed, 847 insertions(+), 1 deletion(-) create mode 100644 REDIS_SIGNALR_DEPLOYMENT.md create mode 100644 src/Managing.Application.Abstractions/Services/IRedisConnectionService.cs create mode 100644 src/Managing.Infrastructure.Storage/README-REDIS.md create mode 100644 src/Managing.Infrastructure.Storage/RedisConnectionService.cs diff --git a/REDIS_SIGNALR_DEPLOYMENT.md b/REDIS_SIGNALR_DEPLOYMENT.md new file mode 100644 index 00000000..8af49990 --- /dev/null +++ b/REDIS_SIGNALR_DEPLOYMENT.md @@ -0,0 +1,243 @@ +# Redis + SignalR Multi-Instance Deployment Guide + +## Summary + +The Managing API now supports **multiple instances** with **SignalR** (for LlmHub, BotHub, BacktestHub) using a **Redis backplane**. + +This solves the "No Connection with that ID" error that occurs when: +- `/llmhub/negotiate` hits instance A +- WebSocket connection hits instance B (which doesn't know about the connection ID) + +## What Was Added + +### 1. Infrastructure Layer - Generic Redis Service + +**Files Created:** +- `src/Managing.Application.Abstractions/Services/IRedisConnectionService.cs` - Interface +- `src/Managing.Infrastructure.Storage/RedisConnectionService.cs` - Implementation +- `src/Managing.Infrastructure.Storage/README-REDIS.md` - Documentation + +**Purpose:** Generic Redis connectivity that can be used for SignalR, caching, or any Redis needs. + +### 2. SignalR Redis Backplane + +**Files Modified:** +- `src/Managing.Api/Program.cs` - Auto-configures SignalR with Redis when available +- `src/Managing.Bootstrap/ApiBootstrap.cs` - Registers Redis service + +**How It Works:** +- Checks if Redis is configured +- If yes: Adds Redis backplane to SignalR +- If no: Runs in single-instance mode (graceful degradation) + +### 3. Configuration + +**Files Modified:** +- `src/Managing.Api/appsettings.json` - Default config (empty, for local dev) +- `src/Managing.Api/appsettings.Sandbox.json` - `srv-captain--redis:6379` +- `src/Managing.Api/appsettings.Production.json` - `srv-captain--redis:6379` + +### 4. NuGet Packages Added + +- `Microsoft.AspNetCore.SignalR.StackExchangeRedis` (8.0.10) - SignalR backplane +- `Microsoft.Extensions.Caching.StackExchangeRedis` (8.0.10) - Redis caching +- `StackExchange.Redis` (2.8.16) - Redis client + +## Deployment Steps for CapRover + +### Step 1: Create Redis Service + +1. In CapRover, go to **Apps** +2. Click **One-Click Apps/Databases** +3. Search for "Redis" +4. Deploy Redis (or use existing one) +5. Note the service name: `srv-captain--redis` (or your custom name) + +### Step 2: Configure CapRover App + +For `dev-managing-api` (Sandbox): + +1. **Enable WebSocket Support** + - Go to **HTTP Settings** + - Toggle **"WebSocket Support"** to ON + - Save + +2. **Enable Sticky Sessions** + - In **HTTP Settings** + - Toggle **"Enable Sticky Sessions"** to ON + - Save + +3. **Verify Redis Connection String** + - The connection string is already in `appsettings.Sandbox.json` + - Default: `srv-captain--redis:6379` + - If you used a different Redis service name, update via environment variable: + ``` + ConnectionStrings__Redis=srv-captain--your-redis-name:6379 + ``` + - Or use the fallback: + ``` + REDIS_URL=srv-captain--your-redis-name:6379 + ``` + +### Step 3: Deploy + +1. Build and deploy the API: + ```bash + cd src/Managing.Api + # Your normal deployment process + ``` + +2. Watch the logs during startup. You should see: + ``` + ✅ Configuring SignalR with Redis backplane: srv-captain--redis:6379 + ✅ Redis connection established successfully + ``` + +### Step 4: Scale to Multiple Instances + +1. In CapRover, go to your `dev-managing-api` app +2. **App Configs** tab +3. Set **"Number of app instances"** to `2` or `3` +4. Click **Save & Update** + +### Step 5: Test + +1. Open the frontend (Kaigen Web UI) +2. Open the AI Chat +3. Send a message +4. Should work without "No Connection with that ID" errors + +## Verification Checklist + +After deployment, verify: + +- [ ] Redis service is running in CapRover +- [ ] WebSocket support is enabled +- [ ] Sticky sessions are enabled +- [ ] API logs show Redis connection success +- [ ] Multiple instances are running +- [ ] AI Chat works without connection errors +- [ ] Browser Network tab shows WebSocket upgrade successful + +## Troubleshooting + +### Issue: "No Connection with that ID" Still Appears + +**Check:** +1. Redis service is running: `redis-cli -h srv-captain--redis ping` +2. API logs show Redis connected (not "Redis not configured") +3. Sticky sessions are ON +4. WebSocket support is ON + +**Quick Test:** +- Temporarily set instances to 1 +- If it works with 1 instance, the issue is multi-instance setup +- If it fails with 1 instance, check WebSocket/proxy configuration + +### Issue: Redis Connection Failed + +**Check Logs For:** +``` +⚠️ Failed to configure SignalR Redis backplane: +SignalR will work in single-instance mode only +``` + +**Solutions:** +1. Verify Redis service name matches configuration +2. Ensure Redis is not password-protected (or add password to config) +3. Check Redis service health in CapRover + +### Issue: WebSocket Upgrade Failed + +Not related to Redis. Check: +1. CapRover WebSocket support is ON +2. Nginx configuration allows upgrades +3. Browser console for detailed error + +## Configuration Reference + +### Connection String Formats + +**Simple (no password):** +``` +srv-captain--redis:6379 +``` + +**With Password:** +``` +srv-captain--redis:6379,password=your-password +``` + +**Multiple Options:** +``` +srv-captain--redis:6379,password=pwd,ssl=true,abortConnect=false +``` + +### Configuration Priority + +The app checks these in order: +1. `ConnectionStrings:Redis` (appsettings.json or `ConnectionStrings__Redis` environment variable) +2. `REDIS_URL` (fallback environment variable) + +**Recommended**: Use `ConnectionStrings__Redis` environment variable to override appsettings without rebuilding. + +## Architecture Benefits + +### Before (Single Instance) + +``` +Frontend → Nginx → API Instance + - In-memory SignalR + - Connection IDs stored locally + ❌ Scale limited to 1 instance +``` + +### After (Multi-Instance with Redis) + +``` +Frontend → Nginx (sticky) → API Instance 1 ┐ + → API Instance 2 ├─→ Redis ← SignalR Backplane + → API Instance 3 ┘ + +- Connection IDs in Redis +- Messages distributed via pub/sub +- Any instance can handle any connection +✅ Scale to N instances +``` + +## Next Steps + +After successful deployment: + +1. **Monitor Performance** + - Watch Redis memory usage + - Check API response times + - Monitor WebSocket connection stability + +2. **Consider Redis Clustering** + - For high availability + - If scaling beyond 3-4 API instances + +3. **Extend Redis Usage** + - Distributed caching + - Rate limiting + - Session storage + +## Rollback Plan + +If issues occur: + +1. **Immediate**: Set instances to 1 +2. **Environment Variable**: Set `REDIS_URL=` (empty) to disable Redis +3. **Code Rollback**: Previous version still works (graceful degradation) + +The implementation is backward-compatible and doesn't require Redis to function. + +## Support + +For issues: +1. Check logs: `src/Managing.Infrastructure.Storage/README-REDIS.md` +2. Review this guide +3. Check CapRover app logs for Redis/SignalR messages +4. Test with 1 instance first, then scale up + diff --git a/src/Managing.Api/Managing.Api.csproj b/src/Managing.Api/Managing.Api.csproj index e1ecc986..119bd1b1 100644 --- a/src/Managing.Api/Managing.Api.csproj +++ b/src/Managing.Api/Managing.Api.csproj @@ -17,6 +17,7 @@ + diff --git a/src/Managing.Api/Program.cs b/src/Managing.Api/Program.cs index 9c81db13..00822dbd 100644 --- a/src/Managing.Api/Program.cs +++ b/src/Managing.Api/Program.cs @@ -461,7 +461,32 @@ builder.Services.AddCors(options => }); }); -builder.Services.AddSignalR().AddJsonProtocol(); +// Configure SignalR with Redis backplane if available +var signalRBuilder = builder.Services.AddSignalR().AddJsonProtocol(); + +// Check if Redis is configured for SignalR backplane +// Priority: ConnectionStrings:Redis (can be set via ConnectionStrings__Redis env var) > REDIS_URL env var +var redisConnectionString = builder.Configuration.GetConnectionString("Redis") + ?? builder.Configuration["REDIS_URL"]; + +if (!string.IsNullOrWhiteSpace(redisConnectionString)) +{ + try + { + Console.WriteLine($"✅ Configuring SignalR with Redis backplane"); + signalRBuilder.AddStackExchangeRedis(redisConnectionString, options => + { + // Configure channel prefix for SignalR messages + options.Configuration.ChannelPrefix = "managing-signalr"; + }); + } + catch (Exception ex) + { + Console.WriteLine($"⚠️ Failed to configure SignalR Redis backplane: {ex.Message}"); + Console.WriteLine("SignalR will work in single-instance mode only"); + } +} + builder.Services.AddScoped(); builder.Services.RegisterApiDependencies(builder.Configuration); diff --git a/src/Managing.Api/appsettings.json b/src/Managing.Api/appsettings.json index 9127277e..c9fc60d5 100644 --- a/src/Managing.Api/appsettings.json +++ b/src/Managing.Api/appsettings.json @@ -23,6 +23,9 @@ "DebitEndpoint": "/api/credits/debit", "RefundEndpoint": "/api/credits/refund" }, + "ConnectionStrings": { + "Redis": "" + }, "Flagsmith": { "ApiUrl": "https://flag.kaigen.ai/api/v1/" }, diff --git a/src/Managing.Application.Abstractions/Managing.Application.Abstractions.csproj b/src/Managing.Application.Abstractions/Managing.Application.Abstractions.csproj index fbf3aa2d..3714c682 100644 --- a/src/Managing.Application.Abstractions/Managing.Application.Abstractions.csproj +++ b/src/Managing.Application.Abstractions/Managing.Application.Abstractions.csproj @@ -13,6 +13,7 @@ + diff --git a/src/Managing.Application.Abstractions/Services/IRedisConnectionService.cs b/src/Managing.Application.Abstractions/Services/IRedisConnectionService.cs new file mode 100644 index 00000000..a7f34370 --- /dev/null +++ b/src/Managing.Application.Abstractions/Services/IRedisConnectionService.cs @@ -0,0 +1,35 @@ +using StackExchange.Redis; + +namespace Managing.Application.Abstractions.Services; + +/// +/// Service for managing Redis connections in a generic way across the application. +/// This service provides access to the underlying Redis connection multiplexer +/// which can be used for various purposes including SignalR backplane, caching, etc. +/// +public interface IRedisConnectionService +{ + /// + /// Gets the Redis connection multiplexer instance. + /// Returns null if Redis is not configured or connection failed. + /// + IConnectionMultiplexer? GetConnection(); + + /// + /// Gets a Redis database instance. + /// + /// Database number (default: -1 for default database) + /// IDatabase instance or null if not connected + IDatabase? GetDatabase(int db = -1); + + /// + /// Checks if Redis is connected and available. + /// + bool IsConnected { get; } + + /// + /// Gets the Redis connection string being used. + /// + string? ConnectionString { get; } +} + diff --git a/src/Managing.Bootstrap/ApiBootstrap.cs b/src/Managing.Bootstrap/ApiBootstrap.cs index 5b45d543..35f6bc44 100644 --- a/src/Managing.Bootstrap/ApiBootstrap.cs +++ b/src/Managing.Bootstrap/ApiBootstrap.cs @@ -510,9 +510,56 @@ public static class ApiBootstrap services.AddTransient(); services.AddSingleton(); + // Redis (for SignalR backplane and other distributed scenarios) + services.AddRedis(configuration); + return services; } + private static IServiceCollection AddRedis(this IServiceCollection services, IConfiguration configuration) + { + // Check if Redis is configured + // Priority: ConnectionStrings:Redis (can be set via ConnectionStrings__Redis env var) > REDIS_URL env var + var redisConnectionString = configuration.GetConnectionString("Redis") + ?? configuration["REDIS_URL"]; + + if (!string.IsNullOrWhiteSpace(redisConnectionString)) + { + Console.WriteLine($"✅ Redis configured: {MaskRedisPassword(redisConnectionString)}"); + + // Register generic Redis connection service for various use cases + // (SignalR backplane, distributed caching, pub/sub, etc.) + services.AddSingleton(); + } + else + { + Console.WriteLine("ℹ️ Redis not configured - running in single-instance mode"); + + // Register a no-op Redis service that returns null connections + services.AddSingleton(sp => + new RedisConnectionService(configuration, sp.GetRequiredService>())); + } + + return services; + } + + private static string MaskRedisPassword(string connectionString) + { + if (connectionString.Contains("password=", StringComparison.OrdinalIgnoreCase)) + { + var parts = connectionString.Split(','); + for (int i = 0; i < parts.Length; i++) + { + if (parts[i].Trim().StartsWith("password=", StringComparison.OrdinalIgnoreCase)) + { + parts[i] = "password=***"; + } + } + return string.Join(",", parts); + } + return connectionString; + } + private static IServiceCollection AddWorkers(this IServiceCollection services, IConfiguration configuration) { if (configuration.GetValue("WorkerNotifyBundleBacktest", false)) diff --git a/src/Managing.Infrastructure.Storage/Managing.Infrastructure.Storage.csproj b/src/Managing.Infrastructure.Storage/Managing.Infrastructure.Storage.csproj index f90b07fa..5023b087 100644 --- a/src/Managing.Infrastructure.Storage/Managing.Infrastructure.Storage.csproj +++ b/src/Managing.Infrastructure.Storage/Managing.Infrastructure.Storage.csproj @@ -9,6 +9,8 @@ + + diff --git a/src/Managing.Infrastructure.Storage/README-REDIS.md b/src/Managing.Infrastructure.Storage/README-REDIS.md new file mode 100644 index 00000000..347428ee --- /dev/null +++ b/src/Managing.Infrastructure.Storage/README-REDIS.md @@ -0,0 +1,329 @@ +# Redis Integration Guide + +## Overview + +The Managing platform now includes a generic Redis integration that supports multiple use cases: + +1. **SignalR Backplane** - Enables multi-instance SignalR hubs (BotHub, BacktestHub, LlmHub) +2. **Distributed Caching** - Can be extended for distributed caching scenarios +3. **Other Redis Use Cases** - Generic connection service available for any Redis operations + +## Architecture + +### Components + +- **`IRedisConnectionService`** - Interface for Redis connectivity (in `Managing.Application.Abstractions`) +- **`RedisConnectionService`** - Implementation managing Redis connections (in `Managing.Infrastructure.Storage`) +- **SignalR Backplane** - Automatically configured when Redis is available (in `Managing.Api/Program.cs`) + +### Design Principles + +1. **Generic and Reusable** - The Redis service is in the Infrastructure layer and can be used by any part of the application +2. **Graceful Degradation** - If Redis is not configured, the application runs normally in single-instance mode +3. **Automatic Configuration** - SignalR automatically uses Redis backplane when available +4. **Connection Management** - Single connection multiplexer shared across the application + +## Configuration + +### Environment Variables + +Redis can be configured using any of these methods (in order of precedence): + +1. **ConnectionStrings:Redis** (recommended) - Set via `ConnectionStrings__Redis` environment variable +2. **REDIS_URL** (fallback environment variable) + +### Configuration Examples + +#### appsettings.json + +```json +{ + "ConnectionStrings": { + "Redis": "srv-captain--redis:6379" + } +} +``` + +#### With Password + +```json +{ + "ConnectionStrings": { + "Redis": "srv-captain--redis:6379,password=your-password-here" + } +} +``` + +#### Environment Variable (Recommended for CapRover/Docker) + +**.NET Standard Format:** +```bash +export ConnectionStrings__Redis="srv-captain--redis:6379" +# or with password +export ConnectionStrings__Redis="srv-captain--redis:6379,password=your-password-here" +``` + +**Fallback Format:** +```bash +export REDIS_URL="srv-captain--redis:6379" +# or with password +export REDIS_URL="srv-captain--redis:6379,password=your-password-here" +``` + +### Current Configuration + +- **Sandbox** (`appsettings.Sandbox.json`): `srv-captain--redis:6379` +- **Production** (`appsettings.Production.json`): `srv-captain--redis:6379` +- **Local Development** (`appsettings.json`): Not configured (single-instance mode) + +## Deployment + +### CapRover Requirements + +For multi-instance SignalR to work properly on CapRover: + +1. **Enable WebSocket Support** + - Go to App Settings + - Enable "WebSocket Support" + - Save and redeploy + +2. **Enable Sticky Sessions** + - Go to HTTP Settings + - Enable "Sticky Sessions" + - Save + +3. **Setup Redis** + - Create a Redis app in CapRover (or use external Redis) + - Note the service name (e.g., `srv-captain--redis`) + - Configure connection string in appsettings + +4. **Set Instance Count** + - With Redis and sticky sessions, you can safely scale to multiple instances + - Recommended: 2-3 instances for high availability + +### Docker/Docker Compose + +If using Docker Compose, add a Redis service: + +```yaml +services: + redis: + image: redis:7-alpine + ports: + - "6379:6379" + volumes: + - redis-data:/data + +volumes: + redis-data: +``` + +Then set the connection string to `redis:6379`. + +## SignalR Backplane + +### How It Works + +When Redis is configured: + +1. All SignalR connections register in Redis +2. Messages sent to a hub are distributed via Redis pub/sub +3. Each instance receives messages for its connected clients +4. Connection IDs are shared across instances + +### Benefits + +- **Scale Out**: Run multiple API instances +- **High Availability**: If one instance dies, others continue working +- **Load Balancing**: Distribute WebSocket connections across instances +- **No "Connection ID Not Found" Errors**: All instances know about all connections + +### Verification + +Check the application logs on startup: + +``` +✅ Configuring SignalR with Redis backplane: srv-captain--redis:6379 +✅ Redis connection established successfully +``` + +If Redis is not configured: + +``` +ℹ️ Redis not configured - SignalR running in single-instance mode +``` + +If Redis connection fails: + +``` +⚠️ Failed to configure SignalR Redis backplane: +SignalR will work in single-instance mode only +``` + +## Using Redis in Your Code + +### Accessing Redis Connection + +```csharp +public class MyService +{ + private readonly IRedisConnectionService _redisService; + + public MyService(IRedisConnectionService redisService) + { + _redisService = redisService; + } + + public async Task GetValueAsync(string key) + { + if (!_redisService.IsConnected) + { + // Redis not available, handle gracefully + return null; + } + + var db = _redisService.GetDatabase(); + return await db.StringGetAsync(key); + } + + public async Task SetValueAsync(string key, string value, TimeSpan? expiry = null) + { + if (!_redisService.IsConnected) + { + return; + } + + var db = _redisService.GetDatabase(); + await db.StringSetAsync(key, value, expiry); + } +} +``` + +### Advanced Usage + +```csharp +// Get the connection multiplexer for advanced scenarios +var connection = _redisService.GetConnection(); +if (connection != null) +{ + // Pub/Sub + var subscriber = connection.GetSubscriber(); + await subscriber.SubscribeAsync("my-channel", (channel, message) => + { + Console.WriteLine($"Received: {message}"); + }); + + // Multiple databases + var db0 = _redisService.GetDatabase(0); + var db1 = _redisService.GetDatabase(1); + + // Server operations + var server = connection.GetServer(connection.GetEndPoints().First()); + var keys = server.Keys(); +} +``` + +## Monitoring + +### Health Checks + +The Redis connection service automatically: + +- Reconnects on connection loss +- Logs connection events +- Provides connection status via `IsConnected` property + +### Logging + +Key log messages to watch: + +- `✅ Redis connection established successfully` - Initial connection succeeded +- `✅ Redis connection restored` - Reconnected after failure +- `❌ Redis connection failed: ` - Connection problem +- `⚠️ Redis connection lost, attempting to reconnect...` - Transient failure + +### CapRover Monitoring + +In CapRover App Logs, search for: + +- "Redis" - All Redis-related messages +- "SignalR" - SignalR configuration +- "No Connection with that ID" - SignalR connection issues (should not appear with Redis) + +## Troubleshooting + +### SignalR "No Connection with that ID" Error + +**Symptoms**: Frontend gets 404 with "No Connection with that ID" after negotiation + +**Causes**: +1. Multiple instances without Redis backplane +2. Sticky sessions not enabled +3. Redis not configured correctly + +**Solutions**: +1. Enable Redis (see Configuration section) +2. Enable sticky sessions in CapRover +3. Verify Redis connection in logs +4. Temporarily set instances to 1 to test + +### Redis Connection Failures + +**Check**: +1. Redis service is running in CapRover +2. Connection string is correct (service name, port) +3. No firewall blocking the connection +4. Redis service is healthy (check its logs) + +**Test Connection**: +```bash +# In CapRover terminal or SSH +redis-cli -h srv-captain--redis ping +# Should return: PONG +``` + +### Single Instance Still Shows Errors + +If running single instance and getting SignalR errors: + +1. Check WebSocket support is enabled in CapRover +2. Verify no proxy/load balancer issues +3. Check browser console for actual WebSocket error +4. Review CapRover Nginx configuration + +## Performance + +### Connection Pooling + +The Redis connection service uses a single `ConnectionMultiplexer` instance shared across the application. This is the recommended approach for StackExchange.Redis. + +### SignalR Message Volume + +Redis pub/sub is used for SignalR messages: + +- Low overhead for typical SignalR usage +- Efficient binary protocol +- Automatic message routing + +### Scaling Considerations + +- **2-3 instances**: Optimal for most workloads +- **4+ instances**: Consider Redis clustering for high availability +- Monitor Redis memory usage if using for caching + +## Future Enhancements + +The generic Redis service can be extended for: + +1. **Distributed Caching**: Replace `IDistributedCache` implementation +2. **Session Storage**: Store user sessions in Redis +3. **Rate Limiting**: Use Redis for distributed rate limiting +4. **Pub/Sub**: Implement event-driven architecture +5. **Job Queues**: Background job processing with Redis + +## References + +- [StackExchange.Redis Documentation](https://stackexchange.github.io/StackExchange.Redis/) +- [SignalR Scale-out with Redis](https://learn.microsoft.com/en-us/aspnet/core/signalr/scale) +- [Redis Best Practices](https://redis.io/docs/manual/patterns/) + diff --git a/src/Managing.Infrastructure.Storage/RedisConnectionService.cs b/src/Managing.Infrastructure.Storage/RedisConnectionService.cs new file mode 100644 index 00000000..58f03d8f --- /dev/null +++ b/src/Managing.Infrastructure.Storage/RedisConnectionService.cs @@ -0,0 +1,160 @@ +using Managing.Application.Abstractions.Services; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Logging; +using StackExchange.Redis; + +namespace Managing.Infrastructure.Storage; + +/// +/// Generic Redis connection service that manages a single Redis connection multiplexer +/// to be shared across the application for various purposes (SignalR, caching, etc.) +/// +public class RedisConnectionService : IRedisConnectionService, IDisposable +{ + private readonly ILogger _logger; + private readonly string? _connectionString; + private IConnectionMultiplexer? _connection; + private readonly object _lock = new object(); + private bool _disposed; + + public RedisConnectionService(IConfiguration configuration, ILogger logger) + { + _logger = logger; + + // Try configuration keys in priority order + // Priority: ConnectionStrings:Redis (can be set via ConnectionStrings__Redis env var) > REDIS_URL env var + _connectionString = configuration.GetConnectionString("Redis") + ?? configuration["REDIS_URL"]; + + if (string.IsNullOrWhiteSpace(_connectionString)) + { + _logger.LogWarning("Redis connection string not configured. Redis features will be unavailable."); + return; + } + + InitializeConnection(); + } + + private void InitializeConnection() + { + if (string.IsNullOrWhiteSpace(_connectionString)) + { + return; + } + + try + { + _logger.LogInformation("Initializing Redis connection to: {ConnectionString}", + MaskConnectionString(_connectionString)); + + var options = ConfigurationOptions.Parse(_connectionString); + + // Configure connection options + options.AbortOnConnectFail = false; // Don't fail the app if Redis is down + options.ConnectTimeout = 5000; // 5 second timeout + options.SyncTimeout = 5000; + options.AsyncTimeout = 5000; + options.ConnectRetry = 3; + options.KeepAlive = 60; // Send keepalive every 60 seconds + + _connection = ConnectionMultiplexer.Connect(options); + + _connection.ConnectionFailed += OnConnectionFailed; + _connection.ConnectionRestored += OnConnectionRestored; + _connection.ErrorMessage += OnErrorMessage; + + _logger.LogInformation("✅ Redis connection established successfully"); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to connect to Redis. Redis features will be unavailable."); + _connection = null; + } + } + + private void OnConnectionFailed(object? sender, ConnectionFailedEventArgs e) + { + _logger.LogError("❌ Redis connection failed: {Exception}", e.Exception?.Message ?? "Unknown error"); + } + + private void OnConnectionRestored(object? sender, ConnectionFailedEventArgs e) + { + _logger.LogInformation("✅ Redis connection restored"); + } + + private void OnErrorMessage(object? sender, RedisErrorEventArgs e) + { + _logger.LogError("Redis error: {Message}", e.Message); + } + + public IConnectionMultiplexer? GetConnection() + { + if (_connection != null && _connection.IsConnected) + { + return _connection; + } + + // Try to reconnect if connection was lost + lock (_lock) + { + if (_connection == null || !_connection.IsConnected) + { + _logger.LogWarning("Redis connection lost, attempting to reconnect..."); + _connection?.Dispose(); + _connection = null; + InitializeConnection(); + } + } + + return _connection; + } + + public IDatabase? GetDatabase(int db = -1) + { + var connection = GetConnection(); + return connection?.GetDatabase(db); + } + + public bool IsConnected => _connection != null && _connection.IsConnected; + + public string? ConnectionString => _connectionString; + + private static string MaskConnectionString(string connectionString) + { + // Mask password in connection string for logging + if (connectionString.Contains("password=", StringComparison.OrdinalIgnoreCase)) + { + var parts = connectionString.Split(','); + for (int i = 0; i < parts.Length; i++) + { + if (parts[i].Trim().StartsWith("password=", StringComparison.OrdinalIgnoreCase)) + { + parts[i] = "password=***"; + } + } + return string.Join(",", parts); + } + return connectionString; + } + + public void Dispose() + { + if (_disposed) + { + return; + } + + _disposed = true; + + if (_connection != null) + { + _logger.LogInformation("Disposing Redis connection"); + _connection.ConnectionFailed -= OnConnectionFailed; + _connection.ConnectionRestored -= OnConnectionRestored; + _connection.ErrorMessage -= OnErrorMessage; + _connection.Dispose(); + _connection = null; + } + } +} +