From 539124647654812a181b9750f082da7226b2fa85 Mon Sep 17 00:00:00 2001 From: Flaminel Date: Tue, 20 Oct 2026 22:48:17 +0300 Subject: [PATCH] added seeker cycle reset endpoint --- .../Seeker/SeekerCycleControllerTests.cs | 231 ++++++++++++ .../DependencyInjection/ServicesDI.cs | 2 + .../Controllers/SeekerCycleController.cs | 113 ++++++ .../Enums/SeekerCycleCompletionReason.cs | 5 + .../Integration/SeekerIntegrationTests.cs | 4 +- .../Features/Jobs/SeekerTests.cs | 354 +++++++++++++++++- .../Features/Jobs/Seeker.cs | 22 +- .../Features/Seeker/SeekerCycleLock.cs | 41 ++ 8 files changed, 769 insertions(+), 3 deletions(-) create mode 100644 code/backend/Cleanuparr.Api.Tests/Features/Seeker/SeekerCycleControllerTests.cs create mode 100644 code/backend/Cleanuparr.Api/Features/Seeker/Controllers/SeekerCycleController.cs create mode 100644 code/backend/Cleanuparr.Infrastructure/Features/Seeker/SeekerCycleLock.cs diff --git a/code/backend/Cleanuparr.Api.Tests/Features/Seeker/SeekerCycleControllerTests.cs b/code/backend/Cleanuparr.Api.Tests/Features/Seeker/SeekerCycleControllerTests.cs new file mode 100644 index 00000000..7c29dd9c --- /dev/null +++ b/code/backend/Cleanuparr.Api.Tests/Features/Seeker/SeekerCycleControllerTests.cs @@ -0,0 +1,231 @@ +using Cleanuparr.Api.Features.Seeker.Controllers; +using Cleanuparr.Api.Tests.Features.Seeker.TestHelpers; +using Cleanuparr.Api.Tests.TestHelpers; +using Cleanuparr.Domain.Enums; +using Cleanuparr.Infrastructure.Features.Seeker; +using Cleanuparr.Persistence; +using Cleanuparr.Persistence.Models.Configuration.Arr; +using Cleanuparr.Persistence.Models.Configuration.Seeker; +using Cleanuparr.Persistence.Models.State; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Time.Testing; +using Shouldly; + +namespace Cleanuparr.Api.Tests.Features.Seeker; + +public class SeekerCycleControllerTests : IDisposable +{ + private static readonly DateTimeOffset Now = new(2026, 1, 1, 12, 0, 0, TimeSpan.Zero); + + private readonly DataContext _dataContext; + private readonly EventsContext _eventsContext; + private readonly SeekerCycleLock _cycleLock = new(); + private readonly SeekerCycleController _controller; + + public SeekerCycleControllerTests() + { + _dataContext = SeekerTestDataFactory.CreateDataContext(); + _eventsContext = SeekerTestDataFactory.CreateEventsContext(); + _controller = new SeekerCycleController( + NullLogger.Instance, + _dataContext, + _eventsContext, + new FakeTimeProvider(Now), + _cycleLock); + ConfigControllerTestDataFactory.ConfigureProblemDetails(_controller); + } + + public void Dispose() + { + _dataContext.Dispose(); + _eventsContext.Dispose(); + GC.SuppressFinalize(this); + } + + [Fact] + public async Task ResetCycle_OpenCycle_ClosesItAsResetAndMakesTheNewCycleCurrent() + { + ArrInstance radarr = SeekerTestDataFactory.AddRadarrInstance(_dataContext); + Guid oldCycleId = Guid.NewGuid(); + AddInstanceConfig(radarr.Id, oldCycleId); + AddCycle(oldCycleId, radarr.Id, startedAt: Now.AddDays(-2), searchedUnits: 3, eligibleNow: 10); + + IActionResult result = await _controller.ResetCycle(radarr.Id); + + result.ShouldBeOfType(); + SeekerCycle old = await _eventsContext.SeekerCycles.AsNoTracking().SingleAsync(c => c.Id == oldCycleId); + old.CompletedAt.ShouldBe(Now); + old.CompletionReason.ShouldBe(SeekerCycleCompletionReason.Reset); + + SeekerInstanceConfig config = await _dataContext.SeekerInstanceConfigs.AsNoTracking().SingleAsync(); + config.CurrentCycleId.ShouldNotBe(oldCycleId); + + SeekerCycle next = await _eventsContext.SeekerCycles.AsNoTracking().SingleAsync(c => c.Id == config.CurrentCycleId); + next.ArrInstanceId.ShouldBe(radarr.Id); + next.StartedAt.ShouldBe(Now); + next.CompletedAt.ShouldBeNull(); + next.SearchedUnits.ShouldBe(0); + next.EligibleAtStart.ShouldBe(10); + next.EligibleNow.ShouldBe(10); + next.RemainingUnits.ShouldBe(10); + next.IsDryRun.ShouldBeFalse(); + } + + [Fact] + public async Task ResetCycle_CycleWaitingOnMinCycleTime_KeepsItsCompletedReason() + { + ArrInstance radarr = SeekerTestDataFactory.AddRadarrInstance(_dataContext); + Guid oldCycleId = Guid.NewGuid(); + AddInstanceConfig(radarr.Id, oldCycleId); + AddCycle(oldCycleId, radarr.Id, startedAt: Now.AddDays(-2), searchedUnits: 3, completedAt: Now.AddDays(-1)); + + IActionResult result = await _controller.ResetCycle(radarr.Id); + + result.ShouldBeOfType(); + SeekerCycle old = await _eventsContext.SeekerCycles.AsNoTracking().SingleAsync(c => c.Id == oldCycleId); + old.CompletedAt.ShouldBe(Now.AddDays(-1)); + old.CompletionReason.ShouldBe(SeekerCycleCompletionReason.Completed); + + SeekerInstanceConfig config = await _dataContext.SeekerInstanceConfigs.AsNoTracking().SingleAsync(); + config.CurrentCycleId.ShouldNotBe(oldCycleId); + (await _eventsContext.SeekerCycles.CountAsync()).ShouldBe(2); + } + + [Fact] + public async Task ResetCycle_NothingSearchedYet_ReturnsConflictAndChangesNothing() + { + ArrInstance radarr = SeekerTestDataFactory.AddRadarrInstance(_dataContext); + Guid cycleId = Guid.NewGuid(); + AddInstanceConfig(radarr.Id, cycleId); + AddCycle(cycleId, radarr.Id, startedAt: Now.AddDays(-2), searchedUnits: 0); + + IActionResult result = await _controller.ResetCycle(radarr.Id); + + result.ShouldBeOfType().StatusCode.ShouldBe(StatusCodes.Status409Conflict); + (await _eventsContext.SeekerCycles.CountAsync()).ShouldBe(1); + (await _dataContext.SeekerInstanceConfigs.AsNoTracking().SingleAsync()).CurrentCycleId.ShouldBe(cycleId); + } + + [Fact] + public async Task ResetCycle_NoCycleRow_ReturnsConflict() + { + ArrInstance radarr = SeekerTestDataFactory.AddRadarrInstance(_dataContext); + AddInstanceConfig(radarr.Id, Guid.NewGuid()); + + IActionResult result = await _controller.ResetCycle(radarr.Id); + + result.ShouldBeOfType().StatusCode.ShouldBe(StatusCodes.Status409Conflict); + (await _eventsContext.SeekerCycles.CountAsync()).ShouldBe(0); + } + + [Fact] + public async Task ResetCycle_SeekerProcessingInstance_ReturnsConflictAndChangesNothing() + { + ArrInstance radarr = SeekerTestDataFactory.AddRadarrInstance(_dataContext); + Guid cycleId = Guid.NewGuid(); + AddInstanceConfig(radarr.Id, cycleId); + AddCycle(cycleId, radarr.Id, startedAt: Now.AddDays(-2), searchedUnits: 3); + _cycleLock.TryEnter(radarr.Id).ShouldBeTrue(); + + IActionResult result = await _controller.ResetCycle(radarr.Id); + + result.ShouldBeOfType().StatusCode.ShouldBe(StatusCodes.Status409Conflict); + (await _eventsContext.SeekerCycles.AsNoTracking().SingleAsync()).CompletedAt.ShouldBeNull(); + (await _dataContext.SeekerInstanceConfigs.AsNoTracking().SingleAsync()).CurrentCycleId.ShouldBe(cycleId); + } + + [Fact] + public async Task ResetCycle_AfterReset_ReleasesTheLock() + { + ArrInstance radarr = SeekerTestDataFactory.AddRadarrInstance(_dataContext); + Guid cycleId = Guid.NewGuid(); + AddInstanceConfig(radarr.Id, cycleId); + AddCycle(cycleId, radarr.Id, startedAt: Now.AddDays(-2), searchedUnits: 3); + + await _controller.ResetCycle(radarr.Id); + + _cycleLock.TryEnter(radarr.Id).ShouldBeTrue(); + } + + [Fact] + public async Task ResetCycle_UnknownInstance_ReturnsNotFound() + { + IActionResult result = await _controller.ResetCycle(Guid.NewGuid()); + + result.ShouldBeOfType().StatusCode.ShouldBe(StatusCodes.Status404NotFound); + } + + [Fact] + public async Task ResetCycle_DryRunOn_ResetsTheNewestDryCycleAndLeavesTheLiveCycleAlone() + { + ArrInstance radarr = SeekerTestDataFactory.AddRadarrInstance(_dataContext); + (await _dataContext.GeneralConfigs.FirstAsync()).DryRun = true; + await _dataContext.SaveChangesAsync(); + + Guid liveCycleId = Guid.NewGuid(); + AddInstanceConfig(radarr.Id, liveCycleId); + AddCycle(liveCycleId, radarr.Id, startedAt: Now.AddDays(-5), searchedUnits: 4); + + Guid olderDryCycleId = Guid.NewGuid(); + Guid dryCycleId = Guid.NewGuid(); + AddCycle(olderDryCycleId, radarr.Id, startedAt: Now.AddDays(-9), searchedUnits: 2, completedAt: Now.AddDays(-8), isDryRun: true); + AddCycle(dryCycleId, radarr.Id, startedAt: Now.AddDays(-1), searchedUnits: 2, eligibleNow: 6, isDryRun: true); + + IActionResult result = await _controller.ResetCycle(radarr.Id); + + result.ShouldBeOfType(); + SeekerCycle dry = await _eventsContext.SeekerCycles.AsNoTracking().SingleAsync(c => c.Id == dryCycleId); + dry.CompletionReason.ShouldBe(SeekerCycleCompletionReason.Reset); + + SeekerCycle live = await _eventsContext.SeekerCycles.AsNoTracking().SingleAsync(c => c.Id == liveCycleId); + live.CompletedAt.ShouldBeNull(); + (await _dataContext.SeekerInstanceConfigs.AsNoTracking().SingleAsync()).CurrentCycleId.ShouldBe(liveCycleId); + + SeekerCycle next = await _eventsContext.SeekerCycles.AsNoTracking() + .Where(c => c.IsDryRun) + .OrderByDescending(c => c.StartedAt) + .FirstAsync(); + next.Id.ShouldNotBe(dryCycleId); + next.StartedAt.ShouldBe(Now); + next.EligibleNow.ShouldBe(6); + } + + private void AddInstanceConfig(Guid arrInstanceId, Guid currentCycleId) + { + _dataContext.SeekerInstanceConfigs.Add(new SeekerInstanceConfig + { + ArrInstanceId = arrInstanceId, + Enabled = true, + CurrentCycleId = currentCycleId, + }); + _dataContext.SaveChanges(); + } + + private void AddCycle( + Guid id, + Guid arrInstanceId, + DateTimeOffset startedAt, + int searchedUnits, + int eligibleNow = 5, + DateTimeOffset? completedAt = null, + bool isDryRun = false) + { + _eventsContext.SeekerCycles.Add(new SeekerCycle + { + Id = id, + ArrInstanceId = arrInstanceId, + StartedAt = startedAt, + CompletedAt = completedAt, + CompletionReason = completedAt is null ? null : SeekerCycleCompletionReason.Completed, + EligibleAtStart = eligibleNow, + EligibleNow = eligibleNow, + RemainingUnits = completedAt is null ? eligibleNow - searchedUnits : 0, + SearchedUnits = searchedUnits, + IsDryRun = isDryRun, + }); + _eventsContext.SaveChanges(); + } +} diff --git a/code/backend/Cleanuparr.Api/DependencyInjection/ServicesDI.cs b/code/backend/Cleanuparr.Api/DependencyInjection/ServicesDI.cs index 5e5b9888..78b4861d 100644 --- a/code/backend/Cleanuparr.Api/DependencyInjection/ServicesDI.cs +++ b/code/backend/Cleanuparr.Api/DependencyInjection/ServicesDI.cs @@ -17,6 +17,7 @@ using Cleanuparr.Infrastructure.Features.Files; using Cleanuparr.Infrastructure.Features.ItemStriker; using Cleanuparr.Infrastructure.Features.Jobs; using Cleanuparr.Infrastructure.Features.MalwareBlocker; +using Cleanuparr.Infrastructure.Features.Seeker; using Cleanuparr.Infrastructure.Helpers; using Cleanuparr.Infrastructure.Interceptors; using Cleanuparr.Infrastructure.Services; @@ -47,6 +48,7 @@ public static class ServicesDI .AddScoped() .AddScoped() .AddSingleton() + .AddSingleton() .AddScoped() .AddScoped() .AddScoped() diff --git a/code/backend/Cleanuparr.Api/Features/Seeker/Controllers/SeekerCycleController.cs b/code/backend/Cleanuparr.Api/Features/Seeker/Controllers/SeekerCycleController.cs new file mode 100644 index 00000000..f8b5209c --- /dev/null +++ b/code/backend/Cleanuparr.Api/Features/Seeker/Controllers/SeekerCycleController.cs @@ -0,0 +1,113 @@ +using Cleanuparr.Api.Extensions; +using Cleanuparr.Domain.Enums; +using Cleanuparr.Infrastructure.Features.Seeker; +using Cleanuparr.Persistence; +using Cleanuparr.Persistence.Models.Configuration.Seeker; +using Cleanuparr.Persistence.Models.State; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; + +namespace Cleanuparr.Api.Features.Seeker.Controllers; + +[ApiController] +[Route("api/seeker/cycles")] +[Authorize] +public sealed class SeekerCycleController : ControllerBase +{ + private readonly ILogger _logger; + private readonly DataContext _dataContext; + private readonly EventsContext _eventsContext; + private readonly TimeProvider _timeProvider; + private readonly SeekerCycleLock _cycleLock; + + public SeekerCycleController( + ILogger logger, + DataContext dataContext, + EventsContext eventsContext, + TimeProvider timeProvider, + SeekerCycleLock cycleLock) + { + _logger = logger; + _dataContext = dataContext; + _eventsContext = eventsContext; + _timeProvider = timeProvider; + _cycleLock = cycleLock; + } + + /// + /// Closes the instance's current cycle and starts a new one, skipping the minimum cycle time once. + /// Dry run resets its own cycle and leaves the live one alone. + /// + [HttpPost("{arrInstanceId:guid}/reset")] + public async Task ResetCycle(Guid arrInstanceId) + { + if (!_cycleLock.TryEnter(arrInstanceId)) + { + return this.ProblemResult(StatusCodes.Status409Conflict, "The Seeker is searching this instance, try again in a moment"); + } + + try + { + SeekerInstanceConfig? instanceConfig = await _dataContext.SeekerInstanceConfigs + .Include(s => s.ArrInstance) + .FirstOrDefaultAsync(s => s.ArrInstanceId == arrInstanceId); + + if (instanceConfig is null) + { + return this.ProblemResult(StatusCodes.Status404NotFound, $"Seeker instance with ID {arrInstanceId} not found"); + } + + bool isDryRun = (await _dataContext.GeneralConfigs.AsNoTracking().FirstAsync()).DryRun; + SeekerCycle? cycle = isDryRun + ? await _eventsContext.SeekerCycles + .Where(c => c.ArrInstanceId == arrInstanceId && c.IsDryRun) + .OrderByDescending(c => c.StartedAt) + .FirstOrDefaultAsync() + : await _eventsContext.SeekerCycles + .FirstOrDefaultAsync(c => c.Id == instanceConfig.CurrentCycleId && !c.IsDryRun); + + if (cycle is null || cycle.SearchedUnits == 0) + { + return this.ProblemResult(StatusCodes.Status409Conflict, "The current cycle has not searched anything yet"); + } + + DateTimeOffset now = _timeProvider.GetUtcNow(); + + // A cycle waiting out the minimum cycle time already finished, so it keeps its reason. + if (cycle.CompletedAt is null) + { + cycle.CompletedAt = now; + cycle.CompletionReason = SeekerCycleCompletionReason.Reset; + } + + SeekerCycle next = new() + { + ArrInstanceId = arrInstanceId, + StartedAt = now, + EligibleAtStart = cycle.EligibleNow, + EligibleNow = cycle.EligibleNow, + RemainingUnits = cycle.EligibleNow, + IsDryRun = isDryRun, + }; + _eventsContext.SeekerCycles.Add(next); + + if (!isDryRun) + { + instanceConfig.CurrentCycleId = next.Id; + await _dataContext.SaveChangesAsync(); + } + + await _eventsContext.SaveChangesAsync(); + + _logger.LogInformation("Cycle reset on {InstanceName} after {Count} searches", + instanceConfig.ArrInstance.Name, cycle.SearchedUnits); + + return NoContent(); + } + finally + { + _cycleLock.Release(arrInstanceId); + } + } +} diff --git a/code/backend/Cleanuparr.Domain/Enums/SeekerCycleCompletionReason.cs b/code/backend/Cleanuparr.Domain/Enums/SeekerCycleCompletionReason.cs index dd0d5a94..ec6ad6e8 100644 --- a/code/backend/Cleanuparr.Domain/Enums/SeekerCycleCompletionReason.cs +++ b/code/backend/Cleanuparr.Domain/Enums/SeekerCycleCompletionReason.cs @@ -12,4 +12,9 @@ public enum SeekerCycleCompletionReason /// Every eligible unit was searched /// Completed, + + /// + /// The user started a new cycle before this one finished + /// + Reset, } diff --git a/code/backend/Cleanuparr.Infrastructure.Tests/Features/Jobs/Integration/SeekerIntegrationTests.cs b/code/backend/Cleanuparr.Infrastructure.Tests/Features/Jobs/Integration/SeekerIntegrationTests.cs index bb886901..e5a7b187 100644 --- a/code/backend/Cleanuparr.Infrastructure.Tests/Features/Jobs/Integration/SeekerIntegrationTests.cs +++ b/code/backend/Cleanuparr.Infrastructure.Tests/Features/Jobs/Integration/SeekerIntegrationTests.cs @@ -2,6 +2,7 @@ using Cleanuparr.Domain.Entities.Arr; using Cleanuparr.Domain.Enums; using Cleanuparr.Infrastructure.Features.Arr.Interfaces; using Cleanuparr.Infrastructure.Features.ItemStriker; +using Cleanuparr.Infrastructure.Features.Seeker; using Cleanuparr.Infrastructure.Tests.Features.Jobs.TestHelpers; using Cleanuparr.Persistence.Models.State; using Microsoft.Extensions.Hosting; @@ -47,7 +48,8 @@ public class SeekerIntegrationTests : IDisposable _fixture.DryRunInterceptor, environment, _fixture.TimeProvider, - _fixture.StatusNotifier); + _fixture.StatusNotifier, + new SeekerCycleLock()); } [Fact] diff --git a/code/backend/Cleanuparr.Infrastructure.Tests/Features/Jobs/SeekerTests.cs b/code/backend/Cleanuparr.Infrastructure.Tests/Features/Jobs/SeekerTests.cs index 48957c54..89b8614e 100644 --- a/code/backend/Cleanuparr.Infrastructure.Tests/Features/Jobs/SeekerTests.cs +++ b/code/backend/Cleanuparr.Infrastructure.Tests/Features/Jobs/SeekerTests.cs @@ -4,6 +4,7 @@ using Cleanuparr.Domain.Enums; using Cleanuparr.Infrastructure.Events.Interfaces; using Cleanuparr.Infrastructure.Features.Arr.Interfaces; using Cleanuparr.Infrastructure.Features.Context; +using Cleanuparr.Infrastructure.Features.Seeker; using Cleanuparr.Infrastructure.Interceptors; using Cleanuparr.Infrastructure.Tests.Features.Jobs.TestHelpers; using Cleanuparr.Infrastructure.Tests.TestHelpers; @@ -32,6 +33,7 @@ public class SeekerTests : IDisposable private readonly IDryRunInterceptor _dryRunInterceptor; private readonly IHostEnvironment _hostingEnvironment; private readonly IStatusNotifier _statusNotifier; + private readonly SeekerCycleLock _cycleLock = new(); public SeekerTests(JobHandlerFixture fixture) { @@ -114,7 +116,8 @@ public class SeekerTests : IDisposable _dryRunInterceptor, _hostingEnvironment, _fixture.TimeProvider, - _statusNotifier + _statusNotifier, + _cycleLock ); } @@ -1927,6 +1930,260 @@ public class SeekerTests : IDisposable instanceConfig.CurrentCycleId.ShouldNotBe(currentCycleId); } + [Fact] + public async Task ExecuteAsync_Radarr_AfterCycleReset_SearchesAgainWithinMinCycleTime() + { + SeekerConfig config = await _fixture.DataContext.SeekerConfigs.FirstAsync(); + config.SearchEnabled = true; + config.ProactiveSearchEnabled = true; + + ArrInstance radarrInstance = TestDataContextFactory.AddRadarrInstance(_fixture.DataContext); + Guid resetCycleId = Guid.NewGuid(); + Guid newCycleId = Guid.NewGuid(); + DateTimeOffset now = _fixture.TimeProvider.GetUtcNow(); + + _fixture.DataContext.SeekerInstanceConfigs.Add(new SeekerInstanceConfig + { + ArrInstanceId = radarrInstance.Id, + ArrInstance = radarrInstance, + Enabled = true, + MonitoredOnly = false, + CurrentCycleId = newCycleId, + MinCycleTimeDays = 7 + }); + + // The state SeekerCycleController.ResetCycle leaves behind: both movies searched a day ago in the reset cycle. + _fixture.EventsContext.SeekerCycles.Add(new SeekerCycle + { + Id = resetCycleId, + ArrInstanceId = radarrInstance.Id, + StartedAt = now.AddDays(-2), + CompletedAt = now.AddMinutes(-1), + CompletionReason = SeekerCycleCompletionReason.Reset, + EligibleAtStart = 2, + EligibleNow = 2, + SearchedUnits = 2, + }); + _fixture.EventsContext.SeekerCycles.Add(new SeekerCycle + { + Id = newCycleId, + ArrInstanceId = radarrInstance.Id, + StartedAt = now.AddMinutes(-1), + EligibleAtStart = 2, + EligibleNow = 2, + RemainingUnits = 2, + }); + foreach (int movieId in new[] { 1, 2 }) + { + _fixture.EventsContext.SeekerHistory.Add(new SeekerHistory + { + ArrInstanceId = radarrInstance.Id, + ExternalItemId = movieId, + ItemType = InstanceType.Radarr, + CycleId = resetCycleId, + LastSearchedAt = now.AddDays(-1), + ItemTitle = $"Movie {movieId}" + }); + } + await _fixture.DataContext.SaveChangesAsync(); + await _fixture.EventsContext.SaveChangesAsync(); + + IArrClient mockArrClient = Substitute.For(); + _fixture.ArrQueueReader + .ReadAllAsync(mockArrClient, Arg.Any()) + .Returns([]); + + List movies = + [ + new SearchableMovie { Id = 1, Title = "Movie 1", Status = "released", Monitored = true, Tags = [] }, + new SearchableMovie { Id = 2, Title = "Movie 2", Status = "released", Monitored = true, Tags = [] } + ]; + _radarrClient + .StreamAllMoviesAsync(radarrInstance, Arg.Any()) + .Returns(ToAsyncEnumerable(movies)); + StubRadarrWanted(radarrInstance, movies); + + mockArrClient + .SearchItemAsync(radarrInstance, Arg.Any()) + .Returns(100L); + _fixture.ArrClientFactory + .GetClient(InstanceType.Radarr, Arg.Any()) + .Returns(mockArrClient); + + await CreateSut().ExecuteAsync(); + + await mockArrClient.Received(1) + .SearchItemAsync(radarrInstance, Arg.Any()); + + SeekerInstanceConfig instanceConfig = await _fixture.DataContext.SeekerInstanceConfigs + .FirstAsync(s => s.ArrInstanceId == radarrInstance.Id); + instanceConfig.CurrentCycleId.ShouldBe(newCycleId); + } + + [Fact] + public async Task ExecuteAsync_Radarr_HoldsCycleLockWhileProcessingInstance() + { + SeekerConfig config = await _fixture.DataContext.SeekerConfigs.FirstAsync(); + config.SearchEnabled = true; + config.ProactiveSearchEnabled = true; + + ArrInstance radarrInstance = TestDataContextFactory.AddRadarrInstance(_fixture.DataContext); + _fixture.DataContext.SeekerInstanceConfigs.Add(new SeekerInstanceConfig + { + ArrInstanceId = radarrInstance.Id, + ArrInstance = radarrInstance, + Enabled = true, + MonitoredOnly = false, + CurrentCycleId = Guid.NewGuid(), + MinCycleTimeDays = 0 + }); + await _fixture.DataContext.SaveChangesAsync(); + + IArrClient mockArrClient = Substitute.For(); + _fixture.ArrQueueReader + .ReadAllAsync(mockArrClient, Arg.Any()) + .Returns([]); + _fixture.ArrClientFactory + .GetClient(InstanceType.Radarr, Arg.Any()) + .Returns(mockArrClient); + + bool lockFreeDuringRun = true; + _radarrClient + .StreamAllMoviesAsync(radarrInstance, Arg.Any()) + .Returns(_ => + { + lockFreeDuringRun = _cycleLock.TryEnter(radarrInstance.Id); + return ToAsyncEnumerable([]); + }); + + await CreateSut().ExecuteAsync(); + + lockFreeDuringRun.ShouldBeFalse(); + _cycleLock.TryEnter(radarrInstance.Id).ShouldBeTrue(); + } + + [Fact] + public async Task ExecuteAsync_Radarr_CycleResetWhileWaitingForLock_KeepsResetCycle() + { + SeekerConfig config = await _fixture.DataContext.SeekerConfigs.FirstAsync(); + config.SearchEnabled = true; + config.ProactiveSearchEnabled = true; + + ArrInstance radarrInstance = TestDataContextFactory.AddRadarrInstance(_fixture.DataContext); + Guid runCycleId = Guid.NewGuid(); + Guid resetCycleId = Guid.NewGuid(); + DateTimeOffset now = _fixture.TimeProvider.GetUtcNow(); + + _fixture.DataContext.SeekerInstanceConfigs.Add(new SeekerInstanceConfig + { + ArrInstanceId = radarrInstance.Id, + ArrInstance = radarrInstance, + Enabled = true, + MonitoredOnly = false, + CurrentCycleId = runCycleId, + MinCycleTimeDays = 0 + }); + + // Every movie is already searched, so a run on this cycle would complete it. + _fixture.EventsContext.SeekerCycles.Add(new SeekerCycle + { + Id = runCycleId, + ArrInstanceId = radarrInstance.Id, + StartedAt = now.AddDays(-2), + EligibleAtStart = 2, + EligibleNow = 2, + SearchedUnits = 2, + }); + foreach (int movieId in new[] { 1, 2 }) + { + _fixture.EventsContext.SeekerHistory.Add(new SeekerHistory + { + ArrInstanceId = radarrInstance.Id, + ExternalItemId = movieId, + ItemType = InstanceType.Radarr, + CycleId = runCycleId, + LastSearchedAt = now.AddDays(-1), + ItemTitle = $"Movie {movieId}" + }); + } + await _fixture.DataContext.SaveChangesAsync(); + await _fixture.EventsContext.SaveChangesAsync(); + + IArrClient mockArrClient = Substitute.For(); + _fixture.ArrQueueReader + .ReadAllAsync(mockArrClient, Arg.Any()) + .Returns([]); + + List movies = + [ + new SearchableMovie { Id = 1, Title = "Movie 1", Status = "released", Monitored = true, Tags = [] }, + new SearchableMovie { Id = 2, Title = "Movie 2", Status = "released", Monitored = true, Tags = [] } + ]; + _radarrClient + .StreamAllMoviesAsync(radarrInstance, Arg.Any()) + .Returns(_ => ToAsyncEnumerable(movies)); + StubRadarrWanted(radarrInstance, movies); + + mockArrClient + .SearchItemAsync(radarrInstance, Arg.Any()) + .Returns(100L); + _fixture.ArrClientFactory + .GetClient(InstanceType.Radarr, Arg.Any()) + .Returns(mockArrClient); + + TaskCompletionSource jobStarted = new(TaskCreationOptions.RunContinuationsAsynchronously); + _dryRunInterceptor.IsDryRunEnabled().Returns(_ => + { + jobStarted.TrySetResult(); + return false; + }); + + _cycleLock.TryEnter(radarrInstance.Id).ShouldBeTrue(); + Task jobTask = CreateSut().ExecuteAsync(); + + // Nothing observable marks the job as parked on the lock, so give it time to load its configs. + await jobStarted.Task; + await Task.Delay(250); + jobTask.IsCompleted.ShouldBeFalse(); + + await _fixture.EventsContext.SeekerCycles + .Where(c => c.Id == runCycleId) + .ExecuteUpdateAsync(s => s + .SetProperty(c => c.CompletedAt, now) + .SetProperty(c => c.CompletionReason, SeekerCycleCompletionReason.Reset)); + _fixture.EventsContext.SeekerCycles.Add(new SeekerCycle + { + Id = resetCycleId, + ArrInstanceId = radarrInstance.Id, + StartedAt = now, + EligibleAtStart = 2, + EligibleNow = 2, + RemainingUnits = 2, + }); + await _fixture.EventsContext.SaveChangesAsync(); + await _fixture.DataContext.SeekerInstanceConfigs + .Where(s => s.ArrInstanceId == radarrInstance.Id) + .ExecuteUpdateAsync(s => s.SetProperty(c => c.CurrentCycleId, resetCycleId)); + + _cycleLock.Release(radarrInstance.Id); + await jobTask; + + SeekerCycle runCycle = await _fixture.EventsContext.SeekerCycles + .AsNoTracking() + .FirstAsync(c => c.Id == runCycleId); + runCycle.CompletionReason.ShouldBe(SeekerCycleCompletionReason.Reset); + + SeekerInstanceConfig instanceConfig = await _fixture.DataContext.SeekerInstanceConfigs + .AsNoTracking() + .FirstAsync(s => s.ArrInstanceId == radarrInstance.Id); + instanceConfig.CurrentCycleId.ShouldBe(resetCycleId); + + SeekerCycle resetCycle = await _fixture.EventsContext.SeekerCycles + .AsNoTracking() + .FirstAsync(c => c.Id == resetCycleId); + resetCycle.CompletedAt.ShouldBeNull(); + } + [Fact] public async Task ExecuteAsync_Radarr_CycleComplete_NoCycleHistory_StartsNewCycle() { @@ -2073,6 +2330,101 @@ public class SeekerTests : IDisposable instanceConfig.CurrentCycleId.ShouldBe(currentCycleId); } + [Fact] + public async Task ExecuteAsync_Sonarr_AfterCycleReset_SearchesAgainWithinMinCycleTime() + { + SeekerConfig config = await _fixture.DataContext.SeekerConfigs.FirstAsync(); + config.SearchEnabled = true; + config.ProactiveSearchEnabled = true; + + ArrInstance sonarrInstance = TestDataContextFactory.AddSonarrInstance(_fixture.DataContext); + Guid resetCycleId = Guid.NewGuid(); + Guid newCycleId = Guid.NewGuid(); + DateTimeOffset now = _fixture.TimeProvider.GetUtcNow(); + + _fixture.DataContext.SeekerInstanceConfigs.Add(new SeekerInstanceConfig + { + ArrInstanceId = sonarrInstance.Id, + ArrInstance = sonarrInstance, + Enabled = true, + MonitoredOnly = false, + CurrentCycleId = newCycleId, + MinCycleTimeDays = 7 + }); + + // The state SeekerCycleController.ResetCycle leaves behind: both seasons searched a day ago in the reset cycle. + _fixture.EventsContext.SeekerCycles.Add(new SeekerCycle + { + Id = resetCycleId, + ArrInstanceId = sonarrInstance.Id, + StartedAt = now.AddDays(-2), + CompletedAt = now.AddMinutes(-1), + CompletionReason = SeekerCycleCompletionReason.Reset, + EligibleAtStart = 2, + EligibleNow = 2, + SearchedUnits = 2, + }); + _fixture.EventsContext.SeekerCycles.Add(new SeekerCycle + { + Id = newCycleId, + ArrInstanceId = sonarrInstance.Id, + StartedAt = now.AddMinutes(-1), + EligibleAtStart = 2, + EligibleNow = 2, + RemainingUnits = 2, + }); + foreach (int seasonNumber in new[] { 1, 2 }) + { + _fixture.EventsContext.SeekerHistory.Add(new SeekerHistory + { + ArrInstanceId = sonarrInstance.Id, + ExternalItemId = 10, + ItemType = InstanceType.Sonarr, + SeasonNumber = seasonNumber, + CycleId = resetCycleId, + LastSearchedAt = now.AddDays(-1), + ItemTitle = "Test Series" + }); + } + await _fixture.DataContext.SaveChangesAsync(); + await _fixture.EventsContext.SaveChangesAsync(); + + IArrClient mockArrClient = Substitute.For(); + _fixture.ArrQueueReader + .ReadAllAsync(mockArrClient, Arg.Any()) + .Returns([]); + + _sonarrClient + .StreamAllSeriesAsync(sonarrInstance, Arg.Any()) + .Returns( + ToAsyncEnumerable([ + new SearchableSeries { Id = 10, Title = "Test Series", Status = "continuing", Monitored = true, Tags = [], Statistics = new SeriesStatistics { EpisodeCount = 10, EpisodeFileCount = 5 } } + ])); + + DateTime pastDate = now.UtcDateTime.AddDays(-30); + StubSonarrWanted(sonarrInstance, + [ + new SearchableEpisode { Id = 101, SeriesId = 10, SeasonNumber = 1, EpisodeNumber = 2, Monitored = true, HasFile = false, AirDateUtc = pastDate }, + new SearchableEpisode { Id = 102, SeriesId = 10, SeasonNumber = 2, EpisodeNumber = 2, Monitored = true, HasFile = false, AirDateUtc = pastDate } + ]); + + mockArrClient + .SearchItemAsync(sonarrInstance, Arg.Any()) + .Returns(100L); + _fixture.ArrClientFactory + .GetClient(InstanceType.Sonarr, Arg.Any()) + .Returns(mockArrClient); + + await CreateSut().ExecuteAsync(); + + await mockArrClient.Received(1) + .SearchItemAsync(sonarrInstance, Arg.Any()); + + SeekerInstanceConfig instanceConfig = await _fixture.DataContext.SeekerInstanceConfigs + .FirstAsync(s => s.ArrInstanceId == sonarrInstance.Id); + instanceConfig.CurrentCycleId.ShouldBe(newCycleId); + } + [Fact] public async Task ExecuteAsync_Sonarr_CycleComplete_RestartsAfterMinCycleTimeElapsed() { diff --git a/code/backend/Cleanuparr.Infrastructure/Features/Jobs/Seeker.cs b/code/backend/Cleanuparr.Infrastructure/Features/Jobs/Seeker.cs index 96015ec8..29bb784b 100644 --- a/code/backend/Cleanuparr.Infrastructure/Features/Jobs/Seeker.cs +++ b/code/backend/Cleanuparr.Infrastructure/Features/Jobs/Seeker.cs @@ -62,6 +62,7 @@ public sealed class Seeker : IHandler private readonly IHostEnvironment _environment; private readonly TimeProvider _timeProvider; private readonly IStatusNotifier _statusNotifier; + private readonly SeekerCycleLock _cycleLock; public Seeker( ILogger logger, @@ -75,7 +76,8 @@ public sealed class Seeker : IHandler IDryRunInterceptor dryRunInterceptor, IHostEnvironment environment, TimeProvider timeProvider, - IStatusNotifier statusNotifier) + IStatusNotifier statusNotifier, + SeekerCycleLock cycleLock) { _logger = logger; _dataContext = dataContext; @@ -89,6 +91,7 @@ public sealed class Seeker : IHandler _environment = environment; _timeProvider = timeProvider; _statusNotifier = statusNotifier; + _cycleLock = cycleLock; } public async Task ExecuteAsync(CancellationToken cancellationToken = default) @@ -278,6 +281,23 @@ public sealed class Seeker : IHandler } private async Task ProcessSingleInstanceAsync(SeekerConfig config, SeekerInstanceConfig instanceConfig, bool isDryRun, CancellationToken cancellationToken) + { + Guid arrInstanceId = instanceConfig.ArrInstanceId; + await _cycleLock.WaitAsync(arrInstanceId, cancellationToken); + + try + { + // A cycle reset may have changed CurrentCycleId while this run waited for the lock + await _dataContext.Entry(instanceConfig).ReloadAsync(cancellationToken); + return await ProcessLockedInstanceAsync(config, instanceConfig, isDryRun, cancellationToken); + } + finally + { + _cycleLock.Release(arrInstanceId); + } + } + + private async Task ProcessLockedInstanceAsync(SeekerConfig config, SeekerInstanceConfig instanceConfig, bool isDryRun, CancellationToken cancellationToken) { ArrInstance arrInstance = instanceConfig.ArrInstance; InstanceType instanceType = arrInstance.ArrConfig.Type; diff --git a/code/backend/Cleanuparr.Infrastructure/Features/Seeker/SeekerCycleLock.cs b/code/backend/Cleanuparr.Infrastructure/Features/Seeker/SeekerCycleLock.cs new file mode 100644 index 00000000..7564e2b4 --- /dev/null +++ b/code/backend/Cleanuparr.Infrastructure/Features/Seeker/SeekerCycleLock.cs @@ -0,0 +1,41 @@ +using System.Collections.Concurrent; + +namespace Cleanuparr.Infrastructure.Features.Seeker; + +/// +/// Serializes writes to an instance's Seeker cycle state between the Seeker job and the cycle reset endpoint. +/// +public sealed class SeekerCycleLock +{ + private readonly ConcurrentDictionary _locks = new(); + + /// + /// Waits until the instance's lock is free, then takes it. + /// + public Task WaitAsync(Guid arrInstanceId, CancellationToken cancellationToken = default) + { + return GetLock(arrInstanceId).WaitAsync(cancellationToken); + } + + /// + /// Takes the instance's lock if it is free. + /// + /// Whether the lock was taken. + public bool TryEnter(Guid arrInstanceId) + { + return GetLock(arrInstanceId).Wait(0); + } + + /// + /// Releases the instance's lock. + /// + public void Release(Guid arrInstanceId) + { + GetLock(arrInstanceId).Release(); + } + + private SemaphoreSlim GetLock(Guid arrInstanceId) + { + return _locks.GetOrAdd(arrInstanceId, _ => new SemaphoreSlim(1, 1)); + } +}