added cleanup of old and dry run cycles

This commit is contained in:
Flaminel committed 2026-10-20 15:55:53 +03:00
1 parent 24b7f34e0c
commit af1823779a
6 files changed
+121 -3

No files matched your search

@@ -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<NoContentResult>();
(await _eventsContext.SeekerCycles.CountAsync(c => c.ArrInstanceId == instance.Id)).ShouldBe(0);
(await _eventsContext.SeekerCycles.CountAsync()).ShouldBe(1);
}
[Fact]
public async Task DeleteSonarrInstance_FailsSearchEventsThatAreStillInFlight()
{
@@ -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);
@@ -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<IServiceScopeFactory>(), 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<IServiceScopeFactory>(), 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()
{
@@ -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();
@@ -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
@@ -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();