diff --git a/code/backend/Cleanuparr.Infrastructure.Tests/Features/Jobs/SeekerTests.cs b/code/backend/Cleanuparr.Infrastructure.Tests/Features/Jobs/SeekerTests.cs index 670f4c40..01162ec7 100644 --- a/code/backend/Cleanuparr.Infrastructure.Tests/Features/Jobs/SeekerTests.cs +++ b/code/backend/Cleanuparr.Infrastructure.Tests/Features/Jobs/SeekerTests.cs @@ -7672,5 +7672,83 @@ public class SeekerTests : IDisposable historyRows[0].LastOutcome.ShouldBeNull(); } + [Fact] + public async Task ExecuteAsync_Radarr_LiveRun_ReclaimsDryCycleRowWithSameId() + { + // Arrange: the migration left an all-dry cycle row under the live CurrentCycleId + var config = await _fixture.DataContext.SeekerConfigs.FirstAsync(); + config.SearchEnabled = true; + config.ProactiveSearchEnabled = true; + await _fixture.DataContext.SaveChangesAsync(); + + var radarrInstance = TestDataContextFactory.AddRadarrInstance(_fixture.DataContext); + var currentCycleId = Guid.NewGuid(); + + _fixture.DataContext.SeekerInstanceConfigs.Add(new SeekerInstanceConfig + { + ArrInstanceId = radarrInstance.Id, + ArrInstance = radarrInstance, + Enabled = true, + MonitoredOnly = false, + CurrentCycleId = currentCycleId + }); + + _fixture.EventsContext.SeekerCycles.Add(new SeekerCycle + { + Id = currentCycleId, + ArrInstanceId = radarrInstance.Id, + StartedAt = _fixture.TimeProvider.GetUtcNow().AddDays(-10), + CompletedAt = _fixture.TimeProvider.GetUtcNow().AddDays(-5), + CompletionReason = SeekerCycleCompletionReason.Completed, + SearchedUnits = 3, + GrabbedUnits = 1, + IsDryRun = true + }); + await _fixture.DataContext.SaveChangesAsync(); + await _fixture.EventsContext.SaveChangesAsync(); + + var mockArrClient = Substitute.For(); + + _fixture.ArrQueueReader + .ReadAllAsync(mockArrClient, Arg.Any()) + .Returns([]); + + _radarrClient + .StreamAllMoviesAsync(radarrInstance, Arg.Any()) + .Returns(ToAsyncEnumerable([ + new SearchableMovie { Id = 1, Title = "Movie 1", Status = "released", Monitored = true, HasFile = false, Tags = [] } + ])); + StubRadarrWanted(radarrInstance, [ + new SearchableMovie { Id = 1, Title = "Movie 1", Status = "released", Monitored = true, HasFile = false, Tags = [] } + ]); + + mockArrClient + .SearchItemAsync(radarrInstance, Arg.Any()) + .Returns(100L); + + _fixture.ArrClientFactory + .GetClient(InstanceType.Radarr, Arg.Any()) + .Returns(mockArrClient); + + var sut = CreateSut(); + + // Act + await sut.ExecuteAsync(); + + // Assert: no PK collision, the row is reclaimed as a fresh live cycle, and the search dispatched + await mockArrClient.Received(1) + .SearchItemAsync(radarrInstance, Arg.Any()); + + List cycles = await _fixture.EventsContext.SeekerCycles + .Where(c => c.Id == currentCycleId) + .ToListAsync(); + cycles.Count.ShouldBe(1); + cycles[0].IsDryRun.ShouldBeFalse(); + cycles[0].SearchedUnits.ShouldBe(1); + cycles[0].GrabbedUnits.ShouldBe(0); + cycles[0].CompletedAt.ShouldBeNull(); + cycles[0].CompletionReason.ShouldBeNull(); + } + #endregion } diff --git a/code/backend/Cleanuparr.Infrastructure/Features/Jobs/Seeker.cs b/code/backend/Cleanuparr.Infrastructure/Features/Jobs/Seeker.cs index eeef5d6e..703d235d 100644 --- a/code/backend/Cleanuparr.Infrastructure/Features/Jobs/Seeker.cs +++ b/code/backend/Cleanuparr.Infrastructure/Features/Jobs/Seeker.cs @@ -1092,8 +1092,7 @@ public sealed class Seeker : IHandler Guid currentCycleId = instanceConfig.CurrentCycleId; List openOrCurrent = await _eventsContext.SeekerCycles .Where(c => c.ArrInstanceId == arrInstanceId - && !c.IsDryRun - && (c.Id == currentCycleId || c.CompletedAt == null)) + && (c.Id == currentCycleId || (!c.IsDryRun && c.CompletedAt == null))) .ToListAsync(cancellationToken); // The migration guesses the open cycle from search events, the config holds the real one. @@ -1110,7 +1109,10 @@ public sealed class Seeker : IHandler SeekerCycle? cycle = openOrCurrent.FirstOrDefault(c => c.Id == currentCycleId); if (cycle is not null) { - return cycle; + // The all-dry-events migration can hand this id to a dry cycle; a live run reclaims it instead of colliding with it. + return cycle.IsDryRun + ? OpenCycle(arrInstanceId, isDryRun: false, eligibleAtStart: 0, reclaim: cycle) + : cycle; } List searchedAt = await _eventsContext.SeekerHistory @@ -1128,8 +1130,22 @@ public sealed class Seeker : IHandler return cycle; } - private SeekerCycle OpenCycle(Guid arrInstanceId, bool isDryRun, int eligibleAtStart, Guid? id = null) + private SeekerCycle OpenCycle(Guid arrInstanceId, bool isDryRun, int eligibleAtStart, Guid? id = null, SeekerCycle? reclaim = null) { + if (reclaim is not null) + { + reclaim.StartedAt = _timeProvider.GetUtcNow(); + reclaim.EligibleAtStart = eligibleAtStart; + reclaim.EligibleNow = eligibleAtStart; + reclaim.RemainingUnits = 0; + reclaim.SearchedUnits = 0; + reclaim.GrabbedUnits = 0; + reclaim.CompletedAt = null; + reclaim.CompletionReason = null; + reclaim.IsDryRun = isDryRun; + return reclaim; + } + SeekerCycle cycle = new() { Id = id ?? Guid.NewGuid(),