using System; using System.Collections.Generic; using System.Globalization; using System.Linq; using System.Text.Json; using System.Threading; using System.Threading.Tasks; using Jellyfin.Extensions.Json; using MediaBrowser.Controller.Session; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using StackExchange.Redis; namespace Emby.Server.Implementations.Session; /// /// A Redis-backed . A session is owned by the instance holding its /// connection: ownership is claimed through a check-and-set, so an instance that only served a request /// for the session cannot take it from the instance the device is actually connected to. Each entry is a /// key with an expiry, so the sessions of an instance that stops refreshing them disappear on their own. /// public sealed class RedisSessionDirectory : ISessionDirectory { private const string KeyPrefix = "jellyfin:session:"; private const string OwnerKeyPrefix = "jellyfin:sessionowner:"; /// /// Lua script for an atomic ownership claim. The owner key holds connectedTicks|pod, where /// the ticks are zero for an instance that holds no connection. A claim by another instance is /// refused unless its connection is newer than the recorded one, so the instance holding the live /// connection keeps ownership however many requests the others serve. /// private const string ClaimScript = @" local current = redis.call('GET', KEYS[1]) if current then local separator = string.find(current, '|', 1, true) local connected = tonumber(string.sub(current, 1, separator - 1)) local owner = string.sub(current, separator + 1) if owner ~= ARGV[1] and connected > 0 and tonumber(ARGV[2]) <= connected then return 0 end end redis.call('SET', KEYS[1], ARGV[2] .. '|' .. ARGV[1], 'PX', ARGV[4]) redis.call('SET', KEYS[2], ARGV[3], 'PX', ARGV[4]) return 1"; /// /// Lua script for an atomic, ownership-checked removal, so that an instance ending its own copy of a /// session cannot erase the entry of the instance still holding the connection. /// private const string ReleaseScript = @" local current = redis.call('GET', KEYS[1]) if not current then return 0 end local separator = string.find(current, '|', 1, true) if string.sub(current, separator + 1) ~= ARGV[1] then return 0 end redis.call('DEL', KEYS[1], KEYS[2]) return 1"; private static readonly JsonSerializerOptions _jsonOptions = JsonDefaults.Options; private readonly IConnectionMultiplexer _redis; private readonly IDatabase _db; private readonly SessionDirectoryOptions _options; private readonly ILogger _logger; /// /// Initializes a new instance of the class. /// /// The Redis connection multiplexer. /// The session directory configuration options. /// The logger. public RedisSessionDirectory( IConnectionMultiplexer redis, IOptions options, ILogger logger) { ArgumentNullException.ThrowIfNull(redis); ArgumentNullException.ThrowIfNull(options); _redis = redis; _db = redis.GetDatabase(); _options = options.Value; _logger = logger; } private long EntryTtlMs => Math.Max(1, _options.EntryTtlSeconds) * 1000L; private TimeSpan OperationTimeout => TimeSpan.FromSeconds(Math.Max(1, _options.OperationTimeoutSeconds)); /// public async Task PublishAsync(SessionDirectoryEntry entry, long connectedUtcTicks, CancellationToken cancellationToken = default) { ArgumentNullException.ThrowIfNull(entry); var sessionId = entry.Session?.Id; if (string.IsNullOrEmpty(sessionId)) { return false; } try { var claimed = (long?)await _db.ScriptEvaluateAsync( ClaimScript, keys: new RedisKey[] { OwnerKeyPrefix + sessionId, KeyPrefix + sessionId }, values: new RedisValue[] { entry.OwnerPod, connectedUtcTicks.ToString(CultureInfo.InvariantCulture), JsonSerializer.Serialize(entry, _jsonOptions), EntryTtlMs }).WaitAsync(OperationTimeout, cancellationToken).ConfigureAwait(false); return claimed == 1; } catch (Exception ex) { _logger.LogWarning(ex, "Failed to publish session {SessionId}; it stays invisible to the other instances.", sessionId); return false; } } /// public async Task RemoveAsync(string sessionId, string ownerPod, CancellationToken cancellationToken = default) { try { await _db.ScriptEvaluateAsync( ReleaseScript, keys: new RedisKey[] { OwnerKeyPrefix + sessionId, KeyPrefix + sessionId }, values: new RedisValue[] { ownerPod }).WaitAsync(OperationTimeout, cancellationToken).ConfigureAwait(false); } catch (Exception ex) { _logger.LogWarning(ex, "Failed to remove session {SessionId}; it expires on its own.", sessionId); } } /// public async Task GetAsync(string sessionId, CancellationToken cancellationToken = default) { try { var raw = await _db.StringGetAsync(KeyPrefix + sessionId).WaitAsync(OperationTimeout, cancellationToken).ConfigureAwait(false); return raw.HasValue ? Deserialize(raw) : null; } catch (Exception ex) { _logger.LogWarning(ex, "Failed to read session {SessionId} from the directory.", sessionId); return null; } } /// public async Task> GetAllAsync(CancellationToken cancellationToken = default) { var entries = new List(); try { foreach (var server in _redis.GetServers()) { 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 values = await Task.WhenAll(keys.Select(key => _db.StringGetAsync(key))) .WaitAsync(OperationTimeout, cancellationToken).ConfigureAwait(false); foreach (var raw in values) { if (!raw.HasValue) { continue; } var entry = Deserialize(raw); if (entry?.Session is not null) { entries.Add(entry); } } } } catch (Exception ex) { // Degrade to the sessions this instance holds rather than failing the request outright. _logger.LogWarning(ex, "Failed to read the session directory; only local sessions are reported."); return Array.Empty(); } return entries; } private SessionDirectoryEntry? Deserialize(RedisValue raw) { try { return JsonSerializer.Deserialize(raw.ToString(), _jsonOptions); } catch (JsonException ex) { _logger.LogWarning(ex, "Failed to deserialize a session directory entry."); return null; } } }