using Application.Abstractions.Cache; using Application.Abstractions.Hub; using StackExchange.Redis; namespace Infrastructure.Hubs; /// /// SignalR 멤버 접속 추적 (Redis 기반). /// `member:connections:{memberID}` set에 connectionId 저장 → refcount 패턴. /// 첫 connection 진입 시 `member:online` set에 추가, 마지막 disconnect 시 제거. /// internal sealed class PresenceTracker(IConnectionMultiplexer redis) : IPresenceTracker { public async Task AddConnectionAsync(int memberID, string connectionId) { var db = redis.GetDatabase(); var key = CacheKeys.MemberConnections(memberID); var added = await db.SetAddAsync(key, connectionId); if (!added) { // 동일 connectionId 중복 등록 — 변화 없음 return false; } // 항상 MemberOnlineSet 반영. SADD 의 set semantics 로 idempotent. // 이전 버전은 totalConnections==1 가드를 두었으나, Web.Api 비정상 종료로 남은 // 고아 connectionId 가 set 에 있을 때 신규 connection 이 와도 online set 에 반영되지 않는 버그가 있었음. var wasNewlyOnline = await db.SetAddAsync(CacheKeys.MemberOnlineSet, memberID); return wasNewlyOnline; // true = 0→1 전환 (online set 에 새로 추가됨) } public async Task ClearAllAsync() { // Web.Api 재시작 시 stale presence 데이터 정리. 모든 client 는 reconnect 시 OnConnected 로 재등록됨. var db = redis.GetDatabase(); var endpoints = redis.GetEndPoints(); foreach (var endpoint in endpoints) { var server = redis.GetServer(endpoint); if (!server.IsConnected || server.IsReplica) { continue; } // member:connections:* 패턴 일괄 삭제 await foreach (var key in server.KeysAsync(database: db.Database, pattern: "member:connections:*")) { await db.KeyDeleteAsync(key); } } // member:online set 전체 삭제 await db.KeyDeleteAsync(CacheKeys.MemberOnlineSet); } public async Task RemoveConnectionAsync(int memberID, string connectionId) { var db = redis.GetDatabase(); var key = CacheKeys.MemberConnections(memberID); var removed = await db.SetRemoveAsync(key, connectionId); if (!removed) { return false; } // 마지막 connection이면 online set에서 제거 + 빈 set 삭제 var remaining = await db.SetLengthAsync(key); if (remaining == 0) { await db.KeyDeleteAsync(key); await db.SetRemoveAsync(CacheKeys.MemberOnlineSet, memberID); return true; // 1→0 전환 } return false; } public async Task IsOnlineAsync(int memberID) { var db = redis.GetDatabase(); return await db.SetContainsAsync(CacheKeys.MemberOnlineSet, memberID); } public async Task> GetOnlineMembersAsync(IEnumerable memberIDs) { var ids = memberIDs.Distinct().ToArray(); if (ids.Length == 0) { return []; } var db = redis.GetDatabase(); var values = ids.Select(id => (RedisValue)id).ToArray(); var results = await db.SetContainsAsync(CacheKeys.MemberOnlineSet, values); var online = new HashSet(); for (int i = 0; i < ids.Length; i++) { if (results[i]) { online.Add(ids[i]); } } return online; } }