From 8207bdf0f166a524f063e6cd893d4c9d607da1dd Mon Sep 17 00:00:00 2001 From: Flaminel Date: Thu, 26 Mar 2026 21:37:02 +0200 Subject: [PATCH] fixed instances not retroactively checking total items for changes --- .../Features/Jobs/SeekerTests.cs | 203 +++++++++++++++++- .../Features/Jobs/Seeker.cs | 91 +++----- 2 files changed, 220 insertions(+), 74 deletions(-) diff --git a/code/backend/Cleanuparr.Infrastructure.Tests/Features/Jobs/SeekerTests.cs b/code/backend/Cleanuparr.Infrastructure.Tests/Features/Jobs/SeekerTests.cs index c44d4750..93882fce 100644 --- a/code/backend/Cleanuparr.Infrastructure.Tests/Features/Jobs/SeekerTests.cs +++ b/code/backend/Cleanuparr.Infrastructure.Tests/Features/Jobs/SeekerTests.cs @@ -1297,7 +1297,9 @@ public class SeekerTests : IDisposable [Fact] public async Task ExecuteAsync_RoundRobin_SkipsInstanceWaitingForMinCycleTime() { - // Arrange — two Radarr instances: one waiting for MinCycleTimeDays, the other has work to do + // Arrange — two Radarr instances: one waiting for MinCycleTimeDays, the other has work to do. + // Round-robin tries instances in order of oldest LastProcessedAt. + // Instance A (oldest) is cycle-complete and waiting — no search triggered, moves to instance B. var config = await _fixture.DataContext.SeekerConfigs.FirstAsync(); config.SearchEnabled = true; config.ProactiveSearchEnabled = true; @@ -1307,7 +1309,7 @@ public class SeekerTests : IDisposable var now = _fixture.TimeProvider.GetUtcNow().UtcDateTime; - // Instance A: cycle complete, waiting for MinCycleTimeDays (oldest LastProcessedAt — would be picked first) + // Instance A: cycle complete, waiting for MinCycleTimeDays (oldest LastProcessedAt — tried first) var instanceA = TestDataContextFactory.AddRadarrInstance(_fixture.DataContext, "http://radarr-a:7878"); var cycleIdA = Guid.NewGuid(); _fixture.DataContext.SeekerInstanceConfigs.Add(new SeekerInstanceConfig @@ -1318,7 +1320,7 @@ public class SeekerTests : IDisposable CurrentCycleId = cycleIdA, MinCycleTimeDays = 30, TotalEligibleItems = 1, - LastProcessedAt = now.AddDays(-5) // Oldest — round-robin would pick this first + LastProcessedAt = now.AddDays(-5) // Oldest — round-robin tries this first }); _fixture.DataContext.SeekerHistory.Add(new SeekerHistory { @@ -1353,6 +1355,14 @@ public class SeekerTests : IDisposable .Setup(x => x.Iterate(mockArrClient.Object, It.IsAny(), It.IsAny, Task>>())) .Returns(Task.CompletedTask); + // Instance A: return the movie that was already searched in its cycle + _radarrClient + .Setup(x => x.GetAllMoviesAsync(instanceA)) + .ReturnsAsync( + [ + new SearchableMovie { Id = 1, Title = "Movie A", Status = "released", Monitored = true, Tags = [] } + ]); + _radarrClient .Setup(x => x.GetAllMoviesAsync(instanceB)) .ReturnsAsync( @@ -1361,7 +1371,7 @@ public class SeekerTests : IDisposable ]); mockArrClient - .Setup(x => x.SearchItemsAsync(instanceB, It.IsAny>())) + .Setup(x => x.SearchItemsAsync(It.IsAny(), It.IsAny>())) .ReturnsAsync([100L]); _fixture.ArrClientFactory @@ -1373,13 +1383,192 @@ public class SeekerTests : IDisposable // Act await sut.ExecuteAsync(); - // Assert — Instance B was processed (not A which was waiting) + // Assert — Instance A was checked (library fetched) but no search triggered + _radarrClient.Verify( + x => x.GetAllMoviesAsync(instanceA), + Times.Once); + // Instance B was processed and searched _radarrClient.Verify( x => x.GetAllMoviesAsync(instanceB), Times.Once); - _radarrClient.Verify( - x => x.GetAllMoviesAsync(instanceA), + // Search was only triggered for instance B, not instance A + mockArrClient.Verify( + x => x.SearchItemsAsync(instanceA, It.IsAny>()), Times.Never); + mockArrClient.Verify( + x => x.SearchItemsAsync(instanceB, It.IsAny>()), + Times.Once); + } + + [Fact] + public async Task ExecuteAsync_Radarr_NewItemAdded_SearchedDespiteCycleComplete() + { + // Arrange — cycle was complete (2 items searched), but a new item was added to the library. + // The new item should be searched immediately without waiting for MinCycleTimeDays. + var config = await _fixture.DataContext.SeekerConfigs.FirstAsync(); + config.SearchEnabled = true; + config.ProactiveSearchEnabled = true; + config.MonitoredOnly = false; + await _fixture.DataContext.SaveChangesAsync(); + + var radarrInstance = TestDataContextFactory.AddRadarrInstance(_fixture.DataContext); + var currentCycleId = Guid.NewGuid(); + var now = _fixture.TimeProvider.GetUtcNow().UtcDateTime; + + _fixture.DataContext.SeekerInstanceConfigs.Add(new SeekerInstanceConfig + { + ArrInstanceId = radarrInstance.Id, + ArrInstance = radarrInstance, + Enabled = true, + CurrentCycleId = currentCycleId, + MinCycleTimeDays = 30, + TotalEligibleItems = 2 // Stale value from previous run + }); + + // History: 2 items searched in current cycle + _fixture.DataContext.SeekerHistory.Add(new SeekerHistory + { + ArrInstanceId = radarrInstance.Id, + ExternalItemId = 1, + ItemType = InstanceType.Radarr, + CycleId = currentCycleId, + LastSearchedAt = now.AddDays(-2), + ItemTitle = "Movie 1" + }); + _fixture.DataContext.SeekerHistory.Add(new SeekerHistory + { + ArrInstanceId = radarrInstance.Id, + ExternalItemId = 2, + ItemType = InstanceType.Radarr, + CycleId = currentCycleId, + LastSearchedAt = now.AddDays(-1), + ItemTitle = "Movie 2" + }); + await _fixture.DataContext.SaveChangesAsync(); + + var mockArrClient = new Mock(); + + _fixture.ArrQueueIterator + .Setup(x => x.Iterate(mockArrClient.Object, It.IsAny(), It.IsAny, Task>>())) + .Returns(Task.CompletedTask); + + // Library now has 3 items — the 3rd was newly added + _radarrClient + .Setup(x => x.GetAllMoviesAsync(radarrInstance)) + .ReturnsAsync( + [ + new SearchableMovie { Id = 1, Title = "Movie 1", Status = "released", Monitored = true, Tags = [] }, + new SearchableMovie { Id = 2, Title = "Movie 2", Status = "released", Monitored = true, Tags = [] }, + new SearchableMovie { Id = 3, Title = "Movie 3 (New)", Status = "released", Monitored = true, Tags = [] } + ]); + + mockArrClient + .Setup(x => x.SearchItemsAsync(radarrInstance, It.IsAny>())) + .ReturnsAsync([100L]); + + _fixture.ArrClientFactory + .Setup(x => x.GetClient(InstanceType.Radarr, It.IsAny())) + .Returns(mockArrClient.Object); + + var sut = CreateSut(); + + // Act + await sut.ExecuteAsync(); + + // Assert — search was triggered for the new item (cycle is NOT considered complete) + mockArrClient.Verify( + x => x.SearchItemsAsync(radarrInstance, It.IsAny>()), + Times.Once); + + // Cycle ID should NOT have changed (cycle is not complete — there's still a new item) + var instanceConfig = await _fixture.DataContext.SeekerInstanceConfigs + .FirstAsync(s => s.ArrInstanceId == radarrInstance.Id); + Assert.Equal(currentCycleId, instanceConfig.CurrentCycleId); + } + + [Fact] + public async Task ExecuteAsync_Radarr_ItemSwapped_SearchesNewItem() + { + // Arrange — cycle was complete (2 items searched), but one item was removed and a new one added. + // Total count is the same, but the library has changed. The new item should be searched. + var config = await _fixture.DataContext.SeekerConfigs.FirstAsync(); + config.SearchEnabled = true; + config.ProactiveSearchEnabled = true; + config.MonitoredOnly = false; + await _fixture.DataContext.SaveChangesAsync(); + + var radarrInstance = TestDataContextFactory.AddRadarrInstance(_fixture.DataContext); + var currentCycleId = Guid.NewGuid(); + var now = _fixture.TimeProvider.GetUtcNow().UtcDateTime; + + _fixture.DataContext.SeekerInstanceConfigs.Add(new SeekerInstanceConfig + { + ArrInstanceId = radarrInstance.Id, + ArrInstance = radarrInstance, + Enabled = true, + CurrentCycleId = currentCycleId, + MinCycleTimeDays = 30, + TotalEligibleItems = 2 // Stale value from previous run + }); + + // History: items 1 and 2 were searched + _fixture.DataContext.SeekerHistory.Add(new SeekerHistory + { + ArrInstanceId = radarrInstance.Id, + ExternalItemId = 1, + ItemType = InstanceType.Radarr, + CycleId = currentCycleId, + LastSearchedAt = now.AddDays(-2), + ItemTitle = "Movie 1" + }); + _fixture.DataContext.SeekerHistory.Add(new SeekerHistory + { + ArrInstanceId = radarrInstance.Id, + ExternalItemId = 2, + ItemType = InstanceType.Radarr, + CycleId = currentCycleId, + LastSearchedAt = now.AddDays(-1), + ItemTitle = "Movie 2 (Removed)" + }); + await _fixture.DataContext.SaveChangesAsync(); + + var mockArrClient = new Mock(); + + _fixture.ArrQueueIterator + .Setup(x => x.Iterate(mockArrClient.Object, It.IsAny(), It.IsAny, Task>>())) + .Returns(Task.CompletedTask); + + // Library: item 2 was removed, item 3 was added (same total count of 2) + _radarrClient + .Setup(x => x.GetAllMoviesAsync(radarrInstance)) + .ReturnsAsync( + [ + new SearchableMovie { Id = 1, Title = "Movie 1", Status = "released", Monitored = true, Tags = [] }, + new SearchableMovie { Id = 3, Title = "Movie 3 (New)", Status = "released", Monitored = true, Tags = [] } + ]); + + mockArrClient + .Setup(x => x.SearchItemsAsync(radarrInstance, It.IsAny>())) + .ReturnsAsync([100L]); + + _fixture.ArrClientFactory + .Setup(x => x.GetClient(InstanceType.Radarr, It.IsAny())) + .Returns(mockArrClient.Object); + + var sut = CreateSut(); + + // Act + await sut.ExecuteAsync(); + + // Assert — search was triggered for the new item + mockArrClient.Verify( + x => x.SearchItemsAsync(radarrInstance, It.IsAny>()), + Times.Once); + + // Cycle ID should NOT have changed (the new item hasn't been searched yet) + var instanceConfig = await _fixture.DataContext.SeekerInstanceConfigs + .FirstAsync(s => s.ArrInstanceId == radarrInstance.Id); + Assert.Equal(currentCycleId, instanceConfig.CurrentCycleId); } #endregion diff --git a/code/backend/Cleanuparr.Infrastructure/Features/Jobs/Seeker.cs b/code/backend/Cleanuparr.Infrastructure/Features/Jobs/Seeker.cs index b032394b..e3f61d6b 100644 --- a/code/backend/Cleanuparr.Infrastructure/Features/Jobs/Seeker.cs +++ b/code/backend/Cleanuparr.Infrastructure/Features/Jobs/Seeker.cs @@ -220,23 +220,24 @@ public sealed class Seeker : IHandler return; } - // Pre-filter instances - instanceConfigs = await FilterInstancesAsync(instanceConfigs); - - if (instanceConfigs.Count == 0) - { - _logger.LogDebug("All instances are waiting for min cycle time to elapse"); - return; - } - if (config.UseRoundRobin) { - // Round-robin: pick the instance with the oldest LastProcessedAt - SeekerInstanceConfig nextInstance = instanceConfigs + // Round-robin: try instances in order of oldest LastProcessedAt, + // stop after the first one that triggers a search. + // This prevents cycle-complete-waiting instances from wasting a run. + var ordered = instanceConfigs .OrderBy(s => s.LastProcessedAt ?? DateTime.MinValue) - .First(); + .ToList(); - await ProcessSingleInstanceAsync(config, nextInstance, isDryRun); + foreach (SeekerInstanceConfig instance in ordered) + { + bool searched = await ProcessSingleInstanceAsync(config, instance, isDryRun); + + if (searched) + { + break; + } + } } else { @@ -248,7 +249,7 @@ public sealed class Seeker : IHandler } } - private async Task ProcessSingleInstanceAsync(SeekerConfig config, SeekerInstanceConfig instanceConfig, bool isDryRun) + private async Task ProcessSingleInstanceAsync(SeekerConfig config, SeekerInstanceConfig instanceConfig, bool isDryRun) { ArrInstance arrInstance = instanceConfig.ArrInstance; InstanceType instanceType = arrInstance.ArrConfig.Type; @@ -287,13 +288,14 @@ public sealed class Seeker : IHandler _logger.LogInformation( "Skipping proactive search for {InstanceName} — {Count} items actively downloading (limit: {Limit})", arrInstance.Name, activeDownloads, instanceConfig.ActiveDownloadLimit); - return; + return false; } } + bool searched = false; try { - await ProcessInstanceAsync(config, instanceConfig, arrInstance, instanceType, isDryRun, queueRecords); + searched = await ProcessInstanceAsync(config, instanceConfig, arrInstance, instanceType, isDryRun, queueRecords); } catch (Exception ex) { @@ -305,9 +307,11 @@ public sealed class Seeker : IHandler instanceConfig.LastProcessedAt = _timeProvider.GetUtcNow().UtcDateTime; _dataContext.SeekerInstanceConfigs.Update(instanceConfig); await _dataContext.SaveChangesAsync(); + + return searched; } - private async Task ProcessInstanceAsync( + private async Task ProcessInstanceAsync( SeekerConfig config, SeekerInstanceConfig instanceConfig, ArrInstance arrInstance, @@ -374,7 +378,7 @@ public sealed class Seeker : IHandler { await CleanupStaleHistoryAsync(arrInstance.Id, instanceType, allLibraryIds, allHistoryExternalIds); } - return; + return false; } // Trigger search (arr client guards the HTTP request via dry run interceptor) @@ -401,6 +405,8 @@ public sealed class Seeker : IHandler await CleanupStaleHistoryAsync(arrInstance.Id, instanceType, allLibraryIds, allHistoryExternalIds); await CleanupOldCycleHistoryAsync(arrInstance, instanceConfig.CurrentCycleId); } + + return true; } private async Task<(List SelectedIds, List SelectedNames, List AllLibraryIds)> ProcessRadarrAsync( @@ -894,55 +900,6 @@ public sealed class Seeker : IHandler } } - private async Task> FilterInstancesAsync( - List instanceConfigs) - { - var result = new List(); - - foreach (var ic in instanceConfigs) - { - // Count distinct items searched in current cycle - int cycleItemsSearched = await _dataContext.SeekerHistory - .AsNoTracking() - .Where(h => h.ArrInstanceId == ic.ArrInstanceId && h.CycleId == ic.CurrentCycleId) - .Select(h => h.ExternalItemId) - .Distinct() - .CountAsync(); - - // No searches have been performed - if (cycleItemsSearched is 0) - { - result.Add(ic); - continue; - } - - // Cycle not complete - if (cycleItemsSearched < ic.TotalEligibleItems) - { - result.Add(ic); - continue; - } - - // Cycle is complete, but check if min time has elapsed - DateTime? cycleStartedAt = await _dataContext.SeekerHistory - .AsNoTracking() - .Where(h => h.ArrInstanceId == ic.ArrInstanceId && h.CycleId == ic.CurrentCycleId) - .MinAsync(h => (DateTime?)h.LastSearchedAt); - - if (ShouldWaitForMinCycleTime(ic, cycleStartedAt)) - { - _logger.LogDebug( - "skip | cycle complete but min time ({Days}) not elapsed (started {StartedAt}) | {InstanceName}", - ic.ArrInstance.Name, ic.MinCycleTimeDays, cycleStartedAt); - continue; - } - - result.Add(ic); - } - - return result; - } - /// /// Checks whether the minimum cycle time constraint prevents starting a new cycle. /// Returns true if the cycle started recently and MinCycleTimeDays has not yet elapsed.