using System.Collections.Concurrent; using StackExchange.Redis; namespace DorkNet.Server.Services; /// /// Cross-replica tracker for the set of currently-online players (anyone /// with at least one active SignalR connection somewhere in the cluster). /// /// **Replaces the static NotifyHub._byPlayer dictionary** which /// only tracked connections on the local replica. With horizontal scale, /// admin tools that asked "is player X online" got a per-replica answer /// — they'd see a player as offline if that player happened to be /// connected to a different replica. This service centralises the /// answer in Redis so all replicas agree. /// /// **Two Redis keys per player**: /// /// notify:conns:{playerId} — Set of active connection ids /// for that player on any replica. Connections add themselves on /// and remove on /// . The set has /// a 24h EXPIRE backstop so a replica that crashes mid-flight /// doesn't leak connection ids forever. /// notify:online — Master set of player ids that have at /// least one active connection. Maintained explicitly: SADD when /// the conns set transitions empty→non-empty, SREM when it goes /// non-empty→empty. returns SMEMBERS /// in one round-trip. /// /// /// Falls back to in-process state when no Redis is configured (local dev). /// Same semantics — just per-replica visibility. /// public class OnlinePresenceService { private readonly IConnectionMultiplexer? _redis; // Local fallback only. The outer dict maps playerId → connection-id set. private readonly ConcurrentDictionary> _localConns = new(); /// Aggressive backstop: if a replica vanishes ungracefully, /// the conns set ages out instead of leaking forever. Real disconnect /// events should remove specific connection ids well before this. public static TimeSpan ConnectionsKeyTtl { get; } = TimeSpan.FromHours(24); public OnlinePresenceService(IConnectionMultiplexer? redis = null) => _redis = redis; /// Register one SignalR connection. Returns true when that was the /// player's FIRST active connection, i.e. they just came online and friends /// should be told. Mirrors , which /// reports the opposite transition. public async Task AddConnectionAsync(long playerId, string connectionId) { if (_redis is { } mux) { var db = mux.GetDatabase(); var connsKey = ConnsKey(playerId); var added = await db.SetAddAsync(connsKey, connectionId); await db.KeyExpireAsync(connsKey, ConnectionsKeyTtl); // Only flip the master `online` set when this is the FIRST // connection for the player — saves a SADD on every retry // reconnect and keeps the online set tight. if (added) { var size = await db.SetLengthAsync(connsKey); if (size == 1) { await db.SetAddAsync(OnlineSetKey, playerId); return true; } } return false; } var set = _localConns.GetOrAdd(playerId, _ => new HashSet()); lock (set) { set.Add(connectionId); return set.Count == 1; } } /// Remove one SignalR connection. Returns true when that was /// the player's final active connection and they should now be treated /// as offline by friends-list presence. public async Task RemoveConnectionAsync(long playerId, string connectionId) { if (_redis is { } mux) { var db = mux.GetDatabase(); var connsKey = ConnsKey(playerId); var removed = await db.SetRemoveAsync(connsKey, connectionId); if (removed) { var remaining = await db.SetLengthAsync(connsKey); if (remaining == 0) { // Last connection for this player gone — drop them // from the online set so admin queries see "offline". await db.SetRemoveAsync(OnlineSetKey, playerId); await db.KeyDeleteAsync(connsKey); return true; } } return false; } if (_localConns.TryGetValue(playerId, out var set)) { lock (set) { set.Remove(connectionId); if (set.Count != 0) return false; } _localConns.TryRemove(playerId, out _); return true; } return false; } /// Snapshot of SignalR connection ids that currently belong /// to . Used by /// to detect a second-session join and kick the existing /// connection(s) via a targeted ModerationKick push. Returns an /// empty collection if nobody (or only the current caller) is /// connected — never null. public IReadOnlyCollection GetConnections(long playerId) { if (_redis is { } mux) { try { var members = mux.GetDatabase().SetMembers(ConnsKey(playerId)); if (members.Length == 0) return Array.Empty(); var result = new string[members.Length]; for (int i = 0; i < members.Length; i++) result[i] = members[i].ToString(); return result; } catch { // Redis sick → fall through to local fallback rather // than pretend there are no existing connections. } } if (!_localConns.TryGetValue(playerId, out var set)) return Array.Empty(); lock (set) return set.ToArray(); } /// Connection ids of every player in , /// in one go: the Redis lookups are issued together, so the multiplexer /// pipelines them into one round trip instead of one per player. public async Task> GetConnectionsAsync(IEnumerable playerIds) { var ids = playerIds.Distinct().ToArray(); if (ids.Length == 0) return Array.Empty(); if (_redis is { } mux) { try { var db = mux.GetDatabase(); var sets = await Task.WhenAll(ids.Select(id => db.SetMembersAsync(ConnsKey(id)))); return sets.SelectMany(s => s).Select(v => v.ToString()).ToArray(); } catch { // Redis sick → fall through to the local fallback, as // GetConnections does. } } var result = new List(); foreach (var id in ids) if (_localConns.TryGetValue(id, out var set)) lock (set) result.AddRange(set); return result; } /// Snapshot of currently-online player ids across the /// cluster. Returns an empty array when nobody's online; never null. /// Callers typically .ToHashSet() the result for membership /// queries inside a tight loop. public IReadOnlyCollection OnlinePlayerIds() { if (_redis is { } mux) { var members = mux.GetDatabase().SetMembers(OnlineSetKey); var result = new long[members.Length]; for (int i = 0; i < members.Length; i++) result[i] = (long)members[i]; return result; } return _localConns.Where(kv => kv.Value.Count > 0).Select(kv => kv.Key).ToArray(); } private const string OnlineSetKey = "notify:online"; private static string ConnsKey(long playerId) => $"notify:conns:{playerId}"; }