39 lines
2.8 KiB
C#
39 lines
2.8 KiB
C#
using System.Collections.Concurrent;
|
|
using System.Security.Claims;
|
|
using Microsoft.AspNetCore.SignalR;
|
|
using StackExchange.Redis;
|
|
|
|
namespace ConnectorService.Services;
|
|
public sealed class ConnectionRegistry(IConnectionMultiplexer redis, ILogger<ConnectionRegistry> logger) : BackgroundService
|
|
{
|
|
const string Active = "im:management:connections";
|
|
static readonly RedisChannel Revocations = RedisChannel.Literal("im:management:disconnect");
|
|
readonly ConcurrentDictionary<string, HubCallerContext> contexts = new();
|
|
public async Task Add(HubCallerContext c) { contexts[c.ConnectionId] = c; await Beat(c); }
|
|
public async Task Remove(HubCallerContext c) { contexts.TryRemove(c.ConnectionId, out _); await redis.GetDatabase().SortedSetRemoveAsync(Active, Key(c)); }
|
|
static string Key(HubCallerContext c) => c.User?.FindFirstValue(ClaimTypes.NameIdentifier) + ":" + c.ConnectionId;
|
|
Task Beat(HubCallerContext c) => redis.GetDatabase().SortedSetAddAsync(Active, Key(c), DateTimeOffset.UtcNow.AddSeconds(90).ToUnixTimeSeconds());
|
|
public async Task Disconnect(Guid id) { Abort(id.ToString()); await redis.GetSubscriber().PublishAsync(Revocations, id.ToString()); }
|
|
void Abort(string userId) { foreach (var c in contexts.Values.Where(c => c.User!.FindFirstValue(ClaimTypes.NameIdentifier) == userId)) c.Abort(); }
|
|
public async Task<object> Counts() {
|
|
var db = redis.GetDatabase(); await db.SortedSetRemoveRangeByScoreAsync(Active, double.NegativeInfinity, DateTimeOffset.UtcNow.ToUnixTimeSeconds());
|
|
var active = await db.SortedSetRangeByScoreAsync(Active, DateTimeOffset.UtcNow.ToUnixTimeSeconds(), double.PositiveInfinity);
|
|
return new { connections = active.Length, users = active.Select(x => x.ToString().Split(':')[0]).Distinct().Count(), sampledAt = DateTime.UtcNow, expiresAfterSeconds = 90 };
|
|
}
|
|
protected override async Task ExecuteAsync(CancellationToken ct) {
|
|
using var timer = new PeriodicTimer(TimeSpan.FromSeconds(20));
|
|
var subscribed = false;
|
|
try { do {
|
|
try {
|
|
if (!subscribed) { await redis.GetSubscriber().SubscribeAsync(Revocations, (_, value) => Abort(value.ToString())); subscribed = true; }
|
|
foreach (var c in contexts.Values) { if (c.ConnectionAborted.IsCancellationRequested) await Remove(c); else await Beat(c); }
|
|
} catch (Exception e) when (!ct.IsCancellationRequested) {
|
|
// Fail closed while the revocation channel is unavailable.
|
|
foreach (var c in contexts.Values) c.Abort();
|
|
logger.LogWarning("Connection heartbeat unavailable: {Type}",e.GetType().Name);
|
|
}
|
|
} while (await timer.WaitForNextTickAsync(ct)); }
|
|
finally { try { await redis.GetSubscriber().UnsubscribeAsync(Revocations); } catch { } }
|
|
}
|
|
}
|