From e23d1a23beb539881676b2cbb2791364a47f73cf Mon Sep 17 00:00:00 2001 From: unkin-agent Date: Fri, 11 Sep 2026 23:56:19 +1000 Subject: [PATCH] feat: add a durable transcode session store Transcode state lives only in the process that started ffmpeg, so a pod restart drops every in-flight HLS stream with no way for a peer to pick it up. - add ITranscodeSessionStore plus the TranscodeSession and LiveStreamSession records - add RedisTranscodeSessionStore with TTL leases and an atomic Lua takeover script - add NullTranscodeSessionStore for single-instance deployments - pick the store from Jellyfin:TranscodeStore:RedisConnectionString at startup --- Directory.Packages.props | 1 + .../Emby.Server.Implementations.csproj | 1 + .../RedisTranscodeSessionStore.cs | 270 ++++++++++++ Jellyfin.Server/CoreAppHost.cs | 31 ++ Jellyfin.Server/Jellyfin.Server.csproj | 1 + .../MediaEncoding/ITranscodeSessionStore.cs | 104 +++++ .../MediaEncoding/LiveStreamSession.cs | 36 ++ .../NullTranscodeSessionStore.cs | 49 +++ .../MediaEncoding/TranscodeSession.cs | 49 +++ .../MediaEncoding/TranscodeStoreOptions.cs | 19 + .../Fakes/InMemoryTranscodeSessionStore.cs | 169 ++++++++ .../Transcoding/TranscodeManagerTests.cs | 167 ++++++++ .../RedisTranscodeSessionStoreTests.cs | 384 ++++++++++++++++++ 13 files changed, 1281 insertions(+) create mode 100644 Emby.Server.Implementations/MediaEncoding/RedisTranscodeSessionStore.cs create mode 100644 MediaBrowser.Controller/MediaEncoding/ITranscodeSessionStore.cs create mode 100644 MediaBrowser.Controller/MediaEncoding/LiveStreamSession.cs create mode 100644 MediaBrowser.Controller/MediaEncoding/NullTranscodeSessionStore.cs create mode 100644 MediaBrowser.Controller/MediaEncoding/TranscodeSession.cs create mode 100644 MediaBrowser.Controller/MediaEncoding/TranscodeStoreOptions.cs create mode 100644 tests/Jellyfin.MediaEncoding.Tests/Fakes/InMemoryTranscodeSessionStore.cs create mode 100644 tests/Jellyfin.MediaEncoding.Tests/Transcoding/TranscodeManagerTests.cs create mode 100644 tests/Jellyfin.Server.Implementations.Tests/MediaEncoding/RedisTranscodeSessionStoreTests.cs diff --git a/Directory.Packages.props b/Directory.Packages.props index a00f24b1a7..1732f575a5 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -78,6 +78,7 @@ + diff --git a/Emby.Server.Implementations/Emby.Server.Implementations.csproj b/Emby.Server.Implementations/Emby.Server.Implementations.csproj index f312fb4db9..f0a5f1e2c3 100644 --- a/Emby.Server.Implementations/Emby.Server.Implementations.csproj +++ b/Emby.Server.Implementations/Emby.Server.Implementations.csproj @@ -65,6 +65,7 @@ + diff --git a/Emby.Server.Implementations/MediaEncoding/RedisTranscodeSessionStore.cs b/Emby.Server.Implementations/MediaEncoding/RedisTranscodeSessionStore.cs new file mode 100644 index 0000000000..6ac5c01205 --- /dev/null +++ b/Emby.Server.Implementations/MediaEncoding/RedisTranscodeSessionStore.cs @@ -0,0 +1,270 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using MediaBrowser.Controller.MediaEncoding; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using StackExchange.Redis; + +namespace Emby.Server.Implementations.MediaEncoding; + +/// +/// A Redis-backed implementation of that provides +/// durable, distributed session tracking with lease-based ownership between pods. +/// +public sealed class RedisTranscodeSessionStore : ITranscodeSessionStore +{ + private const string KeyPrefix = "jellyfin:transcode:"; + private const string LiveStreamKeyPrefix = "jellyfin:livestream:"; + + /// + /// Lua script for atomic takeover: reads the stored session, checks whether the lease has + /// expired (comparing LeaseExpiresUtc.Ticks against the caller-supplied current ticks), + /// and if expired, updates the owner and expiry before returning 1; returns 0 otherwise. + /// + private const string TakeoverScript = @" +local raw = redis.call('GET', KEYS[1]) +if not raw then return 0 end +local session = cjson.decode(raw) +local currentTicks = tonumber(ARGV[1]) +if session['LeaseExpiresUtc'] > currentTicks then return 0 end +session['OwnerPod'] = ARGV[2] +local leaseDurationMs = tonumber(ARGV[3]) +local newTicks = currentTicks + (leaseDurationMs * 10000) +session['LeaseExpiresUtc'] = newTicks +redis.call('SET', KEYS[1], cjson.encode(session), 'PX', leaseDurationMs) +return 1"; + + private readonly IConnectionMultiplexer _redis; + private readonly IDatabase _db; + private readonly TranscodeStoreOptions _options; + private readonly ILogger _logger; + + /// + /// Initializes a new instance of the class. + /// + /// The Redis connection multiplexer. + /// The transcode store configuration options. + /// The logger. + public RedisTranscodeSessionStore( + IConnectionMultiplexer redis, + IOptions options, + ILogger logger) + { + _redis = redis; + _db = redis.GetDatabase(); + _options = options.Value; + _logger = logger; + } + + /// + public async Task SetAsync(TranscodeSession session, CancellationToken cancellationToken = default) + { + var key = GetKey(session.PlaySessionId); + var json = JsonSerializer.Serialize(session); + var leaseDurationMs = (long)_options.LeaseDurationSeconds * 1000; + await _db.StringSetAsync(key, json, TimeSpan.FromMilliseconds(leaseDurationMs)).ConfigureAwait(false); + _logger.LogDebug("Set transcode session {PlaySessionId} in Redis.", session.PlaySessionId); + } + + /// + public async Task TryGetAsync(string playSessionId, CancellationToken cancellationToken = default) + { + var key = GetKey(playSessionId); + var raw = await _db.StringGetAsync(key).ConfigureAwait(false); + if (!raw.HasValue) + { + return null; + } + + var session = JsonSerializer.Deserialize(raw.ToString()); + + // Check LeaseExpiresUtc in addition to Redis TTL to guard against the window between + // Redis TTL evaluation and the GET result being returned to the caller. + if (session is null || session.LeaseExpiresUtc <= DateTime.UtcNow) + { + return null; + } + + return session; + } + + /// + public async Task RenewLeaseAsync(string playSessionId, CancellationToken cancellationToken = default) + { + var key = GetKey(playSessionId); + var raw = await _db.StringGetAsync(key).ConfigureAwait(false); + if (!raw.HasValue) + { + return; + } + + var session = JsonSerializer.Deserialize(raw.ToString()); + if (session is null) + { + return; + } + + var leaseDurationMs = (long)_options.LeaseDurationSeconds * 1000; + session.LeaseExpiresUtc = DateTime.UtcNow.AddMilliseconds(leaseDurationMs); + var json = JsonSerializer.Serialize(session); + await _db.StringSetAsync(key, json, TimeSpan.FromMilliseconds(leaseDurationMs)).ConfigureAwait(false); + _logger.LogDebug("Renewed lease for transcode session {PlaySessionId}.", playSessionId); + } + + /// + public async Task DeleteAsync(string playSessionId, CancellationToken cancellationToken = default) + { + var key = GetKey(playSessionId); + await _db.KeyDeleteAsync(key).ConfigureAwait(false); + _logger.LogDebug("Deleted transcode session {PlaySessionId} from Redis.", playSessionId); + } + + /// + public async Task TryTakeoverAsync(string playSessionId, string claimingPod, CancellationToken cancellationToken = default) + { + var key = GetKey(playSessionId); + var leaseDurationMs = (long)_options.LeaseDurationSeconds * 1000; + var currentTicks = DateTime.UtcNow.Ticks; + + var result = (long?)await _db.ScriptEvaluateAsync( + TakeoverScript, + keys: new RedisKey[] { key }, + values: new RedisValue[] { currentTicks, claimingPod, leaseDurationMs }).ConfigureAwait(false); + + var succeeded = result == 1; + if (succeeded) + { + _logger.LogInformation("Pod {ClaimingPod} successfully took over transcode session {PlaySessionId}.", claimingPod, playSessionId); + } + + return succeeded; + } + + private static string GetKey(string playSessionId) => KeyPrefix + playSessionId; + + private static string GetLiveStreamKey(string liveStreamId, string sessionIdOrPlaySessionId) + => LiveStreamKeyPrefix + liveStreamId + ":" + sessionIdOrPlaySessionId; + + /// + public async Task> GetActiveSessionsAsync(CancellationToken cancellationToken = default) + { + var sessions = new List(); + var servers = _redis.GetServers(); + + foreach (var server in servers) + { + if (!server.IsConnected) + { + continue; + } + + var keys = new List(); + await foreach (var key in server.KeysAsync(database: _db.Database, pattern: KeyPrefix + "*", pageSize: 1000).WithCancellation(cancellationToken).ConfigureAwait(false)) + { + keys.Add(key); + } + + var tasks = keys.Select(key => _db.StringGetAsync(key)).ToList(); + var values = await Task.WhenAll(tasks).ConfigureAwait(false); + + foreach (var raw in values) + { + if (!raw.HasValue) + { + continue; + } + + TranscodeSession? session; + try + { + session = JsonSerializer.Deserialize(raw.ToString()); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to deserialize transcode session from Redis."); + continue; + } + + if (session is not null) + { + sessions.Add(session); + } + } + } + + return sessions; + } + + /// + public async Task SetLiveStreamAsync(LiveStreamSession session, CancellationToken cancellationToken = default) + { + var key = GetLiveStreamKey(session.LiveStreamId, session.SessionId); + var json = JsonSerializer.Serialize(session); + // Live stream records use the same lease duration as transcode sessions. + var leaseDurationMs = (long)_options.LeaseDurationSeconds * 1000; + await _db.StringSetAsync(key, json, TimeSpan.FromMilliseconds(leaseDurationMs)).ConfigureAwait(false); + + // Also index by play session id so the caller can look up by either key. + if (!string.IsNullOrEmpty(session.PlaySessionId)) + { + var playKey = GetLiveStreamKey(session.LiveStreamId, session.PlaySessionId); + await _db.StringSetAsync(playKey, json, TimeSpan.FromMilliseconds(leaseDurationMs)).ConfigureAwait(false); + } + + _logger.LogDebug( + "Set live stream session {LiveStreamId}/{SessionId} in Redis.", + session.LiveStreamId, + session.SessionId); + } + + /// + public async Task TryGetLiveStreamAsync(string liveStreamId, string sessionIdOrPlaySessionId, CancellationToken cancellationToken = default) + { + var key = GetLiveStreamKey(liveStreamId, sessionIdOrPlaySessionId); + var raw = await _db.StringGetAsync(key).ConfigureAwait(false); + if (!raw.HasValue) + { + return null; + } + + return JsonSerializer.Deserialize(raw.ToString()); + } + + /// + public async Task DeleteLiveStreamAsync(string liveStreamId, string sessionIdOrPlaySessionId, CancellationToken cancellationToken = default) + { + var key = GetLiveStreamKey(liveStreamId, sessionIdOrPlaySessionId); + var raw = await _db.StringGetAsync(key).ConfigureAwait(false); + if (raw.HasValue) + { + var session = JsonSerializer.Deserialize(raw.ToString()); + if (session is not null) + { + // Remove both the session-id key and the play-session-id key if present. + var keysToDelete = new System.Collections.Generic.List + { + GetLiveStreamKey(liveStreamId, session.SessionId) + }; + + if (!string.IsNullOrEmpty(session.PlaySessionId)) + { + keysToDelete.Add(GetLiveStreamKey(liveStreamId, session.PlaySessionId)); + } + + await _db.KeyDeleteAsync(keysToDelete.ToArray()).ConfigureAwait(false); + _logger.LogDebug( + "Deleted live stream session {LiveStreamId}/{SessionId} from Redis.", + liveStreamId, + session.SessionId); + return; + } + } + + // Fallback: delete just the key that was supplied. + await _db.KeyDeleteAsync(key).ConfigureAwait(false); + } +} diff --git a/Jellyfin.Server/CoreAppHost.cs b/Jellyfin.Server/CoreAppHost.cs index 2548ddea7c..6020a1bc33 100644 --- a/Jellyfin.Server/CoreAppHost.cs +++ b/Jellyfin.Server/CoreAppHost.cs @@ -2,6 +2,7 @@ using System; using System.Collections.Generic; using System.Reflection; using Emby.Server.Implementations; +using Emby.Server.Implementations.MediaEncoding; using Emby.Server.Implementations.Session; using Jellyfin.Api.WebSocketListeners; using Jellyfin.Database.Implementations; @@ -23,6 +24,7 @@ using MediaBrowser.Controller.Drawing; using MediaBrowser.Controller.Events; using MediaBrowser.Controller.Library; using MediaBrowser.Controller.Lyrics; +using MediaBrowser.Controller.MediaEncoding; using MediaBrowser.Controller.Net; using MediaBrowser.Controller.Security; using MediaBrowser.Controller.Trickplay; @@ -31,6 +33,7 @@ using MediaBrowser.Providers.Lyric; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; +using StackExchange.Redis; namespace Jellyfin.Server { @@ -39,6 +42,8 @@ namespace Jellyfin.Server /// public class CoreAppHost : ApplicationHost { + private readonly IConfiguration _startupConfig; + /// /// Initializes a new instance of the class. /// @@ -57,6 +62,7 @@ namespace Jellyfin.Server options, startupConfig) { + _startupConfig = startupConfig; } /// @@ -98,6 +104,31 @@ namespace Jellyfin.Server serviceCollection.AddScoped(); + // Transcode session store: Redis-backed when configured, no-op otherwise. + serviceCollection.Configure(_startupConfig.GetSection("Jellyfin:TranscodeStore")); + var redisConnectionString = _startupConfig["Jellyfin:TranscodeStore:RedisConnectionString"]; + if (!string.IsNullOrEmpty(redisConnectionString)) + { + serviceCollection.AddSingleton(sp => + { + try + { + return ConnectionMultiplexer.Connect(redisConnectionString); + } + catch (Exception ex) + { + sp.GetRequiredService>() + .LogError(ex, "Failed to connect to Redis. Check the Jellyfin:TranscodeStore:RedisConnectionString configuration."); + throw; + } + }); + serviceCollection.AddSingleton(); + } + else + { + serviceCollection.AddSingleton(); + } + foreach (var type in GetExportTypes()) { serviceCollection.AddSingleton(typeof(ILyricProvider), type); diff --git a/Jellyfin.Server/Jellyfin.Server.csproj b/Jellyfin.Server/Jellyfin.Server.csproj index 9f5bf01a05..00fdc1d894 100644 --- a/Jellyfin.Server/Jellyfin.Server.csproj +++ b/Jellyfin.Server/Jellyfin.Server.csproj @@ -56,6 +56,7 @@ + diff --git a/MediaBrowser.Controller/MediaEncoding/ITranscodeSessionStore.cs b/MediaBrowser.Controller/MediaEncoding/ITranscodeSessionStore.cs new file mode 100644 index 0000000000..e72d327cd1 --- /dev/null +++ b/MediaBrowser.Controller/MediaEncoding/ITranscodeSessionStore.cs @@ -0,0 +1,104 @@ +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; + +namespace MediaBrowser.Controller.MediaEncoding; + +/// +/// Provides a durable store for HLS transcoding session state, enabling +/// HA recovery and lease-based ownership between pods. +/// +public interface ITranscodeSessionStore +{ + /// + /// Attempts to retrieve a transcoding session by its play session identifier. + /// + /// The play session identifier. + /// A cancellation token. + /// + /// The if it exists and its lease has not expired; + /// otherwise null. + /// + Task TryGetAsync(string playSessionId, CancellationToken cancellationToken = default); + + /// + /// Attempts to take over ownership of an existing session by claiming the lease for + /// . Takeover succeeds only when the session exists and + /// its current lease has already expired. + /// + /// The play session identifier. + /// The name of the pod attempting to claim ownership. + /// A cancellation token. + /// + /// true if the takeover succeeded (the claiming pod now holds the lease); + /// false if the session does not exist, its lease is still valid, or another + /// concurrent caller already claimed it. + /// + Task TryTakeoverAsync(string playSessionId, string claimingPod, CancellationToken cancellationToken = default); + + /// + /// Persists a new or updated transcoding session. + /// + /// The session to store. + /// A cancellation token. + /// A representing the asynchronous operation. + Task SetAsync(TranscodeSession session, CancellationToken cancellationToken = default); + + /// + /// Renews the lease for an existing session, extending its + /// by the store's configured lease duration. + /// + /// The play session identifier. + /// A cancellation token. + /// A representing the asynchronous operation. + Task RenewLeaseAsync(string playSessionId, CancellationToken cancellationToken = default); + + /// + /// Removes a transcoding session from the store. + /// + /// The play session identifier. + /// A cancellation token. + /// A representing the asynchronous operation. + Task DeleteAsync(string playSessionId, CancellationToken cancellationToken = default); + + /// + /// Returns all currently active transcoding sessions from the store. + /// + /// A cancellation token. + /// + /// An enumerable of objects representing all active sessions. + /// Returns an empty enumerable if no sessions are active or if the store cannot be reached. + /// + Task> GetActiveSessionsAsync(CancellationToken cancellationToken = default); + + /// + /// Persists a live stream session record so that takeover pods can identify and close + /// streams that were opened on a pod that has since crashed or been evicted. + /// + /// The live stream session to store. + /// A cancellation token. + /// A representing the asynchronous operation. + Task SetLiveStreamAsync(LiveStreamSession session, CancellationToken cancellationToken = default); + + /// + /// Attempts to retrieve a live stream session by its live stream identifier and the + /// session or play-session identifier that owns it. + /// + /// The live stream identifier. + /// The session identifier or play-session identifier. + /// A cancellation token. + /// + /// The if it exists; otherwise null. + /// + Task TryGetLiveStreamAsync(string liveStreamId, string sessionIdOrPlaySessionId, CancellationToken cancellationToken = default); + + /// + /// Removes the live stream session record for the given live stream and session identifier. + /// This is called when the stream is closed, either by the owning pod or a takeover pod. + /// + /// The live stream identifier. + /// The session identifier or play-session identifier. + /// A cancellation token. + /// A representing the asynchronous operation. + Task DeleteLiveStreamAsync(string liveStreamId, string sessionIdOrPlaySessionId, CancellationToken cancellationToken = default); +} diff --git a/MediaBrowser.Controller/MediaEncoding/LiveStreamSession.cs b/MediaBrowser.Controller/MediaEncoding/LiveStreamSession.cs new file mode 100644 index 0000000000..6549d4adb1 --- /dev/null +++ b/MediaBrowser.Controller/MediaEncoding/LiveStreamSession.cs @@ -0,0 +1,36 @@ +using System; + +namespace MediaBrowser.Controller.MediaEncoding; + +/// +/// Represents a durable record of an open live stream session, enabling HA pod recovery +/// when the owning pod crashes or is evicted. +/// +public sealed class LiveStreamSession +{ + /// + /// Gets or sets the live stream identifier (e.g. a TV tuner channel token). + /// + public string LiveStreamId { get; set; } = string.Empty; + + /// + /// Gets or sets the session identifier of the client that opened this live stream. + /// + public string SessionId { get; set; } = string.Empty; + + /// + /// Gets or sets the play session identifier associated with this live stream, + /// or an empty string when the client did not supply one. + /// + public string PlaySessionId { get; set; } = string.Empty; + + /// + /// Gets or sets the name of the pod that currently holds this live stream open. + /// + public string OwnerPod { get; set; } = string.Empty; + + /// + /// Gets or sets the UTC time at which this record was created. + /// + public DateTime OpenedAtUtc { get; set; } +} diff --git a/MediaBrowser.Controller/MediaEncoding/NullTranscodeSessionStore.cs b/MediaBrowser.Controller/MediaEncoding/NullTranscodeSessionStore.cs new file mode 100644 index 0000000000..a92ab8098d --- /dev/null +++ b/MediaBrowser.Controller/MediaEncoding/NullTranscodeSessionStore.cs @@ -0,0 +1,49 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; + +namespace MediaBrowser.Controller.MediaEncoding; + +/// +/// A no-op implementation of used in single-instance deployments +/// where durable session tracking across pods is not required. +/// +public sealed class NullTranscodeSessionStore : ITranscodeSessionStore +{ + /// + public Task TryGetAsync(string playSessionId, CancellationToken cancellationToken = default) + => Task.FromResult(null); + + /// + public Task TryTakeoverAsync(string playSessionId, string claimingPod, CancellationToken cancellationToken = default) + => Task.FromResult(false); + + /// + public Task SetAsync(TranscodeSession session, CancellationToken cancellationToken = default) + => Task.CompletedTask; + + /// + public Task RenewLeaseAsync(string playSessionId, CancellationToken cancellationToken = default) + => Task.CompletedTask; + + /// + public Task DeleteAsync(string playSessionId, CancellationToken cancellationToken = default) + => Task.CompletedTask; + + /// + public Task> GetActiveSessionsAsync(CancellationToken cancellationToken = default) + => Task.FromResult>(Array.Empty()); + + /// + public Task SetLiveStreamAsync(LiveStreamSession session, CancellationToken cancellationToken = default) + => Task.CompletedTask; + + /// + public Task TryGetLiveStreamAsync(string liveStreamId, string sessionIdOrPlaySessionId, CancellationToken cancellationToken = default) + => Task.FromResult(null); + + /// + public Task DeleteLiveStreamAsync(string liveStreamId, string sessionIdOrPlaySessionId, CancellationToken cancellationToken = default) + => Task.CompletedTask; +} diff --git a/MediaBrowser.Controller/MediaEncoding/TranscodeSession.cs b/MediaBrowser.Controller/MediaEncoding/TranscodeSession.cs new file mode 100644 index 0000000000..690f36e7cd --- /dev/null +++ b/MediaBrowser.Controller/MediaEncoding/TranscodeSession.cs @@ -0,0 +1,49 @@ +using System; + +namespace MediaBrowser.Controller.MediaEncoding; + +/// +/// Represents a durable record of an HLS transcoding session for HA pod recovery. +/// +public sealed class TranscodeSession +{ + /// + /// Gets or sets the unique play session identifier. + /// + public string PlaySessionId { get; set; } = string.Empty; + + /// + /// Gets or sets the name of the pod that currently owns this session's lease. + /// + public string OwnerPod { get; set; } = string.Empty; + + /// + /// Gets or sets the UTC time at which the owning pod's lease expires. + /// + public DateTime LeaseExpiresUtc { get; set; } + + /// + /// Gets or sets the absolute path to the HLS manifest (.m3u8) file on shared storage. + /// + public string ManifestPath { get; set; } = string.Empty; + + /// + /// Gets or sets the path prefix for transcoded segment files on shared storage. + /// + public string SegmentPathPrefix { get; set; } = string.Empty; + + /// + /// Gets or sets the media source identifier associated with this session. + /// + public string MediaSourceId { get; set; } = string.Empty; + + /// + /// Gets or sets the zero-based index of the last segment that was fully written to durable storage. + /// + public int LastCompletedSegmentIndex { get; set; } + + /// + /// Gets or sets the last durable playback offset in ticks, used to resume playback after failover. + /// + public long LastDurablePlaybackOffset { get; set; } +} diff --git a/MediaBrowser.Controller/MediaEncoding/TranscodeStoreOptions.cs b/MediaBrowser.Controller/MediaEncoding/TranscodeStoreOptions.cs new file mode 100644 index 0000000000..23d457bd22 --- /dev/null +++ b/MediaBrowser.Controller/MediaEncoding/TranscodeStoreOptions.cs @@ -0,0 +1,19 @@ +namespace MediaBrowser.Controller.MediaEncoding; + +/// +/// Configuration options for the transcode session store. +/// +public sealed class TranscodeStoreOptions +{ + /// + /// Gets or sets the Redis connection string. + /// A null or empty value indicates single-instance mode, where + /// is used instead of a Redis-backed store. + /// + public string? RedisConnectionString { get; set; } + + /// + /// Gets or sets the duration in seconds for which a transcoding session lease is valid. + /// + public int LeaseDurationSeconds { get; set; } = 30; +} diff --git a/tests/Jellyfin.MediaEncoding.Tests/Fakes/InMemoryTranscodeSessionStore.cs b/tests/Jellyfin.MediaEncoding.Tests/Fakes/InMemoryTranscodeSessionStore.cs new file mode 100644 index 0000000000..af3b89c350 --- /dev/null +++ b/tests/Jellyfin.MediaEncoding.Tests/Fakes/InMemoryTranscodeSessionStore.cs @@ -0,0 +1,169 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using MediaBrowser.Controller.MediaEncoding; + +namespace Jellyfin.MediaEncoding.Tests.Fakes; + +/// +/// Thread-safe, in-memory implementation of for use in unit tests. +/// +public sealed class InMemoryTranscodeSessionStore : ITranscodeSessionStore +{ + /// + /// The duration added to when a lease is renewed or first claimed. + /// + public static readonly TimeSpan DefaultLeaseDuration = TimeSpan.FromSeconds(30); + + private readonly Dictionary _sessions = new(StringComparer.OrdinalIgnoreCase); + private readonly Dictionary _liveStreams = new(StringComparer.OrdinalIgnoreCase); + private readonly Lock _lock = new(); + + /// + public Task TryGetAsync(string playSessionId, CancellationToken cancellationToken = default) + { + lock (_lock) + { + if (_sessions.TryGetValue(playSessionId, out var session) && session.LeaseExpiresUtc > DateTime.UtcNow) + { + return Task.FromResult(Clone(session)); + } + + return Task.FromResult(null); + } + } + + /// + public Task TryTakeoverAsync(string playSessionId, string claimingPod, CancellationToken cancellationToken = default) + { + lock (_lock) + { + if (!_sessions.TryGetValue(playSessionId, out var session)) + { + return Task.FromResult(false); + } + + if (session.LeaseExpiresUtc > DateTime.UtcNow) + { + // Another pod's lease is still valid – takeover not permitted. + return Task.FromResult(false); + } + + // Lease has expired – claim it atomically. + session.OwnerPod = claimingPod; + session.LeaseExpiresUtc = DateTime.UtcNow.Add(DefaultLeaseDuration); + return Task.FromResult(true); + } + } + + /// + public Task SetAsync(TranscodeSession session, CancellationToken cancellationToken = default) + { + lock (_lock) + { + _sessions[session.PlaySessionId] = session; + } + + return Task.CompletedTask; + } + + /// + public Task RenewLeaseAsync(string playSessionId, CancellationToken cancellationToken = default) + { + lock (_lock) + { + if (_sessions.TryGetValue(playSessionId, out var session)) + { + session.LeaseExpiresUtc = DateTime.UtcNow.Add(DefaultLeaseDuration); + } + } + + return Task.CompletedTask; + } + + /// + public Task DeleteAsync(string playSessionId, CancellationToken cancellationToken = default) + { + lock (_lock) + { + _sessions.Remove(playSessionId); + } + + return Task.CompletedTask; + } + + /// + public Task> GetActiveSessionsAsync(CancellationToken cancellationToken = default) + { + lock (_lock) + { + var sessions = _sessions.Values.Select(Clone).ToList(); + return Task.FromResult>(sessions); + } + } + + /// + public Task SetLiveStreamAsync(LiveStreamSession session, CancellationToken cancellationToken = default) + { + lock (_lock) + { + _liveStreams[MakeLiveStreamKey(session.LiveStreamId, session.SessionId)] = session; + if (!string.IsNullOrEmpty(session.PlaySessionId)) + { + _liveStreams[MakeLiveStreamKey(session.LiveStreamId, session.PlaySessionId)] = session; + } + } + + return Task.CompletedTask; + } + + /// + public Task TryGetLiveStreamAsync(string liveStreamId, string sessionIdOrPlaySessionId, CancellationToken cancellationToken = default) + { + lock (_lock) + { + _liveStreams.TryGetValue(MakeLiveStreamKey(liveStreamId, sessionIdOrPlaySessionId), out var session); + return Task.FromResult(session); + } + } + + /// + public Task DeleteLiveStreamAsync(string liveStreamId, string sessionIdOrPlaySessionId, CancellationToken cancellationToken = default) + { + lock (_lock) + { + if (_liveStreams.TryGetValue(MakeLiveStreamKey(liveStreamId, sessionIdOrPlaySessionId), out var session)) + { + _liveStreams.Remove(MakeLiveStreamKey(liveStreamId, session.SessionId)); + if (!string.IsNullOrEmpty(session.PlaySessionId)) + { + _liveStreams.Remove(MakeLiveStreamKey(liveStreamId, session.PlaySessionId)); + } + } + else + { + _liveStreams.Remove(MakeLiveStreamKey(liveStreamId, sessionIdOrPlaySessionId)); + } + } + + return Task.CompletedTask; + } + + private static string MakeLiveStreamKey(string liveStreamId, string sessionIdOrPlaySessionId) + => liveStreamId + "\x00" + sessionIdOrPlaySessionId; + + private static TranscodeSession Clone(TranscodeSession source) + => new TranscodeSession + { + PlaySessionId = source.PlaySessionId, + OwnerPod = source.OwnerPod, + LeaseExpiresUtc = source.LeaseExpiresUtc, + ManifestPath = source.ManifestPath, + SegmentPathPrefix = source.SegmentPathPrefix, + MediaSourceId = source.MediaSourceId, + LastCompletedSegmentIndex = source.LastCompletedSegmentIndex, + LastDurablePlaybackOffset = source.LastDurablePlaybackOffset, + }; +} diff --git a/tests/Jellyfin.MediaEncoding.Tests/Transcoding/TranscodeManagerTests.cs b/tests/Jellyfin.MediaEncoding.Tests/Transcoding/TranscodeManagerTests.cs new file mode 100644 index 0000000000..8ea94edb9b --- /dev/null +++ b/tests/Jellyfin.MediaEncoding.Tests/Transcoding/TranscodeManagerTests.cs @@ -0,0 +1,167 @@ +using System; +using System.Threading.Tasks; +using Jellyfin.MediaEncoding.Tests.Fakes; +using MediaBrowser.Controller.MediaEncoding; +using Xunit; + +namespace Jellyfin.MediaEncoding.Tests.Transcoding; + +/// +/// Unit tests for the HA session-store contract: lease expiry, double-claim prevention, +/// heartbeat renewal, and stale-session cleanup. +/// All tests exercise which implements the +/// interface that will be backed by Redis in Phase 5.2. +/// +public class TranscodeManagerTests +{ + private static TranscodeSession CreateSession( + string id, + string pod, + DateTime leaseExpiry, + int lastSegmentIndex = 0, + long lastOffset = 0L) + => new TranscodeSession + { + PlaySessionId = id, + OwnerPod = pod, + LeaseExpiresUtc = leaseExpiry, + ManifestPath = $"/transcode/{id}/manifest.m3u8", + SegmentPathPrefix = $"/transcode/{id}/segment", + MediaSourceId = $"media-source-{id}", + LastCompletedSegmentIndex = lastSegmentIndex, + LastDurablePlaybackOffset = lastOffset, + }; + + /// + /// Lease expiry: returns null + /// once has passed. + /// + [Fact] + [Trait("Category", "UnitTest")] + public async Task TryGetAsync_AfterLeaseExpires_ReturnsNull() + { + var store = new InMemoryTranscodeSessionStore(); + var session = CreateSession("session-expired", "pod-a", DateTime.UtcNow.AddMilliseconds(-1)); + await store.SetAsync(session, TestContext.Current.CancellationToken); + + var result = await store.TryGetAsync("session-expired", TestContext.Current.CancellationToken); + + Assert.Null(result); + } + + /// + /// A session whose lease has not yet expired is returned correctly. + /// + [Fact] + [Trait("Category", "UnitTest")] + public async Task TryGetAsync_WithinLease_ReturnsSession() + { + var store = new InMemoryTranscodeSessionStore(); + var session = CreateSession("session-live", "pod-a", DateTime.UtcNow.AddMinutes(5), lastSegmentIndex: 3, lastOffset: 18_000_000L); + await store.SetAsync(session, TestContext.Current.CancellationToken); + + var result = await store.TryGetAsync("session-live", TestContext.Current.CancellationToken); + + Assert.NotNull(result); + Assert.Equal("session-live", result.PlaySessionId); + Assert.Equal("pod-a", result.OwnerPod); + Assert.Equal(3, result.LastCompletedSegmentIndex); + Assert.Equal(18_000_000L, result.LastDurablePlaybackOffset); + } + + /// + /// Double-claim prevention: returns + /// false while the first pod's lease is still valid. + /// + [Fact] + [Trait("Category", "UnitTest")] + public async Task TryTakeoverAsync_WhileLeaseValid_ReturnsFalse() + { + var store = new InMemoryTranscodeSessionStore(); + var session = CreateSession("session-valid", "pod-a", DateTime.UtcNow.AddMinutes(5)); + await store.SetAsync(session, TestContext.Current.CancellationToken); + + var firstAttempt = await store.TryTakeoverAsync("session-valid", "pod-b", TestContext.Current.CancellationToken); + var secondAttempt = await store.TryTakeoverAsync("session-valid", "pod-c", TestContext.Current.CancellationToken); + + Assert.False(firstAttempt); + Assert.False(secondAttempt); + } + + /// + /// After a lease expires, the first concurrent caller that invokes + /// wins; the second caller returns false. + /// + [Fact] + [Trait("Category", "UnitTest")] + public async Task TryTakeoverAsync_AfterLeaseExpires_OnlyFirstClaimerSucceeds() + { + var store = new InMemoryTranscodeSessionStore(); + var session = CreateSession("session-stale", "pod-a", DateTime.UtcNow.AddMilliseconds(-1)); + await store.SetAsync(session, TestContext.Current.CancellationToken); + + // First pod wins; its takeover renews the lease atomically. + var firstTakeover = await store.TryTakeoverAsync("session-stale", "pod-b", TestContext.Current.CancellationToken); + + // Second pod is too late – pod-b already holds a fresh lease. + var secondTakeover = await store.TryTakeoverAsync("session-stale", "pod-c", TestContext.Current.CancellationToken); + + Assert.True(firstTakeover); + Assert.False(secondTakeover); + } + + /// + /// Heartbeat renewal: extends + /// beyond its original value. + /// + [Fact] + [Trait("Category", "UnitTest")] + public async Task RenewLeaseAsync_ExtendsLeaseExpiry() + { + var store = new InMemoryTranscodeSessionStore(); + var originalExpiry = DateTime.UtcNow.AddSeconds(5); + var session = CreateSession("session-renew", "pod-a", originalExpiry, lastSegmentIndex: 2, lastOffset: 10_000_000L); + await store.SetAsync(session, TestContext.Current.CancellationToken); + + await store.RenewLeaseAsync("session-renew", TestContext.Current.CancellationToken); + + var renewed = await store.TryGetAsync("session-renew", TestContext.Current.CancellationToken); + Assert.NotNull(renewed); + Assert.True( + renewed.LeaseExpiresUtc > originalExpiry, + "Renewed lease expiry should be later than the original expiry."); + } + + /// + /// Stale-session cleanup: an expired session can be deleted without error, and a + /// subsequent returns null. + /// + [Fact] + [Trait("Category", "UnitTest")] + public async Task DeleteAsync_ExpiredSession_CompletesWithoutError() + { + var store = new InMemoryTranscodeSessionStore(); + var session = CreateSession("session-delete", "pod-a", DateTime.UtcNow.AddMilliseconds(-1)); + await store.SetAsync(session, TestContext.Current.CancellationToken); + + var ex = await Record.ExceptionAsync(() => store.DeleteAsync("session-delete", TestContext.Current.CancellationToken)); + Assert.Null(ex); + + var result = await store.TryGetAsync("session-delete", TestContext.Current.CancellationToken); + Assert.Null(result); + } + + /// + /// Deleting a session that was never stored must complete without error. + /// + [Fact] + [Trait("Category", "UnitTest")] + public async Task DeleteAsync_NonExistentSession_CompletesWithoutError() + { + var store = new InMemoryTranscodeSessionStore(); + + var ex = await Record.ExceptionAsync(() => store.DeleteAsync("nonexistent-session", TestContext.Current.CancellationToken)); + + Assert.Null(ex); + } +} diff --git a/tests/Jellyfin.Server.Implementations.Tests/MediaEncoding/RedisTranscodeSessionStoreTests.cs b/tests/Jellyfin.Server.Implementations.Tests/MediaEncoding/RedisTranscodeSessionStoreTests.cs new file mode 100644 index 0000000000..086ce535e2 --- /dev/null +++ b/tests/Jellyfin.Server.Implementations.Tests/MediaEncoding/RedisTranscodeSessionStoreTests.cs @@ -0,0 +1,384 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using MediaBrowser.Controller.MediaEncoding; +using Xunit; + +namespace Jellyfin.Server.Implementations.Tests.MediaEncoding; + +/// +/// Tests for transcode session store contract behavior, using +/// as a reference implementation (no real Redis required). +/// +public class RedisTranscodeSessionStoreTests +{ + /// + /// Verifies that returns null after + /// a session's lease has expired. + /// + [Fact] + [Trait("Category", "UnitTest")] + public async Task TryGetAsync_AfterLeaseExpires_ReturnsNull() + { + var store = new InMemoryTranscodeSessionStore(); + var session = new TranscodeSession + { + PlaySessionId = "session-1", + OwnerPod = "pod-a", + LeaseExpiresUtc = DateTime.UtcNow.AddSeconds(-1), + }; + + await store.SetAsync(session, TestContext.Current.CancellationToken); + + var result = await store.TryGetAsync("session-1", TestContext.Current.CancellationToken); + + Assert.Null(result); + } + + /// + /// Verifies that returns false + /// when the session's lease is still valid. + /// + [Fact] + [Trait("Category", "UnitTest")] + public async Task TryTakeoverAsync_WhileLeaseValid_ReturnsFalse() + { + var store = new InMemoryTranscodeSessionStore(); + var session = new TranscodeSession + { + PlaySessionId = "session-2", + OwnerPod = "pod-a", + LeaseExpiresUtc = DateTime.UtcNow.AddSeconds(30), + }; + + await store.SetAsync(session, TestContext.Current.CancellationToken); + + var result = await store.TryTakeoverAsync("session-2", "pod-b", TestContext.Current.CancellationToken); + + Assert.False(result); + } + + /// + /// Verifies that returns true + /// and updates the owner when the session's lease has expired. + /// + [Fact] + [Trait("Category", "UnitTest")] + public async Task TryTakeoverAsync_AfterLeaseExpires_ReturnsTrue_AndUpdatesOwner() + { + var store = new InMemoryTranscodeSessionStore(); + var session = new TranscodeSession + { + PlaySessionId = "session-3", + OwnerPod = "pod-a", + LeaseExpiresUtc = DateTime.UtcNow.AddSeconds(-1), + }; + + await store.SetAsync(session, TestContext.Current.CancellationToken); + + var result = await store.TryTakeoverAsync("session-3", "pod-b", TestContext.Current.CancellationToken); + + Assert.True(result); + + var updated = await store.TryGetAsync("session-3", TestContext.Current.CancellationToken); + Assert.NotNull(updated); + Assert.Equal("pod-b", updated.OwnerPod); + Assert.True(updated.LeaseExpiresUtc > DateTime.UtcNow); + } + + /// + /// Verifies that when multiple pods concurrently attempt to take over an expired session, + /// exactly one succeeds. + /// + [Fact] + [Trait("Category", "UnitTest")] + public async Task ConcurrentTryTakeover_OnlyOneWins() + { + var store = new InMemoryTranscodeSessionStore(); + var session = new TranscodeSession + { + PlaySessionId = "session-4", + OwnerPod = "pod-a", + LeaseExpiresUtc = DateTime.UtcNow.AddSeconds(-1), + }; + + await store.SetAsync(session, TestContext.Current.CancellationToken); + + const int concurrency = 10; + var tasks = new Task[concurrency]; + for (int i = 0; i < concurrency; i++) + { + var podName = $"pod-{i}"; + tasks[i] = store.TryTakeoverAsync("session-4", podName, TestContext.Current.CancellationToken); + } + + var results = await Task.WhenAll(tasks); + + var successCount = 0; + foreach (var r in results) + { + if (r) + { + successCount++; + } + } + + Assert.Equal(1, successCount); + } + + /// + /// Verifies that stores a live stream + /// record that can be retrieved by session id. + /// + [Fact] + [Trait("Category", "UnitTest")] + public async Task SetLiveStreamAsync_CanBeRetrievedBySessionId() + { + var store = new InMemoryTranscodeSessionStore(); + var liveStream = new LiveStreamSession + { + LiveStreamId = "stream-1", + SessionId = "session-a", + PlaySessionId = "play-session-a", + OwnerPod = "pod-a", + OpenedAtUtc = DateTime.UtcNow, + }; + + await store.SetLiveStreamAsync(liveStream, TestContext.Current.CancellationToken); + + var result = await store.TryGetLiveStreamAsync("stream-1", "session-a", TestContext.Current.CancellationToken); + Assert.NotNull(result); + Assert.Equal("session-a", result.SessionId); + Assert.Equal("pod-a", result.OwnerPod); + } + + /// + /// Verifies that stores a live stream + /// record that can be retrieved by play session id. + /// + [Fact] + [Trait("Category", "UnitTest")] + public async Task SetLiveStreamAsync_CanBeRetrievedByPlaySessionId() + { + var store = new InMemoryTranscodeSessionStore(); + var liveStream = new LiveStreamSession + { + LiveStreamId = "stream-2", + SessionId = "session-b", + PlaySessionId = "play-session-b", + OwnerPod = "pod-a", + OpenedAtUtc = DateTime.UtcNow, + }; + + await store.SetLiveStreamAsync(liveStream, TestContext.Current.CancellationToken); + + var result = await store.TryGetLiveStreamAsync("stream-2", "play-session-b", TestContext.Current.CancellationToken); + Assert.NotNull(result); + Assert.Equal("session-b", result.SessionId); + } + + /// + /// Verifies that removes the live + /// stream record so that subsequent lookups by either session id or play session id return null. + /// + [Fact] + [Trait("Category", "UnitTest")] + public async Task DeleteLiveStreamAsync_RemovesBothKeys() + { + var store = new InMemoryTranscodeSessionStore(); + var liveStream = new LiveStreamSession + { + LiveStreamId = "stream-3", + SessionId = "session-c", + PlaySessionId = "play-session-c", + OwnerPod = "pod-a", + OpenedAtUtc = DateTime.UtcNow, + }; + + await store.SetLiveStreamAsync(liveStream, TestContext.Current.CancellationToken); + await store.DeleteLiveStreamAsync("stream-3", "session-c", TestContext.Current.CancellationToken); + + var bySessionId = await store.TryGetLiveStreamAsync("stream-3", "session-c", TestContext.Current.CancellationToken); + var byPlaySessionId = await store.TryGetLiveStreamAsync("stream-3", "play-session-c", TestContext.Current.CancellationToken); + + Assert.Null(bySessionId); + Assert.Null(byPlaySessionId); + } + + /// + /// Verifies that returns null when + /// no matching record exists. + /// + [Fact] + [Trait("Category", "UnitTest")] + public async Task TryGetLiveStreamAsync_WhenNotPresent_ReturnsNull() + { + var store = new InMemoryTranscodeSessionStore(); + + var result = await store.TryGetLiveStreamAsync("nonexistent-stream", "nonexistent-session", TestContext.Current.CancellationToken); + + Assert.Null(result); + } + + /// + /// Thread-safe, in-memory implementation of used within + /// this test class to avoid a cross-project reference to Jellyfin.MediaEncoding.Tests. + /// + private sealed class InMemoryTranscodeSessionStore : ITranscodeSessionStore + { + private static readonly TimeSpan DefaultLeaseDuration = TimeSpan.FromSeconds(30); + + private readonly Dictionary _sessions = new(StringComparer.OrdinalIgnoreCase); + private readonly Dictionary _liveStreams = new(StringComparer.OrdinalIgnoreCase); + private readonly Lock _lock = new(); + + /// + public Task TryGetAsync(string playSessionId, CancellationToken cancellationToken = default) + { + lock (_lock) + { + if (_sessions.TryGetValue(playSessionId, out var session) && session.LeaseExpiresUtc > DateTime.UtcNow) + { + return Task.FromResult(Clone(session)); + } + + return Task.FromResult(null); + } + } + + /// + public Task TryTakeoverAsync(string playSessionId, string claimingPod, CancellationToken cancellationToken = default) + { + lock (_lock) + { + if (!_sessions.TryGetValue(playSessionId, out var session)) + { + return Task.FromResult(false); + } + + if (session.LeaseExpiresUtc > DateTime.UtcNow) + { + return Task.FromResult(false); + } + + session.OwnerPod = claimingPod; + session.LeaseExpiresUtc = DateTime.UtcNow.Add(DefaultLeaseDuration); + return Task.FromResult(true); + } + } + + /// + public Task SetAsync(TranscodeSession session, CancellationToken cancellationToken = default) + { + lock (_lock) + { + _sessions[session.PlaySessionId] = session; + } + + return Task.CompletedTask; + } + + /// + public Task RenewLeaseAsync(string playSessionId, CancellationToken cancellationToken = default) + { + lock (_lock) + { + if (_sessions.TryGetValue(playSessionId, out var session)) + { + session.LeaseExpiresUtc = DateTime.UtcNow.Add(DefaultLeaseDuration); + } + } + + return Task.CompletedTask; + } + + /// + public Task DeleteAsync(string playSessionId, CancellationToken cancellationToken = default) + { + lock (_lock) + { + _sessions.Remove(playSessionId); + } + + return Task.CompletedTask; + } + + /// + public Task> GetActiveSessionsAsync(CancellationToken cancellationToken = default) + { + lock (_lock) + { + var sessions = _sessions.Values + .Where(s => s.LeaseExpiresUtc > DateTime.UtcNow) + .Select(Clone) + .ToList(); + return Task.FromResult>(sessions); + } + } + + /// + public Task SetLiveStreamAsync(LiveStreamSession session, CancellationToken cancellationToken = default) + { + lock (_lock) + { + _liveStreams[MakeLiveStreamKey(session.LiveStreamId, session.SessionId)] = session; + if (!string.IsNullOrEmpty(session.PlaySessionId)) + { + _liveStreams[MakeLiveStreamKey(session.LiveStreamId, session.PlaySessionId)] = session; + } + } + + return Task.CompletedTask; + } + + /// + public Task TryGetLiveStreamAsync(string liveStreamId, string sessionIdOrPlaySessionId, CancellationToken cancellationToken = default) + { + lock (_lock) + { + _liveStreams.TryGetValue(MakeLiveStreamKey(liveStreamId, sessionIdOrPlaySessionId), out var session); + return Task.FromResult(session); + } + } + + /// + public Task DeleteLiveStreamAsync(string liveStreamId, string sessionIdOrPlaySessionId, CancellationToken cancellationToken = default) + { + lock (_lock) + { + if (_liveStreams.TryGetValue(MakeLiveStreamKey(liveStreamId, sessionIdOrPlaySessionId), out var session)) + { + _liveStreams.Remove(MakeLiveStreamKey(liveStreamId, session.SessionId)); + if (!string.IsNullOrEmpty(session.PlaySessionId)) + { + _liveStreams.Remove(MakeLiveStreamKey(liveStreamId, session.PlaySessionId)); + } + } + else + { + _liveStreams.Remove(MakeLiveStreamKey(liveStreamId, sessionIdOrPlaySessionId)); + } + } + + return Task.CompletedTask; + } + + private static string MakeLiveStreamKey(string liveStreamId, string sessionIdOrPlaySessionId) + => liveStreamId + "\x00" + sessionIdOrPlaySessionId; + + private static TranscodeSession Clone(TranscodeSession source) + => new TranscodeSession + { + PlaySessionId = source.PlaySessionId, + OwnerPod = source.OwnerPod, + LeaseExpiresUtc = source.LeaseExpiresUtc, + ManifestPath = source.ManifestPath, + SegmentPathPrefix = source.SegmentPathPrefix, + MediaSourceId = source.MediaSourceId, + LastCompletedSegmentIndex = source.LastCompletedSegmentIndex, + LastDurablePlaybackOffset = source.LastDurablePlaybackOffset, + }; + } +}