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.
This commit is contained in:
2026-01-07 16:59:10 +07:00
parent bc4725ca19
commit 7108907e0e
10 changed files with 847 additions and 1 deletions

View File

@@ -17,6 +17,7 @@
<PackageReference Include="AspNetCoreRateLimit" Version="5.0.0" />
<PackageReference Include="Essential.LoggerProvider.Elasticsearch" Version="1.3.2" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="8.0.5" />
<PackageReference Include="Microsoft.AspNetCore.SignalR.StackExchangeRedis" Version="8.0.10" />
<PackageReference Include="Microsoft.Orleans.Core" Version="9.2.1" />
<PackageReference Include="Microsoft.VisualStudio.Azure.Containers.Tools.Targets" Version="1.20.1" />
<PackageReference Include="NSwag.AspNetCore" Version="14.0.7" />

View File

@@ -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<IJwtUtils, JwtUtils>();
builder.Services.RegisterApiDependencies(builder.Configuration);

View File

@@ -23,6 +23,9 @@
"DebitEndpoint": "/api/credits/debit",
"RefundEndpoint": "/api/credits/refund"
},
"ConnectionStrings": {
"Redis": ""
},
"Flagsmith": {
"ApiUrl": "https://flag.kaigen.ai/api/v1/"
},

View File

@@ -13,6 +13,7 @@
<ItemGroup>
<PackageReference Include="Microsoft.Orleans.Core.Abstractions" Version="9.2.1"/>
<PackageReference Include="StackExchange.Redis" Version="2.8.16"/>
</ItemGroup>
</Project>

View File

@@ -0,0 +1,35 @@
using StackExchange.Redis;
namespace Managing.Application.Abstractions.Services;
/// <summary>
/// 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.
/// </summary>
public interface IRedisConnectionService
{
/// <summary>
/// Gets the Redis connection multiplexer instance.
/// Returns null if Redis is not configured or connection failed.
/// </summary>
IConnectionMultiplexer? GetConnection();
/// <summary>
/// Gets a Redis database instance.
/// </summary>
/// <param name="db">Database number (default: -1 for default database)</param>
/// <returns>IDatabase instance or null if not connected</returns>
IDatabase? GetDatabase(int db = -1);
/// <summary>
/// Checks if Redis is connected and available.
/// </summary>
bool IsConnected { get; }
/// <summary>
/// Gets the Redis connection string being used.
/// </summary>
string? ConnectionString { get; }
}

View File

@@ -510,9 +510,56 @@ public static class ApiBootstrap
services.AddTransient<ICacheService, CacheService>();
services.AddSingleton<ITaskCache, TaskCache>();
// 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<IRedisConnectionService, RedisConnectionService>();
}
else
{
Console.WriteLine(" Redis not configured - running in single-instance mode");
// Register a no-op Redis service that returns null connections
services.AddSingleton<IRedisConnectionService>(sp =>
new RedisConnectionService(configuration, sp.GetRequiredService<ILogger<RedisConnectionService>>()));
}
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<bool>("WorkerNotifyBundleBacktest", false))

View File

@@ -9,6 +9,8 @@
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Caching.Abstractions" Version="8.0.0"/>
<PackageReference Include="Microsoft.Extensions.Caching.Memory" Version="8.0.1"/>
<PackageReference Include="Microsoft.Extensions.Caching.StackExchangeRedis" Version="8.0.10"/>
<PackageReference Include="StackExchange.Redis" Version="2.8.16"/>
<PackageReference Include="System.Runtime.Caching" Version="8.0.0"/>
</ItemGroup>

View File

@@ -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: <error message>
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<string?> 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: <error>` - 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/)

View File

@@ -0,0 +1,160 @@
using Managing.Application.Abstractions.Services;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging;
using StackExchange.Redis;
namespace Managing.Infrastructure.Storage;
/// <summary>
/// Generic Redis connection service that manages a single Redis connection multiplexer
/// to be shared across the application for various purposes (SignalR, caching, etc.)
/// </summary>
public class RedisConnectionService : IRedisConnectionService, IDisposable
{
private readonly ILogger<RedisConnectionService> _logger;
private readonly string? _connectionString;
private IConnectionMultiplexer? _connection;
private readonly object _lock = new object();
private bool _disposed;
public RedisConnectionService(IConfiguration configuration, ILogger<RedisConnectionService> 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;
}
}
}