Files
ersatztv/ErsatzTV.Tests/Services/ScannerServiceCollectionLockTests.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

161 lines
5.5 KiB
C#

using System.Reflection;
using System.Threading.Channels;
using ErsatzTV.Application;
using ErsatzTV.Application.Emby;
using ErsatzTV.Application.Jellyfin;
using ErsatzTV.Application.Plex;
using ErsatzTV.Core;
using ErsatzTV.Core.Interfaces.Locking;
using ErsatzTV.Infrastructure.Locking;
using ErsatzTV.Services;
using LanguageExt;
using MediatR;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging.Abstractions;
using NSubstitute;
using NUnit.Framework;
using Shouldly;
namespace ErsatzTV.Tests.Services;
/// <summary>
/// Regression net for the #250 cross-release bug on the per-provider collections lock (#235):
/// a scheduler-enqueued collection scan running with <c>Unlock: false</c> must NOT release a
/// collections lock owned by another party (an in-flight API scan), while <c>Unlock: true</c>
/// (an API request or the last message of a scheduler batch) must release on completion.
/// </summary>
[TestFixture]
public class ScannerServiceCollectionLockTests
{
[Test]
public async Task Plex_UnlockFalse_DoesNotReleaseHeldLock()
{
(ScannerService service, EntityLocker locker) = BuildService(
new SynchronizePlexCollections(1, false, false, Unlock: false));
// another party (an API scan) owns the collections lock
locker.LockPlexCollections().ShouldBeTrue();
await RunToCompletion(service);
locker.ArePlexCollectionsLocked().ShouldBeTrue();
}
[Test]
public async Task Plex_UnlockTrue_ReleasesHeldLock()
{
(ScannerService service, EntityLocker locker) = BuildService(
new SynchronizePlexCollections(1, false, false, Unlock: true));
locker.LockPlexCollections().ShouldBeTrue();
await RunToCompletion(service);
locker.ArePlexCollectionsLocked().ShouldBeFalse();
}
[Test]
public async Task Jellyfin_UnlockFalse_DoesNotReleaseHeldLock()
{
(ScannerService service, EntityLocker locker) = BuildService(
new SynchronizeJellyfinCollections(1, false, false, Unlock: false));
locker.LockJellyfinCollections().ShouldBeTrue();
await RunToCompletion(service);
locker.AreJellyfinCollectionsLocked().ShouldBeTrue();
}
[Test]
public async Task Jellyfin_UnlockTrue_ReleasesHeldLock()
{
(ScannerService service, EntityLocker locker) = BuildService(
new SynchronizeJellyfinCollections(1, false, false, Unlock: true));
locker.LockJellyfinCollections().ShouldBeTrue();
await RunToCompletion(service);
locker.AreJellyfinCollectionsLocked().ShouldBeFalse();
}
[Test]
public async Task Emby_UnlockFalse_DoesNotReleaseHeldLock()
{
(ScannerService service, EntityLocker locker) = BuildService(
new SynchronizeEmbyCollections(1, false, false, Unlock: false));
locker.LockEmbyCollections().ShouldBeTrue();
await RunToCompletion(service);
locker.AreEmbyCollectionsLocked().ShouldBeTrue();
}
[Test]
public async Task Emby_UnlockTrue_ReleasesHeldLock()
{
(ScannerService service, EntityLocker locker) = BuildService(
new SynchronizeEmbyCollections(1, false, false, Unlock: true));
locker.LockEmbyCollections().ShouldBeTrue();
await RunToCompletion(service);
locker.AreEmbyCollectionsLocked().ShouldBeFalse();
}
private static (ScannerService, EntityLocker) BuildService(IScannerBackgroundServiceRequest request)
{
var mediator = Substitute.For<IMediator>();
// every collection sync request returns success; the finally is what we're exercising
Either<BaseError, LanguageExt.Unit> ok = LanguageExt.Unit.Default;
mediator.Send(Arg.Any<SynchronizePlexCollections>(), Arg.Any<CancellationToken>())
.Returns(ok);
mediator.Send(Arg.Any<SynchronizeJellyfinCollections>(), Arg.Any<CancellationToken>())
.Returns(ok);
mediator.Send(Arg.Any<SynchronizeEmbyCollections>(), Arg.Any<CancellationToken>())
.Returns(ok);
var locker = new EntityLocker(mediator, NullLogger<EntityLocker>.Instance);
var provider = Substitute.For<IServiceProvider>();
provider.GetService(typeof(IMediator)).Returns(mediator);
provider.GetService(typeof(IEntityLocker)).Returns(locker);
var scope = Substitute.For<IServiceScope>();
scope.ServiceProvider.Returns(provider);
var scopeFactory = Substitute.For<IServiceScopeFactory>();
scopeFactory.CreateScope().Returns(scope);
var channel = Channel.CreateUnbounded<IScannerBackgroundServiceRequest>();
channel.Writer.TryWrite(request).ShouldBeTrue();
channel.Writer.Complete();
var startup = new SystemStartup();
startup.DatabaseIsReady();
startup.SearchIndexIsReady();
var service = new ScannerService(
channel.Reader,
scopeFactory,
startup,
NullLogger<ScannerService>.Instance);
return (service, locker);
}
private static async Task RunToCompletion(ScannerService service)
{
MethodInfo executeAsync = typeof(ScannerService)
.GetMethod("ExecuteAsync", BindingFlags.NonPublic | BindingFlags.Instance)!;
// the channel writer is already completed, so the read loop drains the one queued
// request, runs its finally, then ExecuteAsync returns
var task = (Task)executeAsync.Invoke(service, [CancellationToken.None])!;
await task.WaitAsync(TimeSpan.FromSeconds(10));
}
}