fixed live run colliding with dry cycle row

This commit is contained in:
Flaminel committed 2026-10-20 15:55:53 +03:00
1 parent d5ea35445f
commit e32cee6f55
2 files changed
+98 -4

No files matched your search

@@ -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<IArrClient>();
_fixture.ArrQueueReader
.ReadAllAsync(mockArrClient, Arg.Any<ArrInstance>())
.Returns([]);
_radarrClient
.StreamAllMoviesAsync(radarrInstance, Arg.Any<CancellationToken>())
.Returns(ToAsyncEnumerable<SearchableMovie>([
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<SearchItem>())
.Returns(100L);
_fixture.ArrClientFactory
.GetClient(InstanceType.Radarr, Arg.Any<float>())
.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<SearchItem>());
List<SeekerCycle> 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
}
@@ -1092,8 +1092,7 @@ public sealed class Seeker : IHandler
Guid currentCycleId = instanceConfig.CurrentCycleId;
List<SeekerCycle> 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<DateTimeOffset> 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(),