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