Files
ersatztv/ErsatzTV.Infrastructure/Locking/EntityLocker.cs
T
timothyandClaude Opus 4.8 275908ec11 fix(locking): atomic EntityLocker flags + single-owner release contract (#231)
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>
2026-07-11 12:30:36 +02:00

275 lines
8.3 KiB
C#

using System.Collections.Concurrent;
using ErsatzTV.Core.Interfaces.Locking;
using ErsatzTV.Core.Notifications;
using MediatR;
using Microsoft.Extensions.Logging;
namespace ErsatzTV.Infrastructure.Locking;
public class EntityLocker(IMediator mediator, ILogger<EntityLocker> logger) : IEntityLocker
{
private readonly ConcurrentDictionary<int, byte> _lockedLibraries = new();
private readonly ConcurrentDictionary<int, byte> _lockedPlayouts = new();
private readonly ConcurrentDictionary<Type, byte> _lockedRemoteMediaSourceTypes = new();
// 0 = unlocked, 1 = locked. 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
// (see docs/decisions.md 2026-07-11, issue #231).
private int _embyCollections;
private int _jellyfinCollections;
private int _plex;
private int _plexCollections;
private int _trakt;
private int _troubleshootingPlayback;
public event EventHandler OnLibraryChanged;
public event EventHandler OnPlexChanged;
public event EventHandler<Type> OnRemoteMediaSourceChanged;
public event EventHandler OnTraktChanged;
public event EventHandler OnEmbyCollectionsChanged;
public event EventHandler OnJellyfinCollectionsChanged;
public event EventHandler OnPlexCollectionsChanged;
public event EventHandler OnTroubleshootingPlaybackChanged;
public bool LockLibrary(int libraryId)
{
if (_lockedLibraries.TryAdd(libraryId, 0))
{
OnLibraryChanged?.Invoke(this, EventArgs.Empty);
return true;
}
return false;
}
public bool UnlockLibrary(int libraryId)
{
if (_lockedLibraries.TryRemove(libraryId, out byte _))
{
OnLibraryChanged?.Invoke(this, EventArgs.Empty);
return true;
}
logger.LogWarning(
"UnlockLibrary called for library {LibraryId} that was not locked (double-release or non-owner release)",
libraryId);
return false;
}
public bool IsLibraryLocked(int libraryId) =>
_lockedLibraries.ContainsKey(libraryId);
public bool LockPlex()
{
if (Interlocked.CompareExchange(ref _plex, 1, 0) == 0)
{
OnPlexChanged?.Invoke(this, EventArgs.Empty);
return true;
}
return false;
}
public bool UnlockPlex()
{
if (Interlocked.CompareExchange(ref _plex, 0, 1) == 1)
{
OnPlexChanged?.Invoke(this, EventArgs.Empty);
return true;
}
logger.LogWarning("UnlockPlex called but Plex was not locked (double-release or non-owner release)");
return false;
}
public bool IsPlexLocked() => Volatile.Read(ref _plex) == 1;
public bool IsRemoteMediaSourceLocked<TMediaSource>() =>
_lockedRemoteMediaSourceTypes.ContainsKey(typeof(TMediaSource));
public bool LockRemoteMediaSource<TMediaSource>()
{
Type mediaSourceType = typeof(TMediaSource);
if (_lockedRemoteMediaSourceTypes.TryAdd(mediaSourceType, 0))
{
OnRemoteMediaSourceChanged?.Invoke(this, mediaSourceType);
return true;
}
return false;
}
public bool UnlockRemoteMediaSource<TMediaSource>()
{
Type mediaSourceType = typeof(TMediaSource);
if (_lockedRemoteMediaSourceTypes.TryRemove(mediaSourceType, out byte _))
{
OnRemoteMediaSourceChanged?.Invoke(this, mediaSourceType);
return true;
}
logger.LogWarning(
"UnlockRemoteMediaSource called for {MediaSourceType} that was not locked (double-release or non-owner release)",
mediaSourceType.Name);
return false;
}
public bool LockTrakt()
{
if (Interlocked.CompareExchange(ref _trakt, 1, 0) == 0)
{
OnTraktChanged?.Invoke(this, EventArgs.Empty);
return true;
}
return false;
}
public bool UnlockTrakt()
{
if (Interlocked.CompareExchange(ref _trakt, 0, 1) == 1)
{
OnTraktChanged?.Invoke(this, EventArgs.Empty);
return true;
}
logger.LogWarning("UnlockTrakt called but Trakt was not locked (double-release or non-owner release)");
return false;
}
public bool IsTraktLocked() => Volatile.Read(ref _trakt) == 1;
public bool LockEmbyCollections()
{
if (Interlocked.CompareExchange(ref _embyCollections, 1, 0) == 0)
{
OnEmbyCollectionsChanged?.Invoke(this, EventArgs.Empty);
return true;
}
return false;
}
public bool UnlockEmbyCollections()
{
if (Interlocked.CompareExchange(ref _embyCollections, 0, 1) == 1)
{
OnEmbyCollectionsChanged?.Invoke(this, EventArgs.Empty);
return true;
}
logger.LogWarning(
"UnlockEmbyCollections called but Emby collections were not locked (double-release or non-owner release)");
return false;
}
public bool AreEmbyCollectionsLocked() => Volatile.Read(ref _embyCollections) == 1;
public bool LockJellyfinCollections()
{
if (Interlocked.CompareExchange(ref _jellyfinCollections, 1, 0) == 0)
{
OnJellyfinCollectionsChanged?.Invoke(this, EventArgs.Empty);
return true;
}
return false;
}
public bool UnlockJellyfinCollections()
{
if (Interlocked.CompareExchange(ref _jellyfinCollections, 0, 1) == 1)
{
OnJellyfinCollectionsChanged?.Invoke(this, EventArgs.Empty);
return true;
}
logger.LogWarning(
"UnlockJellyfinCollections called but Jellyfin collections were not locked (double-release or non-owner release)");
return false;
}
public bool AreJellyfinCollectionsLocked() => Volatile.Read(ref _jellyfinCollections) == 1;
public bool LockPlexCollections()
{
if (Interlocked.CompareExchange(ref _plexCollections, 1, 0) == 0)
{
OnPlexCollectionsChanged?.Invoke(this, EventArgs.Empty);
return true;
}
return false;
}
public bool UnlockPlexCollections()
{
if (Interlocked.CompareExchange(ref _plexCollections, 0, 1) == 1)
{
OnPlexCollectionsChanged?.Invoke(this, EventArgs.Empty);
return true;
}
logger.LogWarning(
"UnlockPlexCollections called but Plex collections were not locked (double-release or non-owner release)");
return false;
}
public bool ArePlexCollectionsLocked() => Volatile.Read(ref _plexCollections) == 1;
public async Task<bool> LockPlayout(int playoutId)
{
if (_lockedPlayouts.TryAdd(playoutId, 0))
{
await mediator.Publish(new PlayoutUpdatedNotification(playoutId, true));
return true;
}
return false;
}
public async Task<bool> UnlockPlayout(int playoutId)
{
if (_lockedPlayouts.TryRemove(playoutId, out byte _))
{
await mediator.Publish(new PlayoutUpdatedNotification(playoutId, false));
return true;
}
logger.LogWarning(
"UnlockPlayout called for playout {PlayoutId} that was not locked (double-release or non-owner release)",
playoutId);
return false;
}
public bool IsPlayoutLocked(int playoutId) => _lockedPlayouts.ContainsKey(playoutId);
public bool LockTroubleshootingPlayback()
{
if (Interlocked.CompareExchange(ref _troubleshootingPlayback, 1, 0) == 0)
{
OnTroubleshootingPlaybackChanged?.Invoke(this, EventArgs.Empty);
return true;
}
return false;
}
public bool UnlockTroubleshootingPlayback()
{
if (Interlocked.CompareExchange(ref _troubleshootingPlayback, 0, 1) == 1)
{
OnTroubleshootingPlaybackChanged?.Invoke(this, EventArgs.Empty);
return true;
}
logger.LogWarning(
"UnlockTroubleshootingPlayback called but troubleshooting playback was not locked (double-release or non-owner release)");
return false;
}
public bool IsTroubleshootingPlaybackLocked() => Volatile.Read(ref _troubleshootingPlayback) == 1;
}