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:
243
REDIS_SIGNALR_DEPLOYMENT.md
Normal file
243
REDIS_SIGNALR_DEPLOYMENT.md
Normal file
@@ -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: <error>
|
||||||
|
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
|
||||||
|
|
||||||
@@ -17,6 +17,7 @@
|
|||||||
<PackageReference Include="AspNetCoreRateLimit" Version="5.0.0" />
|
<PackageReference Include="AspNetCoreRateLimit" Version="5.0.0" />
|
||||||
<PackageReference Include="Essential.LoggerProvider.Elasticsearch" Version="1.3.2" />
|
<PackageReference Include="Essential.LoggerProvider.Elasticsearch" Version="1.3.2" />
|
||||||
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="8.0.5" />
|
<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.Orleans.Core" Version="9.2.1" />
|
||||||
<PackageReference Include="Microsoft.VisualStudio.Azure.Containers.Tools.Targets" Version="1.20.1" />
|
<PackageReference Include="Microsoft.VisualStudio.Azure.Containers.Tools.Targets" Version="1.20.1" />
|
||||||
<PackageReference Include="NSwag.AspNetCore" Version="14.0.7" />
|
<PackageReference Include="NSwag.AspNetCore" Version="14.0.7" />
|
||||||
|
|||||||
@@ -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.AddScoped<IJwtUtils, JwtUtils>();
|
||||||
|
|
||||||
builder.Services.RegisterApiDependencies(builder.Configuration);
|
builder.Services.RegisterApiDependencies(builder.Configuration);
|
||||||
|
|||||||
@@ -23,6 +23,9 @@
|
|||||||
"DebitEndpoint": "/api/credits/debit",
|
"DebitEndpoint": "/api/credits/debit",
|
||||||
"RefundEndpoint": "/api/credits/refund"
|
"RefundEndpoint": "/api/credits/refund"
|
||||||
},
|
},
|
||||||
|
"ConnectionStrings": {
|
||||||
|
"Redis": ""
|
||||||
|
},
|
||||||
"Flagsmith": {
|
"Flagsmith": {
|
||||||
"ApiUrl": "https://flag.kaigen.ai/api/v1/"
|
"ApiUrl": "https://flag.kaigen.ai/api/v1/"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -13,6 +13,7 @@
|
|||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="Microsoft.Orleans.Core.Abstractions" Version="9.2.1"/>
|
<PackageReference Include="Microsoft.Orleans.Core.Abstractions" Version="9.2.1"/>
|
||||||
|
<PackageReference Include="StackExchange.Redis" Version="2.8.16"/>
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
</Project>
|
</Project>
|
||||||
|
|||||||
@@ -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; }
|
||||||
|
}
|
||||||
|
|
||||||
@@ -510,9 +510,56 @@ public static class ApiBootstrap
|
|||||||
services.AddTransient<ICacheService, CacheService>();
|
services.AddTransient<ICacheService, CacheService>();
|
||||||
services.AddSingleton<ITaskCache, TaskCache>();
|
services.AddSingleton<ITaskCache, TaskCache>();
|
||||||
|
|
||||||
|
// Redis (for SignalR backplane and other distributed scenarios)
|
||||||
|
services.AddRedis(configuration);
|
||||||
|
|
||||||
return services;
|
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)
|
private static IServiceCollection AddWorkers(this IServiceCollection services, IConfiguration configuration)
|
||||||
{
|
{
|
||||||
if (configuration.GetValue<bool>("WorkerNotifyBundleBacktest", false))
|
if (configuration.GetValue<bool>("WorkerNotifyBundleBacktest", false))
|
||||||
|
|||||||
@@ -9,6 +9,8 @@
|
|||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="Microsoft.Extensions.Caching.Abstractions" Version="8.0.0"/>
|
<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.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"/>
|
<PackageReference Include="System.Runtime.Caching" Version="8.0.0"/>
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
|
|||||||
329
src/Managing.Infrastructure.Storage/README-REDIS.md
Normal file
329
src/Managing.Infrastructure.Storage/README-REDIS.md
Normal 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/)
|
||||||
|
|
||||||
160
src/Managing.Infrastructure.Storage/RedisConnectionService.cs
Normal file
160
src/Managing.Infrastructure.Storage/RedisConnectionService.cs
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Reference in New Issue
Block a user