Files
natsdotnet/tests/NATS.Server.Tests/MirrorSourceRetryTests.cs
Joseph Doherty 8fa16d59d2 feat(mirror): add exponential backoff retry, gap detection, and error tracking
Exposes public RecordFailure/RecordSuccess/GetRetryDelay (no-jitter, deterministic)
on MirrorCoordinator, plus RecordSourceSeq with HasGap/GapStart/GapEnd properties
and SetError/ClearError/HasError/ErrorMessage for error state. Makes IsDuplicate
and RecordMsgId public on SourceCoordinator and adds PruneDedupWindow(DateTimeOffset)
for explicit-cutoff dedup window pruning. Adds 5 unit tests in MirrorSourceRetryTests.
2026-02-25 02:30:55 -05:00

111 lines
3.3 KiB
C#

using NSubstitute;
using NATS.Server.JetStream.MirrorSource;
using NATS.Server.JetStream.Models;
using NATS.Server.JetStream.Storage;
namespace NATS.Server.Tests;
// Go reference: server/stream.go:3478-3505 (calculateRetryBackoff),
// server/stream.go:3125-3400 (setupMirrorConsumer retry logic)
public class MirrorSourceRetryTests
{
[Fact]
public void Mirror_retry_uses_exponential_backoff()
{
// Go reference: server/stream.go:3478-3505 calculateRetryBackoff
var mirror = MirrorCoordinatorTestHelper.Create();
mirror.RecordFailure();
var delay1 = mirror.GetRetryDelay();
delay1.ShouldBeGreaterThanOrEqualTo(TimeSpan.FromMilliseconds(250));
mirror.RecordFailure();
var delay2 = mirror.GetRetryDelay();
delay2.ShouldBeGreaterThan(delay1);
// Cap at max
for (int i = 0; i < 20; i++) mirror.RecordFailure();
var delayMax = mirror.GetRetryDelay();
delayMax.ShouldBeLessThanOrEqualTo(TimeSpan.FromSeconds(30));
}
[Fact]
public void Mirror_success_resets_backoff()
{
// Go reference: server/stream.go setupMirrorConsumer — success resets retry
var mirror = MirrorCoordinatorTestHelper.Create();
for (int i = 0; i < 5; i++) mirror.RecordFailure();
mirror.RecordSuccess();
var delay = mirror.GetRetryDelay();
delay.ShouldBe(TimeSpan.FromMilliseconds(250));
}
[Fact]
public void Mirror_tracks_sequence_gap()
{
// Go reference: server/stream.go:2863-3014 processInboundMirrorMsg — gap detection
var mirror = MirrorCoordinatorTestHelper.Create();
mirror.RecordSourceSeq(1);
mirror.RecordSourceSeq(2);
mirror.RecordSourceSeq(5); // gap: 3, 4 missing
mirror.HasGap.ShouldBeTrue();
mirror.GapStart.ShouldBe(3UL);
mirror.GapEnd.ShouldBe(4UL);
}
[Fact]
public void Mirror_tracks_error_state()
{
// Go reference: server/stream.go mirror error state tracking
var mirror = MirrorCoordinatorTestHelper.Create();
mirror.SetError("connection refused");
mirror.HasError.ShouldBeTrue();
mirror.ErrorMessage.ShouldBe("connection refused");
mirror.ClearError();
mirror.HasError.ShouldBeFalse();
}
[Fact]
public void Source_dedup_window_prunes_expired()
{
// Go reference: server/stream.go duplicate window pruning
var source = SourceCoordinatorTestHelper.Create();
source.RecordMsgId("msg-1");
source.RecordMsgId("msg-2");
source.IsDuplicate("msg-1").ShouldBeTrue();
source.IsDuplicate("msg-3").ShouldBeFalse();
// Simulate time passing beyond dedup window
source.PruneDedupWindow(DateTimeOffset.UtcNow.AddMinutes(5));
source.IsDuplicate("msg-1").ShouldBeFalse();
}
}
public static class MirrorCoordinatorTestHelper
{
public static MirrorCoordinator Create()
{
var store = Substitute.For<IStreamStore>();
return new MirrorCoordinator(store);
}
}
public static class SourceCoordinatorTestHelper
{
public static SourceCoordinator Create()
{
var store = Substitute.For<IStreamStore>();
var config = new StreamSourceConfig { Name = "test-source", DuplicateWindowMs = 60_000 };
return new SourceCoordinator(store, config);
}
}