using System; using System.Collections.Concurrent; using System.Collections.Generic; using System.Linq; using System.Threading; using Serilog; namespace ZB.MOM.WW.LmxProxy.Host.Sessions { /// /// Tracks active client sessions in memory. /// Thread-safe via ConcurrentDictionary. /// public sealed class SessionManager : IDisposable { private static readonly ILogger Log = Serilog.Log.ForContext(); private readonly ConcurrentDictionary _sessions = new ConcurrentDictionary(StringComparer.OrdinalIgnoreCase); private readonly Timer? _scavengingTimer; private readonly TimeSpan _inactivityTimeout; private Action? _onSessionScavenged; /// /// Creates a SessionManager with optional inactivity scavenging. /// /// /// Sessions inactive for this many minutes are automatically terminated. /// Set to 0 to disable scavenging. /// public SessionManager(int inactivityTimeoutMinutes = 5) { _inactivityTimeout = TimeSpan.FromMinutes(inactivityTimeoutMinutes); if (inactivityTimeoutMinutes > 0) { // Check every 60 seconds _scavengingTimer = new Timer(ScavengeInactiveSessions, null, TimeSpan.FromSeconds(60), TimeSpan.FromSeconds(60)); } } /// /// Register a callback invoked when a session is scavenged due to inactivity. /// The callback receives the session ID. /// public void OnSessionScavenged(Action callback) { _onSessionScavenged = callback; } /// Gets the count of active sessions. public int ActiveSessionCount => _sessions.Count; /// /// Creates a new session. /// Returns the 32-character hex GUID session ID. /// public string CreateSession(string clientId, string apiKey) { var sessionId = Guid.NewGuid().ToString("N"); // 32-char lowercase hex, no hyphens var sessionInfo = new SessionInfo(sessionId, clientId, apiKey); _sessions[sessionId] = sessionInfo; Log.Information("Session created: {SessionId} for client {ClientId}", sessionId, clientId); return sessionId; } /// /// Validates a session ID. Updates LastActivity on success. /// Returns true if the session exists. /// public bool ValidateSession(string sessionId) { if (_sessions.TryGetValue(sessionId, out var session)) { session.TouchLastActivity(); return true; } return false; } /// /// Terminates a session. Returns true if the session existed. /// public bool TerminateSession(string sessionId) { if (_sessions.TryRemove(sessionId, out _)) { Log.Information("Session terminated: {SessionId}", sessionId); return true; } return false; } /// Gets session info by ID, or null if not found. public SessionInfo? GetSession(string sessionId) { _sessions.TryGetValue(sessionId, out var session); return session; } /// Gets a snapshot of all active sessions. public IReadOnlyList GetAllSessions() { return _sessions.Values.ToList().AsReadOnly(); } /// /// Scavenges sessions that have been inactive for longer than the timeout. /// private void ScavengeInactiveSessions(object? state) { if (_inactivityTimeout <= TimeSpan.Zero) return; var cutoff = DateTime.UtcNow - _inactivityTimeout; var expired = _sessions.Where(kvp => kvp.Value.LastActivity < cutoff).ToList(); foreach (var kvp in expired) { if (_sessions.TryRemove(kvp.Key, out _)) { Log.Information("Session {SessionId} scavenged (inactive since {LastActivity})", kvp.Key, kvp.Value.LastActivity); try { _onSessionScavenged?.Invoke(kvp.Key); } catch (Exception ex) { Log.Warning(ex, "Error in session scavenge callback for {SessionId}", kvp.Key); } } } } public void Dispose() { _scavengingTimer?.Dispose(); _sessions.Clear(); } } /// /// Information about an active client session. /// public class SessionInfo { public SessionInfo(string sessionId, string clientId, string apiKey) { SessionId = sessionId; ClientId = clientId; ApiKey = apiKey; ConnectedAt = DateTime.UtcNow; LastActivity = DateTime.UtcNow; } public string SessionId { get; } public string ClientId { get; } public string ApiKey { get; } public DateTime ConnectedAt { get; } public DateTime LastActivity { get; private set; } public long ConnectedSinceUtcTicks => ConnectedAt.Ticks; /// Updates the last activity timestamp to now. public void TouchLastActivity() { LastActivity = DateTime.UtcNow; } } }