using System;
using System.Threading;
using System.Threading.Tasks;
using Emby.Server.Implementations.ScheduledTasks;
using MediaBrowser.Controller.ScheduledTasks;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using Moq;
using StackExchange.Redis;
using Xunit;
namespace Jellyfin.Server.Implementations.Tests.ScheduledTasks;
///
/// Tests for scan-leader lease behavior. The acquire/renew/takeover state machine is exercised
/// through an in-memory reference implementation that mirrors the Redis Lua contract (no real Redis
/// required), while the fail-safe and success paths of are
/// exercised against a mocked .
///
public class ScanLeaderLeaseTests
{
///
/// Verifies that the first instance to call the lease becomes the leader.
///
[Fact]
[Trait("Category", "UnitTest")]
public async Task Acquire_WhenUnheld_ReturnsTrue()
{
var store = new FakeLeaderStore();
var clock = new TestClock(DateTime.UtcNow);
var podA = new ReferenceScanLeaderLease(store, "pod-a", clock, TimeSpan.FromSeconds(60));
Assert.True(await podA.TryAcquireOrRenewAsync(TestContext.Current.CancellationToken));
Assert.Equal("pod-a", store.Owner);
}
///
/// Verifies that the current leader renewing its own lease succeeds.
///
[Fact]
[Trait("Category", "UnitTest")]
public async Task Renew_BySameInstance_ReturnsTrue()
{
var store = new FakeLeaderStore();
var clock = new TestClock(DateTime.UtcNow);
var podA = new ReferenceScanLeaderLease(store, "pod-a", clock, TimeSpan.FromSeconds(60));
Assert.True(await podA.TryAcquireOrRenewAsync(TestContext.Current.CancellationToken));
clock.Advance(TimeSpan.FromSeconds(10));
Assert.True(await podA.TryAcquireOrRenewAsync(TestContext.Current.CancellationToken));
Assert.Equal("pod-a", store.Owner);
}
///
/// Verifies that a second instance cannot acquire the lease while the leader's lease is still valid.
///
[Fact]
[Trait("Category", "UnitTest")]
public async Task Acquire_BySecondInstance_WhileLeaseValid_ReturnsFalse()
{
var store = new FakeLeaderStore();
var clock = new TestClock(DateTime.UtcNow);
var podA = new ReferenceScanLeaderLease(store, "pod-a", clock, TimeSpan.FromSeconds(60));
var podB = new ReferenceScanLeaderLease(store, "pod-b", clock, TimeSpan.FromSeconds(60));
Assert.True(await podA.TryAcquireOrRenewAsync(TestContext.Current.CancellationToken));
clock.Advance(TimeSpan.FromSeconds(30));
Assert.False(await podB.TryAcquireOrRenewAsync(TestContext.Current.CancellationToken));
Assert.Equal("pod-a", store.Owner);
}
///
/// Verifies that a second instance takes over the lease once the previous leader's lease has expired.
///
[Fact]
[Trait("Category", "UnitTest")]
public async Task Acquire_BySecondInstance_AfterLeaseExpires_ReturnsTrue()
{
var store = new FakeLeaderStore();
var clock = new TestClock(DateTime.UtcNow);
var podA = new ReferenceScanLeaderLease(store, "pod-a", clock, TimeSpan.FromSeconds(60));
var podB = new ReferenceScanLeaderLease(store, "pod-b", clock, TimeSpan.FromSeconds(60));
Assert.True(await podA.TryAcquireOrRenewAsync(TestContext.Current.CancellationToken));
// Advance past pod-a's lease expiry without pod-a renewing.
clock.Advance(TimeSpan.FromSeconds(61));
Assert.True(await podB.TryAcquireOrRenewAsync(TestContext.Current.CancellationToken));
Assert.Equal("pod-b", store.Owner);
}
///
/// Verifies that always reports the caller as the leader.
///
[Fact]
[Trait("Category", "UnitTest")]
public async Task NullScanLeaderLease_AlwaysReturnsTrue()
{
var lease = new NullScanLeaderLease();
Assert.True(await lease.TryAcquireOrRenewAsync(TestContext.Current.CancellationToken));
Assert.True(await lease.TryAcquireOrRenewAsync(TestContext.Current.CancellationToken));
}
///
/// Verifies that returns true (fail-safe) when the Redis
/// evaluation throws, so that scheduled scans keep running when Redis is unreachable.
///
[Fact]
[Trait("Category", "UnitTest")]
public async Task RedisScanLeaderLease_WhenRedisThrows_ReturnsTrue()
{
var dbMock = new Mock();
dbMock
.Setup(d => d.ScriptEvaluateAsync(
It.IsAny(),
It.IsAny(),
It.IsAny(),
It.IsAny()))
.ThrowsAsync(new InvalidOperationException("Redis unavailable"));
var lease = new RedisScanLeaderLease(
CreateMultiplexer(dbMock.Object),
Options.Create(new ScanLeaderOptions { Enabled = true, LeaseDurationSeconds = 60 }),
new Mock>().Object);
Assert.True(await lease.TryAcquireOrRenewAsync(TestContext.Current.CancellationToken));
}
///
/// Verifies that reports leadership when the Redis script
/// returns 1 (lease acquired or renewed).
///
[Fact]
[Trait("Category", "UnitTest")]
public async Task RedisScanLeaderLease_WhenScriptReturnsOne_ReturnsTrue()
{
var dbMock = new Mock();
dbMock
.Setup(d => d.ScriptEvaluateAsync(
It.IsAny(),
It.IsAny(),
It.IsAny(),
It.IsAny()))
.ReturnsAsync(RedisResult.Create((RedisValue)1L));
var lease = new RedisScanLeaderLease(
CreateMultiplexer(dbMock.Object),
Options.Create(new ScanLeaderOptions { Enabled = true, LeaseDurationSeconds = 60 }),
new Mock>().Object);
Assert.True(await lease.TryAcquireOrRenewAsync(TestContext.Current.CancellationToken));
}
///
/// Verifies that reports non-leadership when the Redis script
/// returns 0 (another instance holds a live lease).
///
[Fact]
[Trait("Category", "UnitTest")]
public async Task RedisScanLeaderLease_WhenScriptReturnsZero_ReturnsFalse()
{
var dbMock = new Mock();
dbMock
.Setup(d => d.ScriptEvaluateAsync(
It.IsAny(),
It.IsAny(),
It.IsAny(),
It.IsAny()))
.ReturnsAsync(RedisResult.Create((RedisValue)0L));
var lease = new RedisScanLeaderLease(
CreateMultiplexer(dbMock.Object),
Options.Create(new ScanLeaderOptions { Enabled = true, LeaseDurationSeconds = 60 }),
new Mock>().Object);
Assert.False(await lease.TryAcquireOrRenewAsync(TestContext.Current.CancellationToken));
}
private static IConnectionMultiplexer CreateMultiplexer(IDatabase database)
{
var muxMock = new Mock();
muxMock
.Setup(m => m.GetDatabase(It.IsAny(), It.IsAny