The six plain-bool lock flags (Plex, Trakt, Emby/Jellyfin/Plex collections, troubleshooting playback) used a non-atomic check-then-set, so two concurrent Lock* callers could both win. Convert them to int flags mutated only via Interlocked.CompareExchange, so the caller that wins the 0->1 transition is the sole owner and the only one that fires the change event. The three ConcurrentDictionary-backed kinds (Library/Playout/RemoteMediaSource) were already atomic; drop their redundant ContainsKey pre-checks. Define the ownership contract (tokenless single-owner discipline, no interface change) on IEntityLocker and in docs/decisions.md: a true from Lock* confers ownership of exactly one release; Unlock* on an unlocked slot returns false, fires no event, and logs a warning (the double-release / non-owner tripwire). Adds EntityLockerTests (real locker, parallel-caller races) proving exactly one winner per kind, one-releaser-per-slot, and event-fires-once-per-transition. Ref #231. Scan-lifecycle call-site fixes that consume this contract land in the same PR (#232); the BuildPlayout/subtitle finally-gating is #234. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
271 lines
9.4 KiB
C#
271 lines
9.4 KiB
C#
using ErsatzTV.Core.Domain;
|
|
using ErsatzTV.Core.Notifications;
|
|
using ErsatzTV.Infrastructure.Locking;
|
|
using MediatR;
|
|
using Microsoft.Extensions.Logging.Abstractions;
|
|
using NSubstitute;
|
|
using NUnit.Framework;
|
|
using Shouldly;
|
|
|
|
namespace ErsatzTV.Infrastructure.Tests.Locking;
|
|
|
|
// Verifies the atomicity + single-owner release contract from docs/decisions.md 2026-07-11 (issue #231).
|
|
// These run against the real EntityLocker (not a mock) so the Interlocked/ConcurrentDictionary state is exercised.
|
|
[TestFixture]
|
|
public class EntityLockerTests
|
|
{
|
|
private const int ParallelCallers = 64;
|
|
|
|
private static EntityLocker CreateLocker() =>
|
|
new(Substitute.For<IMediator>(), NullLogger<EntityLocker>.Instance);
|
|
|
|
// Every exclusive single-slot kind, adapted to a uniform bool lock/unlock/is-locked shape.
|
|
// Library bakes in a fixed id; RemoteMediaSource a concrete media-source type. Playout is async
|
|
// (tested separately) and so is not in this source.
|
|
private static IEnumerable<LockKind> ExclusiveWinnerKinds()
|
|
{
|
|
foreach (LockKind kind in EventCountingKinds())
|
|
{
|
|
yield return kind;
|
|
}
|
|
|
|
yield return new LockKind
|
|
{
|
|
Name = "RemoteMediaSource",
|
|
Lock = l => l.LockRemoteMediaSource<PlexMediaSource>(),
|
|
Unlock = l => l.UnlockRemoteMediaSource<PlexMediaSource>(),
|
|
IsLocked = l => l.IsRemoteMediaSourceLocked<PlexMediaSource>()
|
|
};
|
|
}
|
|
|
|
// The kinds whose change event is a plain EventHandler, so a subscriber can count transitions.
|
|
private static IEnumerable<LockKind> EventCountingKinds()
|
|
{
|
|
yield return new LockKind
|
|
{
|
|
Name = "Plex",
|
|
Lock = l => l.LockPlex(),
|
|
Unlock = l => l.UnlockPlex(),
|
|
IsLocked = l => l.IsPlexLocked(),
|
|
Subscribe = (l, h) => l.OnPlexChanged += h
|
|
};
|
|
yield return new LockKind
|
|
{
|
|
Name = "Trakt",
|
|
Lock = l => l.LockTrakt(),
|
|
Unlock = l => l.UnlockTrakt(),
|
|
IsLocked = l => l.IsTraktLocked(),
|
|
Subscribe = (l, h) => l.OnTraktChanged += h
|
|
};
|
|
yield return new LockKind
|
|
{
|
|
Name = "EmbyCollections",
|
|
Lock = l => l.LockEmbyCollections(),
|
|
Unlock = l => l.UnlockEmbyCollections(),
|
|
IsLocked = l => l.AreEmbyCollectionsLocked(),
|
|
Subscribe = (l, h) => l.OnEmbyCollectionsChanged += h
|
|
};
|
|
yield return new LockKind
|
|
{
|
|
Name = "JellyfinCollections",
|
|
Lock = l => l.LockJellyfinCollections(),
|
|
Unlock = l => l.UnlockJellyfinCollections(),
|
|
IsLocked = l => l.AreJellyfinCollectionsLocked(),
|
|
Subscribe = (l, h) => l.OnJellyfinCollectionsChanged += h
|
|
};
|
|
yield return new LockKind
|
|
{
|
|
Name = "PlexCollections",
|
|
Lock = l => l.LockPlexCollections(),
|
|
Unlock = l => l.UnlockPlexCollections(),
|
|
IsLocked = l => l.ArePlexCollectionsLocked(),
|
|
Subscribe = (l, h) => l.OnPlexCollectionsChanged += h
|
|
};
|
|
yield return new LockKind
|
|
{
|
|
Name = "TroubleshootingPlayback",
|
|
Lock = l => l.LockTroubleshootingPlayback(),
|
|
Unlock = l => l.UnlockTroubleshootingPlayback(),
|
|
IsLocked = l => l.IsTroubleshootingPlaybackLocked(),
|
|
Subscribe = (l, h) => l.OnTroubleshootingPlaybackChanged += h
|
|
};
|
|
yield return new LockKind
|
|
{
|
|
Name = "Library",
|
|
Lock = l => l.LockLibrary(1),
|
|
Unlock = l => l.UnlockLibrary(1),
|
|
IsLocked = l => l.IsLibraryLocked(1),
|
|
Subscribe = (l, h) => l.OnLibraryChanged += h
|
|
};
|
|
}
|
|
|
|
// Stress the acquire race over many rounds: a single-round 64-thread barrier does NOT reliably
|
|
// collide the non-atomic check-then-set window on fast hardware (verified: it green-lit a
|
|
// deliberately broken flag), so this hammers Lock* for thousands of rounds — each round every
|
|
// thread races one acquire, exactly one must win, then the winner releases to reset. Any round
|
|
// with a winner count != 1 fails the whole test. Against the atomic CAS this is invariant;
|
|
// against a non-atomic flag some round double-acquires.
|
|
[Test]
|
|
[TestCaseSource(nameof(ExclusiveWinnerKinds))]
|
|
public void Lock_ParallelCallers_ExactlyOneWinner(LockKind kind)
|
|
{
|
|
const int threads = 8;
|
|
const int rounds = 20_000;
|
|
|
|
EntityLocker locker = CreateLocker();
|
|
var winners = 0;
|
|
var badRounds = 0;
|
|
|
|
using var startRound = new Barrier(threads);
|
|
using var endRound = new Barrier(
|
|
threads,
|
|
_ =>
|
|
{
|
|
if (Volatile.Read(ref winners) != 1)
|
|
{
|
|
Interlocked.Increment(ref badRounds);
|
|
}
|
|
|
|
// reset for the next round: release the (single) held lock and clear the counter
|
|
kind.Unlock(locker);
|
|
Volatile.Write(ref winners, 0);
|
|
});
|
|
|
|
var workers = new Thread[threads];
|
|
for (var t = 0; t < threads; t++)
|
|
{
|
|
workers[t] = new Thread(() =>
|
|
{
|
|
for (var r = 0; r < rounds; r++)
|
|
{
|
|
startRound.SignalAndWait();
|
|
if (kind.Lock(locker))
|
|
{
|
|
Interlocked.Increment(ref winners);
|
|
}
|
|
|
|
endRound.SignalAndWait();
|
|
}
|
|
});
|
|
workers[t].Start();
|
|
}
|
|
|
|
foreach (Thread worker in workers)
|
|
{
|
|
worker.Join();
|
|
}
|
|
|
|
badRounds.ShouldBe(0);
|
|
kind.IsLocked(locker).ShouldBeFalse();
|
|
}
|
|
|
|
[Test]
|
|
public async Task LockPlayout_ParallelCallers_ExactlyOneWinner_PublishesOnce()
|
|
{
|
|
var mediator = Substitute.For<IMediator>();
|
|
var locker = new EntityLocker(mediator, NullLogger<EntityLocker>.Instance);
|
|
const int playoutId = 7;
|
|
using var barrier = new Barrier(ParallelCallers);
|
|
|
|
Task<bool>[] tasks = Enumerable.Range(0, ParallelCallers)
|
|
.Select(_ => Task.Run(async () =>
|
|
{
|
|
barrier.SignalAndWait();
|
|
return await locker.LockPlayout(playoutId);
|
|
}))
|
|
.ToArray();
|
|
|
|
bool[] results = await Task.WhenAll(tasks);
|
|
|
|
results.Count(r => r).ShouldBe(1);
|
|
locker.IsPlayoutLocked(playoutId).ShouldBeTrue();
|
|
await mediator.Received(1).Publish(
|
|
Arg.Is<PlayoutUpdatedNotification>(n => n.PlayoutId == playoutId && n.IsLocked),
|
|
Arg.Any<CancellationToken>());
|
|
}
|
|
|
|
[Test]
|
|
[TestCaseSource(nameof(EventCountingKinds))]
|
|
public void Lock_WhenAlreadyLocked_ReturnsFalse_AndFiresNoSecondEvent(LockKind kind)
|
|
{
|
|
EntityLocker locker = CreateLocker();
|
|
var eventCount = 0;
|
|
kind.Subscribe!(locker, (_, _) => eventCount++);
|
|
|
|
kind.Lock(locker).ShouldBeTrue();
|
|
kind.Lock(locker).ShouldBeFalse();
|
|
|
|
kind.IsLocked(locker).ShouldBeTrue();
|
|
eventCount.ShouldBe(1);
|
|
}
|
|
|
|
[Test]
|
|
[TestCaseSource(nameof(EventCountingKinds))]
|
|
public void Unlock_WhenNotLocked_ReturnsFalse_AndFiresNoEvent(LockKind kind)
|
|
{
|
|
EntityLocker locker = CreateLocker();
|
|
var eventCount = 0;
|
|
kind.Subscribe!(locker, (_, _) => eventCount++);
|
|
|
|
kind.Unlock(locker).ShouldBeFalse();
|
|
|
|
eventCount.ShouldBe(0);
|
|
kind.IsLocked(locker).ShouldBeFalse();
|
|
}
|
|
|
|
[Test]
|
|
[TestCaseSource(nameof(EventCountingKinds))]
|
|
public void Unlock_AfterLock_ReturnsTrue_ThenSecondUnlockReturnsFalse(LockKind kind)
|
|
{
|
|
EntityLocker locker = CreateLocker();
|
|
var eventCount = 0;
|
|
kind.Subscribe!(locker, (_, _) => eventCount++);
|
|
|
|
kind.Lock(locker).ShouldBeTrue();
|
|
kind.Unlock(locker).ShouldBeTrue();
|
|
kind.Unlock(locker).ShouldBeFalse();
|
|
|
|
kind.IsLocked(locker).ShouldBeFalse();
|
|
eventCount.ShouldBe(2); // one lock transition + one unlock transition, the no-op unlock fires nothing
|
|
}
|
|
|
|
[Test]
|
|
[TestCaseSource(nameof(EventCountingKinds))]
|
|
public async Task ParallelUnlock_AfterSingleLock_ExactlyOneReleaser(LockKind kind)
|
|
{
|
|
EntityLocker locker = CreateLocker();
|
|
kind.Lock(locker).ShouldBeTrue();
|
|
|
|
var unlockEvents = 0;
|
|
kind.Subscribe!(locker, (_, _) => Interlocked.Increment(ref unlockEvents));
|
|
using var barrier = new Barrier(ParallelCallers);
|
|
|
|
Task<bool>[] tasks = Enumerable.Range(0, ParallelCallers)
|
|
.Select(_ => Task.Run(() =>
|
|
{
|
|
barrier.SignalAndWait();
|
|
return kind.Unlock(locker);
|
|
}))
|
|
.ToArray();
|
|
|
|
bool[] results = await Task.WhenAll(tasks);
|
|
|
|
results.Count(r => r).ShouldBe(1);
|
|
kind.IsLocked(locker).ShouldBeFalse();
|
|
unlockEvents.ShouldBe(1);
|
|
}
|
|
|
|
public sealed class LockKind
|
|
{
|
|
public required string Name { get; init; }
|
|
public required Func<EntityLocker, bool> Lock { get; init; }
|
|
public required Func<EntityLocker, bool> Unlock { get; init; }
|
|
public required Func<EntityLocker, bool> IsLocked { get; init; }
|
|
|
|
// null for kinds whose change event is not a plain EventHandler (RemoteMediaSource is EventHandler<Type>).
|
|
public Action<EntityLocker, EventHandler>? Subscribe { get; init; }
|
|
|
|
public override string ToString() => Name;
|
|
}
|
|
}
|