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