From af1823779a013e6bbce0d89a2d2cf6bd16d4bd43 Mon Sep 17 00:00:00 2001 From: Flaminel Date: Sat, 17 Oct 2026 21:07:09 +0300 Subject: [PATCH] added cleanup of old and dry run cycles --- .../Features/Arr/ArrConfigControllerTests.cs | 31 ++++++++++ .../Arr/Controllers/ArrConfigController.cs | 4 ++ .../Events/EventCleanupServiceTests.cs | 60 +++++++++++++++++++ .../Features/DryRun/DryRunPurgerTests.cs | 6 ++ .../Events/EventCleanupService.cs | 14 +++++ .../Features/DryRun/DryRunPurger.cs | 9 ++- 6 files changed, 121 insertions(+), 3 deletions(-) diff --git a/code/backend/Cleanuparr.Api.Tests/Features/Arr/ArrConfigControllerTests.cs b/code/backend/Cleanuparr.Api.Tests/Features/Arr/ArrConfigControllerTests.cs index b31f9380..9f847f40 100644 --- a/code/backend/Cleanuparr.Api.Tests/Features/Arr/ArrConfigControllerTests.cs +++ b/code/backend/Cleanuparr.Api.Tests/Features/Arr/ArrConfigControllerTests.cs @@ -9,6 +9,7 @@ using Cleanuparr.Api.Features.Arr.Contracts.Responses; using Cleanuparr.Infrastructure.Features.Arr.Interfaces; using Cleanuparr.Persistence; using Cleanuparr.Persistence.Models.Configuration.Arr; +using Cleanuparr.Persistence.Models.State; using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; @@ -278,6 +279,36 @@ public class ArrConfigControllerTests : IDisposable (await _dataContext.ArrInstances.CountAsync(i => i.Id == instance.Id)).ShouldBe(0); } + [Fact] + public async Task DeleteSonarrInstance_Existing_RemovesSeekerCycles() + { + // Arrange + var sonarr = await _dataContext.ArrConfigs.FirstAsync(c => c.Type == InstanceType.Sonarr); + var instance = new ArrInstance + { + Name = "doomed", + Url = new Uri("http://doomed:8989"), + ApiKey = "k", + ArrConfigId = sonarr.Id, + Enabled = true, + }; + _dataContext.ArrInstances.Add(instance); + await _dataContext.SaveChangesAsync(); + + _eventsContext.SeekerCycles.AddRange( + new SeekerCycle { ArrInstanceId = instance.Id }, + new SeekerCycle { ArrInstanceId = Guid.NewGuid() }); + await _eventsContext.SaveChangesAsync(); + + // Act + var result = await _controller.DeleteSonarrInstance(instance.Id); + + // Assert + result.ShouldBeOfType(); + (await _eventsContext.SeekerCycles.CountAsync(c => c.ArrInstanceId == instance.Id)).ShouldBe(0); + (await _eventsContext.SeekerCycles.CountAsync()).ShouldBe(1); + } + [Fact] public async Task DeleteSonarrInstance_FailsSearchEventsThatAreStillInFlight() { diff --git a/code/backend/Cleanuparr.Api/Features/Arr/Controllers/ArrConfigController.cs b/code/backend/Cleanuparr.Api/Features/Arr/Controllers/ArrConfigController.cs index 07491e58..5293237d 100644 --- a/code/backend/Cleanuparr.Api/Features/Arr/Controllers/ArrConfigController.cs +++ b/code/backend/Cleanuparr.Api/Features/Arr/Controllers/ArrConfigController.cs @@ -350,6 +350,10 @@ public sealed class ArrConfigController : ControllerBase .Where(e => e.ArrInstanceId == arrInstanceId) .ExecuteDeleteAsync(cancellationToken); + await _eventsContext.SeekerCycles + .Where(e => e.ArrInstanceId == arrInstanceId) + .ExecuteDeleteAsync(cancellationToken); + await _eventsContext.SearchQueue .Where(e => e.ArrInstanceId == arrInstanceId) .ExecuteDeleteAsync(cancellationToken); diff --git a/code/backend/Cleanuparr.Infrastructure.Tests/Events/EventCleanupServiceTests.cs b/code/backend/Cleanuparr.Infrastructure.Tests/Events/EventCleanupServiceTests.cs index f5b945c6..3404c303 100644 --- a/code/backend/Cleanuparr.Infrastructure.Tests/Events/EventCleanupServiceTests.cs +++ b/code/backend/Cleanuparr.Infrastructure.Tests/Events/EventCleanupServiceTests.cs @@ -1,10 +1,12 @@ using Cleanuparr.Domain.Enums; using Cleanuparr.Infrastructure.Events; using Cleanuparr.Infrastructure.Features.DryRun; +using Cleanuparr.Infrastructure.Tests.Features.Jobs.TestHelpers; using Cleanuparr.Infrastructure.Tests.TestHelpers; using Cleanuparr.Persistence; using Cleanuparr.Persistence.Models.Configuration.General; using Cleanuparr.Persistence.Models.Events; +using Cleanuparr.Persistence.Models.State; using Microsoft.Data.Sqlite; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; @@ -189,6 +191,64 @@ public class EventCleanupServiceTests : IDisposable delay.ShouldBe(TimeSpan.FromSeconds(expectedSeconds)); } + [Fact] + public async Task PruneSeekerCyclesAsync_DeletesCompletedCyclesOlderThanRetention() + { + // Arrange + using EventsContext eventsContext = TestDataContextFactory.CreateEvents(); + FakeTimeProvider timeProvider = new(); + EventCleanupService service = new EventCleanupService(_logger, _serviceProvider.GetRequiredService(), timeProvider, new DryRunActivity()); + + Guid oldCompletedId = Guid.NewGuid(); + eventsContext.SeekerCycles.Add(new SeekerCycle + { + Id = oldCompletedId, + StartedAt = timeProvider.GetUtcNow().AddDays(-40), + CompletedAt = timeProvider.GetUtcNow().AddDays(-31), + CompletionReason = SeekerCycleCompletionReason.Completed, + }); + await eventsContext.SaveChangesAsync(); + + // Act + await service.PruneSeekerCyclesAsync(eventsContext, retentionDays: 30); + + // Assert + (await eventsContext.SeekerCycles.CountAsync(c => c.Id == oldCompletedId)).ShouldBe(0); + } + + [Fact] + public async Task PruneSeekerCyclesAsync_KeepsOpenAndRecentCycles() + { + // Arrange + using EventsContext eventsContext = TestDataContextFactory.CreateEvents(); + FakeTimeProvider timeProvider = new(); + EventCleanupService service = new EventCleanupService(_logger, _serviceProvider.GetRequiredService(), timeProvider, new DryRunActivity()); + + Guid openCycleId = Guid.NewGuid(); + Guid recentCompletedId = Guid.NewGuid(); + eventsContext.SeekerCycles.AddRange( + new SeekerCycle + { + Id = openCycleId, + StartedAt = timeProvider.GetUtcNow().AddDays(-60), + CompletedAt = null, + }, + new SeekerCycle + { + Id = recentCompletedId, + StartedAt = timeProvider.GetUtcNow().AddDays(-5), + CompletedAt = timeProvider.GetUtcNow().AddDays(-1), + CompletionReason = SeekerCycleCompletionReason.Completed, + }); + await eventsContext.SaveChangesAsync(); + + // Act + await service.PruneSeekerCyclesAsync(eventsContext, retentionDays: 30); + + // Assert + (await eventsContext.SeekerCycles.CountAsync()).ShouldBe(2); + } + [Fact] public async Task WaitForNextRunAsync_DelayElapses_Completes() { diff --git a/code/backend/Cleanuparr.Infrastructure.Tests/Features/DryRun/DryRunPurgerTests.cs b/code/backend/Cleanuparr.Infrastructure.Tests/Features/DryRun/DryRunPurgerTests.cs index 31f4ab9f..2dba8063 100644 --- a/code/backend/Cleanuparr.Infrastructure.Tests/Features/DryRun/DryRunPurgerTests.cs +++ b/code/backend/Cleanuparr.Infrastructure.Tests/Features/DryRun/DryRunPurgerTests.cs @@ -83,6 +83,10 @@ public sealed class DryRunPurgerTests : IDisposable new SeekerHistory { ItemTitle = "dry run history", IsDryRun = true, CycleId = Guid.NewGuid() }, new SeekerHistory { ItemTitle = "real history", IsDryRun = false, CycleId = Guid.NewGuid() }); + _eventsContext.SeekerCycles.AddRange( + new SeekerCycle { IsDryRun = true }, + new SeekerCycle { IsDryRun = false }); + await _eventsContext.SaveChangesAsync(); // Act @@ -93,6 +97,8 @@ public sealed class DryRunPurgerTests : IDisposable (await _eventsContext.Strikes.CountAsync(x => x.IsDryRun)).ShouldBe(0); (await _eventsContext.SeekerHistory.CountAsync(x => x.IsDryRun)).ShouldBe(0); (await _eventsContext.SeekerHistory.CountAsync()).ShouldBe(1); + (await _eventsContext.SeekerCycles.CountAsync(x => x.IsDryRun)).ShouldBe(0); + (await _eventsContext.SeekerCycles.CountAsync()).ShouldBe(1); DownloadItem touched = await _eventsContext.DownloadItems.AsNoTracking().FirstAsync(x => x.DownloadId == "touched-by-dry-run"); touched.IsMarkedForRemoval.ShouldBeTrue(); diff --git a/code/backend/Cleanuparr.Infrastructure/Events/EventCleanupService.cs b/code/backend/Cleanuparr.Infrastructure/Events/EventCleanupService.cs index eef4fd25..d5b1384d 100644 --- a/code/backend/Cleanuparr.Infrastructure/Events/EventCleanupService.cs +++ b/code/backend/Cleanuparr.Infrastructure/Events/EventCleanupService.cs @@ -177,6 +177,7 @@ public class EventCleanupService : BackgroundService // Prune events older than the configured retention window await PruneEventsAsync(eventsContext, config.HistoryRetentionDays); + await PruneSeekerCyclesAsync(eventsContext, config.HistoryRetentionDays); await CleanupStrikesAsync(eventsContext, config.StrikeInactivityWindowHours); @@ -217,6 +218,19 @@ public class EventCleanupService : BackgroundService } } + internal async Task PruneSeekerCyclesAsync(EventsContext eventsContext, ushort retentionDays) + { + DateTimeOffset cutoff = _timeProvider.GetUtcNow().AddDays(-retentionDays); + int deleted = await eventsContext.SeekerCycles + .Where(c => c.CompletedAt != null && c.CompletedAt < cutoff) + .ExecuteDeleteAsync(); + + if (deleted > 0) + { + _logger.LogInformation("Pruned {Count} search cycles older than {Days} days", deleted, retentionDays); + } + } + internal async Task PruneJobRunsAsync(EventsContext eventsContext, DateTimeOffset cutoff) { int deleted = await eventsContext.JobRuns diff --git a/code/backend/Cleanuparr.Infrastructure/Features/DryRun/DryRunPurger.cs b/code/backend/Cleanuparr.Infrastructure/Features/DryRun/DryRunPurger.cs index 21ee5878..251e44d8 100644 --- a/code/backend/Cleanuparr.Infrastructure/Features/DryRun/DryRunPurger.cs +++ b/code/backend/Cleanuparr.Infrastructure/Features/DryRun/DryRunPurger.cs @@ -47,12 +47,15 @@ public sealed class DryRunPurger : IDryRunPurger int deletedHistory = await _eventsContext.SeekerHistory .Where(h => h.IsDryRun) .ExecuteDeleteAsync(); + int deletedCycles = await _eventsContext.SeekerCycles + .Where(c => c.IsDryRun) + .ExecuteDeleteAsync(); - if (deletedStrikes + deletedEvents + deletedManualEvents + deletedItems + deletedHistory > 0) + if (deletedStrikes + deletedEvents + deletedManualEvents + deletedItems + deletedHistory + deletedCycles > 0) { _logger.LogWarning( - "Purged dry-run data: {Strikes} strikes, {Events} events, {ManualEvents} manual events, {Items} orphaned download items, {History} search history entries removed", - deletedStrikes, deletedEvents, deletedManualEvents, deletedItems, deletedHistory); + "Purged dry-run data: {Strikes} strikes, {Events} events, {ManualEvents} manual events, {Items} orphaned download items, {History} search history entries, {Cycles} search cycles removed", + deletedStrikes, deletedEvents, deletedManualEvents, deletedItems, deletedHistory, deletedCycles); } await transaction.CommitAsync();