using CollabDAL.CustomCode.CollabConfig; using CollabDAL.CustomCode.CollabEvent; using CollabDAL.CustomCode.CollabSession; using CollabDAL.DTO.CollabEvent; using CollabDAL.DTO.CollabSession; using CollabSL.EndPoints.Hubs; using GB5Shared.DTO.Framework.Login; using Microsoft.AspNetCore.SignalR; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; namespace CollabSL.EndPoints.Services; /// /// Background service that runs every 5 minutes to: /// 1. Find sessions with no heartbeat for longer than the configured timeout /// 2. Mark them as Abandoned /// 3. Broadcast SessionEnded to any still-connected clients /// 4. Expire stale invite links /// public sealed class CollabSessionCleanupService( IServiceProvider _serviceProvider, ILogger _logger ) : BackgroundService { private static readonly TimeSpan _interval = TimeSpan.FromMinutes(5); protected override async Task ExecuteAsync(CancellationToken stoppingToken) { _logger.LogInformation("CollabSessionCleanupService started."); while (!stoppingToken.IsCancellationRequested) { await Task.Delay(_interval, stoppingToken).ConfigureAwait(false); try { await RunCleanupCycleAsync(stoppingToken).ConfigureAwait(false); } catch (Exception ex) when (ex is not OperationCanceledException) { _logger.LogError(ex, "CollabSessionCleanupService cycle failed."); } } _logger.LogInformation("CollabSessionCleanupService stopped."); } private async Task RunCleanupCycleAsync(CancellationToken ct) { using var scope = _serviceProvider.CreateScope(); var sp = scope.ServiceProvider; var sessionDal = sp.GetRequiredService(); var eventDal = sp.GetRequiredService(); var configDal = sp.GetRequiredService(); var hubContext = sp.GetRequiredService>(); // Use a system login for cross-tenant cleanup operations var systemLogin = new LoginDTO { DatabaseName = "master" }; // Default timeout if config unavailable const int defaultTimeoutMinutes = 30; int timeoutMinutes = defaultTimeoutMinutes; try { var config = await configDal.GetConfigAsync(systemLogin, ct).ConfigureAwait(false); if (config is not null) timeoutMinutes = config.SessionTimeoutMinutes; } catch (Exception ex) { _logger.LogWarning(ex, "Could not read CollabConfig; using default timeout of {Timeout} min.", defaultTimeoutMinutes); } List abandonedCandidates; try { abandonedCandidates = await sessionDal .GetAllAbandonedCandidatesAsync(timeoutMinutes, systemLogin, ct) .ConfigureAwait(false); } catch (Exception ex) { _logger.LogError(ex, "Failed to fetch abandoned session candidates."); return; } _logger.LogInformation("CollabSessionCleanupService found {Count} session(s) to abandon.", abandonedCandidates.Count); foreach (var session in abandonedCandidates) { // Per-session try/catch — one failure must not block the rest try { var tenantLogin = new LoginDTO { DatabaseName = session.DatabaseName }; await sessionDal.UpdateSessionStatusAsync( new UpdateSessionStatusSaveDTO { SessionId = session.SessionId, NewStatus = "Abandoned", }, tenantLogin, ct).ConfigureAwait(false); await eventDal.InsertEventAsync(new CollabSessionEventDTO { SessionId = session.SessionId, EventType = "SessionAbandoned", TriggeredBy = "system", DatabaseName = session.DatabaseName, }, tenantLogin, ct).ConfigureAwait(false); // Notify any still-connected clients await hubContext.Clients .Group($"collab:{session.SessionId}") .SessionEnded("Session timed out due to inactivity.") .ConfigureAwait(false); _logger.LogInformation("Session {SessionId} marked Abandoned.", session.SessionId); } catch (Exception ex) { _logger.LogError(ex, "Failed to abandon session {SessionId}.", session.SessionId); } } } }