From e1aeb3da3120174e8d5071ceded4a68dd3999fe1 Mon Sep 17 00:00:00 2001 From: Flaminel Date: Tue, 22 Jul 2025 12:24:38 +0300 Subject: [PATCH] Try #1 to fix memory leak (#241) --- .../DependencyInjection/ServicesDI.cs | 52 +++++++++---------- code/backend/Cleanuparr.Api/HostExtensions.cs | 7 ++- .../Jobs/BackgroundJobManager.cs | 16 +++--- .../backend/Cleanuparr.Api/Jobs/GenericJob.cs | 12 +++-- code/backend/Cleanuparr.Api/Program.cs | 28 +++++----- .../Events/EventCleanupService.cs | 8 +-- .../ContentBlocker/BlocklistProvider.cs | 9 ++-- .../DownloadClient/DownloadServiceFactory.cs | 7 +-- .../Health/HealthCheckService.cs | 20 +++---- .../Http/DynamicHttpClientProvider.cs | 9 ++-- .../DynamicHttpClientConfiguration.cs | 12 +++-- .../HttpClientConfigurationService.cs | 14 +++-- 12 files changed, 107 insertions(+), 87 deletions(-) diff --git a/code/backend/Cleanuparr.Api/DependencyInjection/ServicesDI.cs b/code/backend/Cleanuparr.Api/DependencyInjection/ServicesDI.cs index 1fbc727d..fa9580a1 100644 --- a/code/backend/Cleanuparr.Api/DependencyInjection/ServicesDI.cs +++ b/code/backend/Cleanuparr.Api/DependencyInjection/ServicesDI.cs @@ -25,34 +25,32 @@ public static class ServicesDI { public static IServiceCollection AddServices(this IServiceCollection services) => services - .AddSingleton() - .AddTransient() - .AddTransient() - .AddTransient() - .AddTransient() + .AddScoped() + .AddScoped() + .AddScoped() + .AddScoped() + .AddScoped() .AddHostedService() - // API services + .AddScoped() + .AddScoped() + .AddScoped() + .AddScoped() + .AddScoped() + .AddScoped() + .AddScoped() + .AddScoped() + .AddScoped() + .AddScoped() + .AddScoped() + .AddScoped() + .AddScoped() + .AddScoped() + .AddScoped() + .AddScoped() + .AddScoped() + .AddScoped() + .AddScoped() + .AddScoped() .AddSingleton() - // Core services - .AddTransient() - .AddTransient() - .AddTransient() - .AddTransient() - .AddTransient() - .AddTransient() - .AddTransient() - .AddTransient() - .AddTransient() - .AddTransient() - .AddTransient() - .AddTransient() - .AddTransient() - .AddTransient() - .AddTransient() - .AddTransient() - .AddTransient() - .AddTransient() - .AddTransient() - .AddTransient() .AddSingleton(); } \ No newline at end of file diff --git a/code/backend/Cleanuparr.Api/HostExtensions.cs b/code/backend/Cleanuparr.Api/HostExtensions.cs index f090f58e..411343e3 100644 --- a/code/backend/Cleanuparr.Api/HostExtensions.cs +++ b/code/backend/Cleanuparr.Api/HostExtensions.cs @@ -21,13 +21,16 @@ public static class HostExtensions logger.LogInformation("timezone: {tz}", TimeZoneInfo.Local.DisplayName); // Apply db migrations - var eventsContext = app.Services.GetRequiredService(); + var scopeFactory = app.Services.GetRequiredService(); + await using var scope = scopeFactory.CreateAsyncScope(); + + await using var eventsContext = scope.ServiceProvider.GetRequiredService(); if ((await eventsContext.Database.GetPendingMigrationsAsync()).Any()) { await eventsContext.Database.MigrateAsync(); } - var configContext = app.Services.GetRequiredService(); + await using var configContext = scope.ServiceProvider.GetRequiredService(); if ((await configContext.Database.GetPendingMigrationsAsync()).Any()) { await configContext.Database.MigrateAsync(); diff --git a/code/backend/Cleanuparr.Api/Jobs/BackgroundJobManager.cs b/code/backend/Cleanuparr.Api/Jobs/BackgroundJobManager.cs index 27e39a5a..8a30134a 100644 --- a/code/backend/Cleanuparr.Api/Jobs/BackgroundJobManager.cs +++ b/code/backend/Cleanuparr.Api/Jobs/BackgroundJobManager.cs @@ -22,18 +22,18 @@ namespace Cleanuparr.Api.Jobs; public class BackgroundJobManager : IHostedService { private readonly ISchedulerFactory _schedulerFactory; - private readonly DataContext _dataContext; + private readonly IServiceScopeFactory _scopeFactory; private readonly ILogger _logger; private IScheduler? _scheduler; public BackgroundJobManager( ISchedulerFactory schedulerFactory, - DataContext dataContext, + IServiceScopeFactory scopeFactory, ILogger logger ) { _schedulerFactory = schedulerFactory; - _dataContext = dataContext; + _scopeFactory = scopeFactory; _logger = logger; } @@ -86,14 +86,18 @@ public class BackgroundJobManager : IHostedService throw new InvalidOperationException("Scheduler not initialized"); } + // Use scoped DataContext to prevent memory leaks + await using var scope = _scopeFactory.CreateAsyncScope(); + await using var dataContext = scope.ServiceProvider.GetRequiredService(); + // Get configurations from db - QueueCleanerConfig queueCleanerConfig = await _dataContext.QueueCleanerConfigs + QueueCleanerConfig queueCleanerConfig = await dataContext.QueueCleanerConfigs .AsNoTracking() .FirstAsync(cancellationToken); - ContentBlockerConfig contentBlockerConfig = await _dataContext.ContentBlockerConfigs + ContentBlockerConfig contentBlockerConfig = await dataContext.ContentBlockerConfigs .AsNoTracking() .FirstAsync(cancellationToken); - DownloadCleanerConfig downloadCleanerConfig = await _dataContext.DownloadCleanerConfigs + DownloadCleanerConfig downloadCleanerConfig = await dataContext.DownloadCleanerConfigs .AsNoTracking() .FirstAsync(cancellationToken); diff --git a/code/backend/Cleanuparr.Api/Jobs/GenericJob.cs b/code/backend/Cleanuparr.Api/Jobs/GenericJob.cs index 5b30d0be..b04116be 100644 --- a/code/backend/Cleanuparr.Api/Jobs/GenericJob.cs +++ b/code/backend/Cleanuparr.Api/Jobs/GenericJob.cs @@ -9,12 +9,12 @@ public sealed class GenericJob : IJob where T : IHandler { private readonly ILogger> _logger; - private readonly T _handler; - - public GenericJob(ILogger> logger, T handler) + private readonly IServiceScopeFactory _scopeFactory; + + public GenericJob(ILogger> logger, IServiceScopeFactory scopeFactory) { _logger = logger; - _handler = handler; + _scopeFactory = scopeFactory; } public async Task Execute(IJobExecutionContext context) @@ -23,7 +23,9 @@ public sealed class GenericJob : IJob try { - await _handler.ExecuteAsync(); + await using var scope = _scopeFactory.CreateAsyncScope(); + var handler = scope.ServiceProvider.GetRequiredService(); + await handler.ExecuteAsync(); } catch (Exception ex) { diff --git a/code/backend/Cleanuparr.Api/Program.cs b/code/backend/Cleanuparr.Api/Program.cs index 6acaa61f..32a77dce 100644 --- a/code/backend/Cleanuparr.Api/Program.cs +++ b/code/backend/Cleanuparr.Api/Program.cs @@ -70,7 +70,7 @@ builder.Services.AddCors(options => // Register services needed for logging first builder.Services - .AddTransient() + .AddScoped() .AddSingleton(); // Add logging with proper service provider @@ -133,21 +133,25 @@ logger.LogInformation("Server configuration: PORT={port}, BASE_PATH={basePath}", await app.Init(); // Get LoggingConfigManager (will be created if not already registered) -var configManager = app.Services.GetRequiredService(); - -// Get the dynamic level switch for controlling log levels -var levelSwitch = configManager.GetLevelSwitch(); +var scopeFactory = app.Services.GetRequiredService(); +using (var scope = scopeFactory.CreateScope()) +{ + var configManager = scope.ServiceProvider.GetRequiredService(); + + // Get the dynamic level switch for controlling log levels + var levelSwitch = configManager.GetLevelSwitch(); -// Get the SignalRLogSink instance -var signalRSink = app.Services.GetRequiredService(); + // Get the SignalRLogSink instance + var signalRSink = app.Services.GetRequiredService(); -var logConfig = LoggingDI.GetDefaultLoggerConfiguration(); -logConfig.MinimumLevel.ControlledBy(levelSwitch); + var logConfig = LoggingDI.GetDefaultLoggerConfiguration(); + logConfig.MinimumLevel.ControlledBy(levelSwitch); -// Add to Serilog pipeline -logConfig.WriteTo.Sink(signalRSink); + // Add to Serilog pipeline + logConfig.WriteTo.Sink(signalRSink); -Log.Logger = logConfig.CreateLogger(); + Log.Logger = logConfig.CreateLogger(); +} // Configure health check endpoints before the API configuration app.MapHealthChecks("/health", new HealthCheckOptions diff --git a/code/backend/Cleanuparr.Infrastructure/Events/EventCleanupService.cs b/code/backend/Cleanuparr.Infrastructure/Events/EventCleanupService.cs index b03c8b44..105fbeff 100644 --- a/code/backend/Cleanuparr.Infrastructure/Events/EventCleanupService.cs +++ b/code/backend/Cleanuparr.Infrastructure/Events/EventCleanupService.cs @@ -11,15 +11,15 @@ namespace Cleanuparr.Infrastructure.Events; /// public class EventCleanupService : BackgroundService { - private readonly IServiceProvider _serviceProvider; private readonly ILogger _logger; + private readonly IServiceScopeFactory _scopeFactory; private readonly TimeSpan _cleanupInterval = TimeSpan.FromHours(4); // Run every 4 hours private readonly int _retentionDays = 30; // Keep events for 30 days - public EventCleanupService(IServiceProvider serviceProvider, ILogger logger) + public EventCleanupService(ILogger logger, IServiceScopeFactory scopeFactory) { - _serviceProvider = serviceProvider; _logger = logger; + _scopeFactory = scopeFactory; } protected override async Task ExecuteAsync(CancellationToken stoppingToken) @@ -58,7 +58,7 @@ public class EventCleanupService : BackgroundService { try { - using var scope = _serviceProvider.CreateScope(); + await using var scope = _scopeFactory.CreateAsyncScope(); var context = scope.ServiceProvider.GetRequiredService(); var cutoffDate = DateTime.UtcNow.AddDays(-_retentionDays); diff --git a/code/backend/Cleanuparr.Infrastructure/Features/ContentBlocker/BlocklistProvider.cs b/code/backend/Cleanuparr.Infrastructure/Features/ContentBlocker/BlocklistProvider.cs index 054026f8..1d7d9d62 100644 --- a/code/backend/Cleanuparr.Infrastructure/Features/ContentBlocker/BlocklistProvider.cs +++ b/code/backend/Cleanuparr.Infrastructure/Features/ContentBlocker/BlocklistProvider.cs @@ -19,7 +19,7 @@ namespace Cleanuparr.Infrastructure.Features.ContentBlocker; public sealed class BlocklistProvider { private readonly ILogger _logger; - private readonly IServiceProvider _serviceProvider; + private readonly IServiceScopeFactory _scopeFactory; private readonly HttpClient _httpClient; private readonly IMemoryCache _cache; private readonly Dictionary _configHashes = new(); @@ -28,13 +28,13 @@ public sealed class BlocklistProvider public BlocklistProvider( ILogger logger, - IServiceProvider serviceProvider, + IServiceScopeFactory scopeFactory, IMemoryCache cache, IHttpClientFactory httpClientFactory ) { _logger = logger; - _serviceProvider = serviceProvider; + _scopeFactory = scopeFactory; _cache = cache; _httpClient = httpClientFactory.CreateClient(Constants.HttpClientWithRetryName); } @@ -43,7 +43,8 @@ public sealed class BlocklistProvider { try { - var dataContext = _serviceProvider.GetRequiredService(); + await using var scope = _scopeFactory.CreateAsyncScope(); + await using var dataContext = scope.ServiceProvider.GetRequiredService(); int changedCount = 0; var contentBlockerConfig = await dataContext.ContentBlockerConfigs .AsNoTracking() diff --git a/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/DownloadServiceFactory.cs b/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/DownloadServiceFactory.cs index b01cea37..e2e5cfa7 100644 --- a/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/DownloadServiceFactory.cs +++ b/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/DownloadServiceFactory.cs @@ -20,12 +20,13 @@ namespace Cleanuparr.Infrastructure.Features.DownloadClient; /// public sealed class DownloadServiceFactory { - private readonly IServiceProvider _serviceProvider; private readonly ILogger _logger; + private readonly IServiceProvider _serviceProvider; public DownloadServiceFactory( - IServiceProvider serviceProvider, - ILogger logger) + ILogger logger, + IServiceProvider serviceProvider + ) { _serviceProvider = serviceProvider; _logger = logger; diff --git a/code/backend/Cleanuparr.Infrastructure/Health/HealthCheckService.cs b/code/backend/Cleanuparr.Infrastructure/Health/HealthCheckService.cs index 983c8c68..61aff583 100644 --- a/code/backend/Cleanuparr.Infrastructure/Health/HealthCheckService.cs +++ b/code/backend/Cleanuparr.Infrastructure/Health/HealthCheckService.cs @@ -1,4 +1,3 @@ -using Cleanuparr.Domain.Enums; using Cleanuparr.Infrastructure.Features.DownloadClient; using Cleanuparr.Persistence; using Microsoft.EntityFrameworkCore; @@ -13,9 +12,8 @@ namespace Cleanuparr.Infrastructure.Health; public class HealthCheckService : IHealthCheckService { private readonly ILogger _logger; - private readonly IServiceProvider _serviceProvider; - private readonly DownloadServiceFactory _downloadServiceFactory; private readonly Dictionary _healthStatuses = new(); + private readonly IServiceScopeFactory _scopeFactory; private readonly object _lockObject = new(); /// @@ -25,12 +23,11 @@ public class HealthCheckService : IHealthCheckService public HealthCheckService( ILogger logger, - IServiceProvider serviceProvider, - DownloadServiceFactory downloadServiceFactory) + IServiceScopeFactory scopeFactory + ) { _logger = logger; - _serviceProvider = serviceProvider; - _downloadServiceFactory = downloadServiceFactory; + _scopeFactory = scopeFactory; } /// @@ -40,7 +37,8 @@ public class HealthCheckService : IHealthCheckService try { - var dataContext = _serviceProvider.GetRequiredService(); + await using var scope = _scopeFactory.CreateAsyncScope(); + await using var dataContext = scope.ServiceProvider.GetRequiredService(); // Get the client configuration var downloadClientConfig = await dataContext.DownloadClients @@ -63,7 +61,8 @@ public class HealthCheckService : IHealthCheckService } // Get the client instance - var client = _downloadServiceFactory.GetDownloadService(downloadClientConfig); + var downloadServiceFactory = scope.ServiceProvider.GetRequiredService(); + var client = downloadServiceFactory.GetDownloadService(downloadClientConfig); // Execute the health check var healthResult = await client.HealthCheckAsync(); @@ -107,7 +106,8 @@ public class HealthCheckService : IHealthCheckService try { - var dataContext = _serviceProvider.GetRequiredService(); + await using var scope = _scopeFactory.CreateAsyncScope(); + await using var dataContext = scope.ServiceProvider.GetRequiredService(); // Get all enabled client configurations var enabledClients = await dataContext.DownloadClients diff --git a/code/backend/Cleanuparr.Infrastructure/Http/DynamicHttpClientProvider.cs b/code/backend/Cleanuparr.Infrastructure/Http/DynamicHttpClientProvider.cs index 1b1d5c57..75710143 100644 --- a/code/backend/Cleanuparr.Infrastructure/Http/DynamicHttpClientProvider.cs +++ b/code/backend/Cleanuparr.Infrastructure/Http/DynamicHttpClientProvider.cs @@ -13,16 +13,16 @@ namespace Cleanuparr.Infrastructure.Http; public class DynamicHttpClientProvider : IDynamicHttpClientProvider { private readonly ILogger _logger; - private readonly IServiceProvider _serviceProvider; + private readonly IServiceScopeFactory _scopeFactory; private readonly IDynamicHttpClientFactory _dynamicHttpClientFactory; public DynamicHttpClientProvider( ILogger logger, - IServiceProvider serviceProvider, + IServiceScopeFactory scopeFactory, IDynamicHttpClientFactory dynamicHttpClientFactory) { _logger = logger; - _serviceProvider = serviceProvider; + _scopeFactory = scopeFactory; _dynamicHttpClientFactory = dynamicHttpClientFactory; } @@ -49,7 +49,8 @@ public class DynamicHttpClientProvider : IDynamicHttpClientProvider /// A configured HttpClient instance private HttpClient CreateGenericClient(DownloadClientConfig downloadClientConfig) { - var dataContext = _serviceProvider.GetRequiredService(); + using var scope = _scopeFactory.CreateScope(); + using var dataContext = scope.ServiceProvider.GetRequiredService(); var httpConfig = dataContext.GeneralConfigs.First(); var clientName = GetClientName(downloadClientConfig); diff --git a/code/backend/Cleanuparr.Infrastructure/Http/DynamicHttpClientSystem/DynamicHttpClientConfiguration.cs b/code/backend/Cleanuparr.Infrastructure/Http/DynamicHttpClientSystem/DynamicHttpClientConfiguration.cs index 9925bcc8..59bc317a 100644 --- a/code/backend/Cleanuparr.Infrastructure/Http/DynamicHttpClientSystem/DynamicHttpClientConfiguration.cs +++ b/code/backend/Cleanuparr.Infrastructure/Http/DynamicHttpClientSystem/DynamicHttpClientConfiguration.cs @@ -13,16 +13,17 @@ namespace Cleanuparr.Infrastructure.Http.DynamicHttpClientSystem; /// public class DynamicHttpClientConfiguration : IConfigureNamedOptions { - private readonly IServiceProvider _serviceProvider; + private readonly IServiceScopeFactory _scopeFactory; - public DynamicHttpClientConfiguration(IServiceProvider serviceProvider) + public DynamicHttpClientConfiguration(IServiceScopeFactory scopeFactory) { - _serviceProvider = serviceProvider; + _scopeFactory = scopeFactory; } public void Configure(string name, HttpClientFactoryOptions options) { - var configStore = _serviceProvider.GetRequiredService(); + using var scope = _scopeFactory.CreateScope(); + var configStore = scope.ServiceProvider.GetRequiredService(); if (!configStore.TryGetConfiguration(name, out HttpClientConfig? config)) return; @@ -48,7 +49,8 @@ public class DynamicHttpClientConfiguration : IConfigureNamedOptions(); + using var scope = _scopeFactory.CreateScope(); + var certValidationService = scope.ServiceProvider.GetRequiredService(); switch (config.Type) { diff --git a/code/backend/Cleanuparr.Infrastructure/Http/DynamicHttpClientSystem/HttpClientConfigurationService.cs b/code/backend/Cleanuparr.Infrastructure/Http/DynamicHttpClientSystem/HttpClientConfigurationService.cs index b161251f..28c85cb9 100644 --- a/code/backend/Cleanuparr.Infrastructure/Http/DynamicHttpClientSystem/HttpClientConfigurationService.cs +++ b/code/backend/Cleanuparr.Infrastructure/Http/DynamicHttpClientSystem/HttpClientConfigurationService.cs @@ -1,6 +1,7 @@ using Cleanuparr.Persistence; using Cleanuparr.Shared.Helpers; using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; using DelugeService = Cleanuparr.Infrastructure.Features.DownloadClient.Deluge.DelugeService; @@ -13,24 +14,27 @@ namespace Cleanuparr.Infrastructure.Http.DynamicHttpClientSystem; public class HttpClientConfigurationService : IHostedService { private readonly IDynamicHttpClientFactory _clientFactory; - private readonly DataContext _dataContext; private readonly ILogger _logger; + private readonly IServiceScopeFactory _scopeFactory; public HttpClientConfigurationService( IDynamicHttpClientFactory clientFactory, - DataContext dataContext, - ILogger logger) + ILogger logger, + IServiceScopeFactory scopeFactory) { _clientFactory = clientFactory; - _dataContext = dataContext; _logger = logger; + _scopeFactory = scopeFactory; } public async Task StartAsync(CancellationToken cancellationToken) { try { - var config = await _dataContext.GeneralConfigs + await using var scope = _scopeFactory.CreateAsyncScope(); + await using var dataContext = scope.ServiceProvider.GetRequiredService(); + + var config = await dataContext.GeneralConfigs .AsNoTracking() .FirstAsync(cancellationToken);