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 logger) : BackgroundService { const string Active = "im:management:connections"; static readonly RedisChannel Revocations = RedisChannel.Literal("im:management:disconnect"); readonly ConcurrentDictionary 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 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 { } } } }