Files
unkin-agent 3b416c7fb2 feat: gate periodic library tasks behind a scan-leader lease
Timer-driven library tasks fire on every replica, so a library refresh or a
database optimise runs once per pod against the same shared library.

- add IScanLeaderLease with a Redis TTL implementation and a no-op default
- skip timer-driven runs of the gated tasks on instances without the lease
- treat an unreachable Redis as holding the lease so tasks never stop running
- leave manual and API-triggered runs ungated
2026-09-11 23:56:51 +10:00

82 lines
3.1 KiB
C#

using System;
using System.Threading;
using System.Threading.Tasks;
using MediaBrowser.Controller.ScheduledTasks;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using StackExchange.Redis;
namespace Emby.Server.Implementations.ScheduledTasks;
/// <summary>
/// A Redis-backed <see cref="IScanLeaderLease"/> that elects a single scan-leader instance using a
/// TTL lease on a shared key. The lease value is this instance's pod identity; a leader that keeps
/// renewing retains the lease, and any instance can claim it once the previous leader's lease expires.
/// </summary>
public sealed class RedisScanLeaderLease : IScanLeaderLease
{
private const string LeaderKey = "jellyfin:scanleader";
/// <summary>
/// Lua script for atomic acquire-or-renew: if the key is unset (missing or already expired) it is
/// set to this pod for the lease duration and 1 is returned; if it already holds this pod the TTL is
/// extended and 1 is returned; otherwise another pod owns a live lease and 0 is returned.
/// </summary>
private const string AcquireOrRenewScript = @"
local current = redis.call('GET', KEYS[1])
if not current then
redis.call('SET', KEYS[1], ARGV[1], 'PX', ARGV[2])
return 1
elseif current == ARGV[1] then
redis.call('PEXPIRE', KEYS[1], ARGV[2])
return 1
else
return 0
end";
private readonly IDatabase _db;
private readonly ScanLeaderOptions _options;
private readonly ILogger<RedisScanLeaderLease> _logger;
private readonly string _podId;
/// <summary>
/// Initializes a new instance of the <see cref="RedisScanLeaderLease"/> class.
/// </summary>
/// <param name="redis">The Redis connection multiplexer.</param>
/// <param name="options">The scan-leader configuration options.</param>
/// <param name="logger">The logger.</param>
public RedisScanLeaderLease(
IConnectionMultiplexer redis,
IOptions<ScanLeaderOptions> options,
ILogger<RedisScanLeaderLease> logger)
{
_db = redis.GetDatabase();
_options = options.Value;
_logger = logger;
_podId = Environment.GetEnvironmentVariable("JELLYFIN_INSTANCE_ID") ?? Environment.MachineName;
}
/// <inheritdoc />
public async Task<bool> TryAcquireOrRenewAsync(CancellationToken cancellationToken = default)
{
var leaseDurationMs = (long)_options.LeaseDurationSeconds * 1000;
try
{
var result = (long?)await _db.ScriptEvaluateAsync(
AcquireOrRenewScript,
keys: new RedisKey[] { LeaderKey },
values: new RedisValue[] { _podId, leaseDurationMs }).ConfigureAwait(false);
return result == 1;
}
catch (Exception ex)
{
// Fail-safe: if Redis is unreachable, treat this instance as the leader so scheduled scans
// keep running. Every instance scanning is preferable to no instance scanning.
_logger.LogWarning(ex, "Scan-leader lease evaluation failed; treating {PodId} as leader.", _podId);
return true;
}
}
}