using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using System.Collections.Concurrent;
using System.Threading;
namespace Microsoft.Extensions.DependencyInjection;
///
/// A background service that manages session-scoped hosted services.
///
/// The type of the hosted service.
public class SessionScopedFactory(
IServiceProvider serviceProvider,
IHttpContextAccessor httpContextAccessor,
ILoggerFactory loggerFactory) : BackgroundService where T : IHostedService
{
///
/// Logger for the session-scoped factory.
///
private readonly ILogger Logger = loggerFactory.CreateLogger>();
///
/// A dictionary of session-scoped services, keyed by session ID.
///
private readonly ConcurrentDictionary services = new();
///
/// A dictionary to track session timeouts.
///
private readonly ConcurrentDictionary timeout = new();
///
/// Creates an instance of the scoped service.
///
/// A session-scoped service.
private T GetScopedService()
{
using var scope = serviceProvider.CreateScope();
return scope.ServiceProvider.GetRequiredService();
}
///
/// Gets or creates the instance of the hosted service for the current session.
///
public T Instance
{
get
{
var sessionId = (httpContextAccessor.HttpContext?.Session?.Id) ??
throw new InvalidOperationException("Session ID is not available.");
timeout.AddOrUpdate(sessionId, DateTimeOffset.UtcNow,
(key, oldValue) => DateTimeOffset.UtcNow);
if (!services.TryGetValue(sessionId, out SessionScopedWrapper? wrapper))
{
var service = GetScopedService() ??
throw new InvalidOperationException("Cannot get scoped service.");
wrapper = new SessionScopedWrapper(service);
if (services.TryAdd(sessionId, wrapper))
{
wrapper.Start();
Logger.LogInformation("Added started service with ID: {sessionId}", sessionId);
}
}
return (T)wrapper.hostedService;
}
}
///
/// Removes a service associated with the given session ID.
///
/// The session ID to remove.
/// A task representing the asynchronous operation.
private async Task RemoveServiceAsync(string sessionId)
{
// Remove service if it exists
if (services.TryRemove(sessionId, out SessionScopedWrapper? service))
{
await service.StopAsync();
Logger.LogInformation("Removed stopped service with ID: {sessionId}", sessionId);
service = null; // Let the GC collect it
}
// Remove the timeout entry
timeout.TryRemove(sessionId, out _);
}
///
/// Stops all services and cleans up.
///
public override async Task StopAsync(CancellationToken cancellationToken)
{
foreach (var id in services.Keys.ToArray())
{
await RemoveServiceAsync(id);
}
}
///
/// Executes the background service and cleans up expired sessions.
///
/// The token to monitor for cancellation requests.
protected override async Task ExecuteAsync(CancellationToken cancellationToken)
{
Logger.LogInformation($"{nameof(SessionScopedFactory)} starting.");
var sessionOptions = serviceProvider.GetRequiredService>();
var sessionTimeout = sessionOptions.Value.IdleTimeout;
while (!cancellationToken.IsCancellationRequested)
{
try
{
// Non-Configurable interval duration (good sampling)
await Task.Delay(TimeSpan.FromSeconds(5), cancellationToken);
// Determine new threshold for expired items
var expirationThreshold = DateTimeOffset.UtcNow.Subtract(sessionTimeout);
var sessionIds = timeout.Where(x => x.Value < expirationThreshold).Select(x => x.Key).ToArray();
foreach (var sessionId in sessionIds)
{
await RemoveServiceAsync(sessionId);
}
}
catch (TaskCanceledException)
{
}
catch (Exception ex)
{
// Log errors during the loop
Logger.LogError(ex, "An error occurred during timeout cleanup.");
}
}
Logger.LogInformation($"{nameof(SessionScopedFactory)} has stopped.");
}
}