Skip to content
Open
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion src/KubernetesClient/LeaderElection/LeaderElector.cs
Original file line number Diff line number Diff line change
Expand Up @@ -192,7 +192,7 @@ private async Task<bool> TryAcquireOrRenew(CancellationToken cancellationToken)
}

if (!string.IsNullOrEmpty(oldLeaderElectionRecord.HolderIdentity)
&& observedTime + config.LeaseDuration > DateTimeOffset.Now
&& observedTime + TimeSpan.FromSeconds(oldLeaderElectionRecord.LeaseDurationSeconds) > DateTimeOffset.Now
&& !IsLeader())
{
// lock is held by %v and has not yet expired", oldLeaderElectionRecord.HolderIdentity
Expand Down
70 changes: 70 additions & 0 deletions tests/KubernetesClient.Tests/LeaderElection/LeaderElectionTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -409,6 +409,76 @@ public void LeaderElectionShouldReportLeaderItAcquiresOnStart()
Assert.True(notifications.SequenceEqual(new[] { "foo1" }));
}

[Fact]
public void LeaderElectionUsesActualLeaseDurationFromKubernetesObject()
{
// This test validates that the actual lease duration from the Kubernetes object
// is used instead of the configured lease duration when checking if a lease has expired.
// This is critical for graceful step-downs where a leader sets lease duration to 1 second.

var l = new Mock<ILock>();
l.Setup(obj => obj.Identity).Returns("client1");

var firstCallTime = DateTime.UtcNow;
var callCount = 0;

l.Setup(obj => obj.GetAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(() =>
{
callCount++;
// Return a lease held by another client with a short 1-second duration (graceful step-down)
return new LeaderElectionRecord()
{
HolderIdentity = "client2",
AcquireTime = firstCallTime.AddSeconds(-5),
RenewTime = firstCallTime,
LeaderTransitions = 1,
LeaseDurationSeconds = 1, // Actual lease duration is 1 second (not the configured 10 seconds)
};
});

var updateCalled = false;
l.Setup(obj => obj.UpdateAsync(It.IsAny<LeaderElectionRecord>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(() =>
{
updateCalled = true;
return true;
});

var leaderElector = new LeaderElector(new LeaderElectionConfig(l.Object)
{
LeaseDuration = TimeSpan.FromSeconds(10), // Configured for 10 seconds
RetryPeriod = TimeSpan.FromMilliseconds(200),
RenewDeadline = TimeSpan.FromSeconds(9),
});

var cts = new CancellationTokenSource(TimeSpan.FromSeconds(3));

// Run the leader election
var task = Task.Run(async () =>
{
try
{
await leaderElector.RunUntilLeadershipLostAsync(cts.Token).ConfigureAwait(false);
}
catch (OperationCanceledException)
{
// Expected when timeout occurs
}
});

// Wait for the task to complete or timeout
task.Wait(TimeSpan.FromSeconds(4));

// The key assertion: With the fix, the lease should be recognized as expired after ~1 second
// (the actual lease duration from the K8s object), not after 10 seconds (the configured duration).
// Therefore, UpdateAsync should have been called to attempt to acquire leadership.
Assert.True(updateCalled,
"UpdateAsync should have been called after the actual lease duration (1 second) expired, " +
"not after the configured lease duration (10 seconds). This test validates that the fix " +
"correctly uses oldLeaderElectionRecord.LeaseDurationSeconds instead of config.LeaseDuration.");
}

private class MockResourceLock : ILock
{
private static LeaderElectionRecord leaderRecord;
Expand Down
Loading