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;
}
}