fix: make transcode leases ownership-checked and cleanup-aware

Cleanup never matched a live session because the controller registered empty
manifest and segment paths, renewal was a read-modify-write that could revert a
takeover, and the takeover script compared an ISO date to a number, so it errored.

- populate the session record's manifest and segment paths from the playlist path
- renew the lease via a Lua compare-and-set on the owning pod
- store the lease expiry as unix milliseconds so the scripts can compare it
- retain the session record past its lease so an orphan can still be taken over
- test the Redis store against a real Redis, including the renew-vs-takeover race
- drop the live stream record nothing ever read back
This commit is contained in:
2026-09-12 10:19:29 +10:00
parent d825f8ac81
commit baa16b6586
22 changed files with 644 additions and 1221 deletions
@@ -28,7 +28,6 @@ using MediaBrowser.Controller.Events;
using MediaBrowser.Controller.Events.Authentication;
using MediaBrowser.Controller.Events.Session;
using MediaBrowser.Controller.Library;
using MediaBrowser.Controller.MediaEncoding;
using MediaBrowser.Controller.Net;
using MediaBrowser.Controller.Session;
using MediaBrowser.Model.Dto;
@@ -61,7 +60,6 @@ namespace Emby.Server.Implementations.Session
private readonly IMediaSourceManager _mediaSourceManager;
private readonly IServerApplicationHost _appHost;
private readonly IDeviceManager _deviceManager;
private readonly ITranscodeSessionStore _transcodeSessionStore;
private readonly CancellationTokenRegistration _shutdownCallback;
private readonly ConcurrentDictionary<string, SessionInfo> _activeConnections
= new(StringComparer.OrdinalIgnoreCase);
@@ -91,7 +89,6 @@ namespace Emby.Server.Implementations.Session
/// <param name="deviceManager">Instance of <see cref="IDeviceManager"/> interface.</param>
/// <param name="mediaSourceManager">Instance of <see cref="IMediaSourceManager"/> interface.</param>
/// <param name="hostApplicationLifetime">Instance of <see cref="IHostApplicationLifetime"/> interface.</param>
/// <param name="transcodeSessionStore">Instance of <see cref="ITranscodeSessionStore"/> interface.</param>
public SessionManager(
ILogger<SessionManager> logger,
IEventManager eventManager,
@@ -105,8 +102,7 @@ namespace Emby.Server.Implementations.Session
IServerApplicationHost appHost,
IDeviceManager deviceManager,
IMediaSourceManager mediaSourceManager,
IHostApplicationLifetime hostApplicationLifetime,
ITranscodeSessionStore transcodeSessionStore)
IHostApplicationLifetime hostApplicationLifetime)
{
_logger = logger;
_eventManager = eventManager;
@@ -120,7 +116,6 @@ namespace Emby.Server.Implementations.Session
_appHost = appHost;
_deviceManager = deviceManager;
_mediaSourceManager = mediaSourceManager;
_transcodeSessionStore = transcodeSessionStore;
_shutdownCallback = hostApplicationLifetime.ApplicationStopping.Register(OnApplicationStopping);
_deviceManager.DeviceOptionsUpdated += OnDeviceManagerDeviceOptionsUpdated;
@@ -355,15 +350,6 @@ namespace Emby.Server.Implementations.Session
if (liveStreamNeedsToBeClosed)
{
try
{
await _transcodeSessionStore.DeleteLiveStreamAsync(liveStreamId, sessionIdOrPlaySessionId).ConfigureAwait(false);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to delete live stream session {LiveStreamId}/{SessionId} from durable store.", liveStreamId, sessionIdOrPlaySessionId);
}
try
{
await _mediaSourceManager.CloseLiveStream(liveStreamId).ConfigureAwait(false);
@@ -810,7 +796,7 @@ namespace Emby.Server.Implementations.Session
if (!string.IsNullOrEmpty(info.LiveStreamId))
{
await UpdateLiveStreamActiveSessionMappings(info.LiveStreamId, info.SessionId, info.PlaySessionId).ConfigureAwait(false);
UpdateLiveStreamActiveSessionMappings(info.LiveStreamId, info.SessionId, info.PlaySessionId);
}
var eventArgs = new PlaybackStartEventArgs
@@ -876,7 +862,7 @@ namespace Emby.Server.Implementations.Session
return OnPlaybackProgress(info, false);
}
private async Task UpdateLiveStreamActiveSessionMappings(string liveStreamId, string sessionId, string playSessionId)
private void UpdateLiveStreamActiveSessionMappings(string liveStreamId, string sessionId, string playSessionId)
{
var activeSessionMappings = _activeLiveStreamSessions.GetOrAdd(liveStreamId, _ => new ConcurrentDictionary<string, string>());
@@ -900,25 +886,6 @@ namespace Emby.Server.Implementations.Session
activeSessionMappings[sessionId] = string.Empty;
}
}
// Persist to the durable store so a takeover pod can discover open live streams.
var liveStreamSession = new LiveStreamSession
{
LiveStreamId = liveStreamId,
SessionId = sessionId,
PlaySessionId = playSessionId ?? string.Empty,
OwnerPod = Environment.GetEnvironmentVariable("JELLYFIN_INSTANCE_ID") ?? Environment.MachineName,
OpenedAtUtc = DateTime.UtcNow,
};
try
{
await _transcodeSessionStore.SetLiveStreamAsync(liveStreamSession).ConfigureAwait(false);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to persist live stream session {LiveStreamId}/{SessionId} to durable store.", liveStreamId, sessionId);
}
}
/// <summary>
@@ -964,7 +931,7 @@ namespace Emby.Server.Implementations.Session
if (!string.IsNullOrEmpty(info.LiveStreamId))
{
await UpdateLiveStreamActiveSessionMappings(info.LiveStreamId, info.SessionId, info.PlaySessionId).ConfigureAwait(false);
UpdateLiveStreamActiveSessionMappings(info.LiveStreamId, info.SessionId, info.PlaySessionId);
}
var eventArgs = new PlaybackProgressEventArgs