Files
ersatztv/ErsatzTV.Tests/Services/SchedulerServiceCollectionLockTests.cs
T
timothyandClaude Opus 4.8 787058d18c fix(235): scheduler-safe collections lock ownership (Codex High / Fable reconciliation)
The new POST /api/media-sources/{plex|jellyfin|emby}/{id}/scan-collections
endpoints acquire a per-provider collections lock (409 if held) and hand the
single release to the ScannerService finally. But SchedulerService's periodic
collection scans were enqueued WITHOUT the lock, and ScannerService's finally
released the collections lock whenever held with no ownership check. A
scheduler-queued scan running while an API request held the lock cross-released
the API's lock (#250 bug class), letting a second API request get a spurious
202 instead of 409.

Fix (mirrors the SynchronizePlexLibraryByIdIfNeeded(Unlock: !networksFollow)
library-scan precedent):
- Add `bool Unlock = true` (4th positional param) to the three
  Synchronize{Plex,Jellyfin,Emby}Collections records; default keeps the
  controller + Libraries.razor call sites compiling and releasing on run.
- ScannerService: the three collection finallys now honor `request.Unlock`
  (the concrete typed request is in scope in each method) so a batch member
  with Unlock:false never releases a lock it doesn't own.
- SchedulerService: replace the unlocked per-source enqueue with a lock-once
  per-provider batch — LockX Collections() once, enqueue each source with
  Unlock:isLast (last message owns the release), compensating unlock in catch,
  and SKIP the whole provider loop if the lock is already held. A naive
  "lock-per-source, skip if held" would deterministically starve the 2nd+
  source; lock-once-batch does not.

Tests (ErsatzTV.Tests/Services/): ScannerServiceCollectionLockTests drives the
real ScannerService read loop + real EntityLocker and asserts Unlock:false
leaves a held lock intact while Unlock:true releases (all three providers);
SchedulerServiceCollectionLockTests reflect-invokes ScanPlexMediaSources and
asserts it locks once + skips the enqueue when held, and hands the release to
the last message when acquired. Proven non-vacuous: reverting the Plex fix
fails exactly the three Plex tests.

No OpenAPI/v1.json change (internal channel-message record, not a DTO).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 18:37:35 +02:00

151 lines
5.7 KiB
C#

using System.Reflection;
using System.Threading.Channels;
using SysChannels = System.Threading.Channels;
using ErsatzTV.Application;
using ErsatzTV.Application.Plex;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Locking;
using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Services;
using ErsatzTV.Tests.Support;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging.Abstractions;
using NSubstitute;
using NUnit.Framework;
using Shouldly;
namespace ErsatzTV.Tests.Services;
/// <summary>
/// Verifies the scheduler's lock-once-per-provider collection batch (#235): it acquires
/// <c>LockPlexCollections()</c> exactly once, and when the slot is already held (an API scan or a
/// prior tick) it SKIPS the collection enqueue entirely rather than enqueuing an unlocked scan
/// that would cross-release the holder's lock (#250). When it does acquire, only the LAST message
/// carries the release (<c>Unlock: true</c>).
/// </summary>
[TestFixture]
public class SchedulerServiceCollectionLockTests
{
private InMemoryTvContext _db;
[SetUp]
public async Task SetUp() => _db = await InMemoryTvContext.CreateAsync();
[TearDown]
public async Task TearDown() => await _db.DisposeAsync();
[Test]
public async Task Plex_SkipsCollectionEnqueue_WhenCollectionsLockAlreadyHeld()
{
await SeedPlexLibrary(id: 1, mediaSourceId: 7);
var locker = Substitute.For<IEntityLocker>();
// isolate the collection path: the library loop enqueues nothing but still records the source id
locker.LockLibrary(Arg.Any<int>()).Returns(false);
// the collections slot is already held by another party
locker.LockPlexCollections().Returns(false);
(SchedulerService service, SysChannels.Channel<IScannerBackgroundServiceRequest> scannerChannel) = BuildService(locker);
await InvokeScanPlex(service);
List<IScannerBackgroundServiceRequest> writes = await Drain(scannerChannel);
locker.Received(1).LockPlexCollections();
writes.OfType<SynchronizePlexCollections>().ShouldBeEmpty();
locker.DidNotReceive().UnlockPlexCollections();
}
[Test]
public async Task Plex_EnqueuesBatch_LastMessageOwnsRelease_WhenLockAcquired()
{
await SeedPlexLibrary(id: 1, mediaSourceId: 7);
await SeedPlexLibrary(id: 2, mediaSourceId: 8);
var locker = Substitute.For<IEntityLocker>();
locker.LockLibrary(Arg.Any<int>()).Returns(false);
locker.LockPlexCollections().Returns(true);
(SchedulerService service, SysChannels.Channel<IScannerBackgroundServiceRequest> scannerChannel) = BuildService(locker);
await InvokeScanPlex(service);
List<IScannerBackgroundServiceRequest> writes = await Drain(scannerChannel);
locker.Received(1).LockPlexCollections();
List<SynchronizePlexCollections> collectionScans = writes.OfType<SynchronizePlexCollections>().ToList();
collectionScans.Count.ShouldBe(2);
// exactly one message owns the single release, and it is the last one written
collectionScans.Count(m => m.Unlock).ShouldBe(1);
collectionScans.Last().Unlock.ShouldBeTrue();
collectionScans.First().Unlock.ShouldBeFalse();
// release is handed off to the last message; no compensating unlock on the happy path
locker.DidNotReceive().UnlockPlexCollections();
}
private async Task SeedPlexLibrary(int id, int mediaSourceId)
{
await using TvContext context = _db.CreateContext();
context.PlexLibraries.Add(
new PlexLibrary
{
Id = id,
Name = $"Plex {id}",
MediaKind = LibraryMediaKind.Movies,
MediaSourceId = mediaSourceId,
ShouldSyncItems = true,
Key = $"key-{id}"
});
await context.SaveChangesAsync();
}
private (SchedulerService, SysChannels.Channel<IScannerBackgroundServiceRequest>) BuildService(IEntityLocker locker)
{
var provider = Substitute.For<IServiceProvider>();
provider.GetService(typeof(TvContext)).Returns(_ => _db.CreateContext());
var scope = Substitute.For<IServiceScope>();
scope.ServiceProvider.Returns(provider);
var scopeFactory = Substitute.For<IServiceScopeFactory>();
scopeFactory.CreateScope().Returns(scope);
var workerChannel = SysChannels.Channel.CreateUnbounded<IBackgroundServiceRequest>();
var scannerChannel = SysChannels.Channel.CreateUnbounded<IScannerBackgroundServiceRequest>();
var startup = new SystemStartup();
var service = new SchedulerService(
scopeFactory,
workerChannel.Writer,
scannerChannel.Writer,
locker,
startup,
NullLogger<SchedulerService>.Instance);
return (service, scannerChannel);
}
private static async Task<List<IScannerBackgroundServiceRequest>> Drain(
SysChannels.Channel<IScannerBackgroundServiceRequest> channel)
{
channel.Writer.Complete();
var results = new List<IScannerBackgroundServiceRequest>();
await foreach (IScannerBackgroundServiceRequest request in channel.Reader.ReadAllAsync())
{
results.Add(request);
}
return results;
}
private static async Task InvokeScanPlex(SchedulerService service)
{
MethodInfo method = typeof(SchedulerService)
.GetMethod("ScanPlexMediaSources", BindingFlags.NonPublic | BindingFlags.Instance)!;
var task = (Task)method.Invoke(service, [CancellationToken.None])!;
await task.WaitAsync(TimeSpan.FromSeconds(10));
}
}