using System.Collections.Concurrent; using Microsoft.Extensions.Logging; using StackExchange.Redis; namespace DorkNet.Server.Services; /// /// Cross-replica ledger of "freshly created accounts that haven't logged /// in yet." Backs the orphan-cleanup pattern around account/create: /// /// • The 2020 watch boot flow always calls account/create /// after cachedlogin as a defensive "ensure my account /// exists" probe, regardless of whether the user is picking a /// cached account or making a brand-new one. We can't tell which /// intent it is from the request alone — both flows look identical /// on the wire. /// /// • Solution: account/create ALWAYS creates a new row (so the /// "Create new account" UI is honoured), but the new id gets /// registered here. When the watch's next call is /// cached_login with a DIFFERENT account_id (the boot-flow /// case — user picked their cached account), the cached_login /// handler reaches into this tracker and deletes the just-created /// orphan. When cached_login picks the same id (the "create new" /// case — user is logging in to the new account), we leave it. /// /// Entries expire after via Redis EXPIRE so a stale /// boot doesn't keep a row orphaned indefinitely. Keys are namespaced /// orphan:{deviceId}|{platformId}. /// /// **Multi-replica**: when is /// available (Coolify production), the state lives in Redis and is /// visible across every replica — so a POST account/create on /// replica A followed by POST connect/token (cached_login) on /// replica B sees the same orphan id and cleans it up correctly. When /// no Redis is configured (local single-instance dev), falls back to /// a process-local with the same /// semantics — identical to pre-PR-2 behaviour. /// public class OrphanAccountTracker { private record Entry(long AccountId, DateTime CreatedAt); private readonly ConcurrentDictionary _local = new(); private readonly IConnectionMultiplexer? _redis; private readonly ILogger? _logger; public OrphanAccountTracker(IConnectionMultiplexer? redis = null, ILogger? logger = null) { _redis = redis; _logger = logger; } /// Time window after creation during which a different /// cached_login call counts as a boot-flow orphan. public static TimeSpan Ttl { get; } = TimeSpan.FromMinutes(5); /// Record that was just /// created via account/create on the given device. Overwrites any /// older pending entry for the same device — we only care about /// the most recent in-flight creation. public void TrackCreation(string? deviceId, string? platformId, long accountId) { var key = MakeKey(deviceId, platformId); if (_redis is { } mux) { try { // SET key value EX 300 — atomic create-or-overwrite with TTL. mux.GetDatabase().StringSet(key, accountId, Ttl); return; } catch (RedisException ex) { // Redis sick (AOF full, MISCONF, connection drop). Fall // through to local tracking so login keeps working — the // worst case is that orphan recovery loses cross-replica // visibility until Redis recovers. _logger?.LogWarning(ex, "[orphan-tracker] Redis SET failed for {Key}; falling back to local", key); } } _local[key] = new Entry(accountId, DateTime.UtcNow); } /// Look up the in-flight creation for this device. Returns /// null when there's no pending entry, or when the entry has /// expired. Read-only — the entry stays around for a possible /// later call. public long? PeekPending(string? deviceId, string? platformId) { var key = MakeKey(deviceId, platformId); if (_redis is { } mux) { try { var v = mux.GetDatabase().StringGet(key); // RedisValue is implicitly convertible — null/missing returns // RedisValue.Null which TryParse would bail on. if (v.HasValue && long.TryParse(v.ToString(), out var id)) return id; // Fall through to local check — a previous TrackCreation // might have fallen back to local while Redis was sick. } catch (RedisException ex) { _logger?.LogWarning(ex, "[orphan-tracker] Redis GET failed for {Key}; falling back to local", key); } } if (!_local.TryGetValue(key, out var entry)) return null; if (DateTime.UtcNow - entry.CreatedAt > Ttl) { _local.TryRemove(key, out _); return null; } return entry.AccountId; } /// Drop the pending entry for this device — typically /// called after we've decided whether to delete the orphan or keep /// it. Idempotent. public void Clear(string? deviceId, string? platformId) { var key = MakeKey(deviceId, platformId); if (_redis is { } mux) { try { mux.GetDatabase().KeyDelete(key); } catch (RedisException ex) { // Best-effort delete. Stale orphan keys expire after // Ttl anyway, so swallowing this is safe. _logger?.LogWarning(ex, "[orphan-tracker] Redis DEL failed for {Key}; ignoring (TTL will collect)", key); } } _local.TryRemove(key, out _); } /// Composite key. Empty platformId is fine — single-Steam- /// emu setups still differentiate by deviceId alone. Redis keys /// are namespaced under orphan: so they don't collide with /// other tenants in the same instance. private static string MakeKey(string? deviceId, string? platformId) => $"orphan:{deviceId ?? ""}|{platformId ?? ""}"; }