Files
natsdotnet/tests/NATS.Server.Tests/Mqtt/MqttFlapperDetectionTests.cs
Joseph Doherty 5fea08dda0 feat: complete MQTT session flapper detection (Gap 6.6)
Add FlapperState class and per-client exponential backoff tracking to
MqttSessionStore. New TrackConnectDisconnect(string) overload returns
FlapperState with backoff level and expiry; IsFlapper, GetBackoffMs,
ClearFlapperState, and CheckAndClearStableClients give callers full
visibility and cleanup control. Legacy two-arg overload preserved for
backward compatibility. Ten unit tests cover counting, threshold,
exponential growth, 60s cap, window reset, and stable-client sweep.
2026-02-25 11:42:24 -05:00

249 lines
9.7 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// MQTT flapper detection tests — exponential backoff for rapid reconnectors.
// Go reference: golang/nats-server/server/mqtt.go mqttCheckFlapper ~lines 300360.
using NATS.Server.Mqtt;
using Shouldly;
namespace NATS.Server.Tests.Mqtt;
public class MqttFlapperDetectionTests
{
// -------------------------------------------------------------------------
// Helper: FakeTimeProvider from Microsoft.Extensions.TimeProvider.Testing
// -------------------------------------------------------------------------
private static MqttSessionStore CreateStore(
FakeTimeProvider? time = null,
int flapThreshold = 3,
TimeSpan? flapWindow = null) =>
new(
flapWindow: flapWindow ?? TimeSpan.FromSeconds(10),
flapThreshold: flapThreshold,
timeProvider: time);
// -------------------------------------------------------------------------
// 1. TrackConnectDisconnect_counts_events
// -------------------------------------------------------------------------
[Fact]
public void TrackConnectDisconnect_counts_events()
{
// Go reference: server/mqtt.go mqttCheckFlapper — each connect increments the counter.
var store = CreateStore();
var s1 = store.TrackConnectDisconnect("client-a");
s1.ConnectDisconnectCount.ShouldBe(1);
var s2 = store.TrackConnectDisconnect("client-a");
s2.ConnectDisconnectCount.ShouldBe(2);
}
// -------------------------------------------------------------------------
// 2. Not_flapper_below_threshold
// -------------------------------------------------------------------------
[Fact]
public void Not_flapper_below_threshold()
{
// Go reference: server/mqtt.go mqttCheckFlapper — threshold is 3; 2 events should not mark as flapper.
var store = CreateStore();
store.TrackConnectDisconnect("client-b");
store.TrackConnectDisconnect("client-b");
store.IsFlapper("client-b").ShouldBeFalse();
}
// -------------------------------------------------------------------------
// 3. Becomes_flapper_at_threshold
// -------------------------------------------------------------------------
[Fact]
public void Becomes_flapper_at_threshold()
{
// Go reference: server/mqtt.go mqttCheckFlapper — 3 events within window marks the client.
var time = new FakeTimeProvider(DateTimeOffset.UtcNow);
var store = CreateStore(time);
store.TrackConnectDisconnect("client-c");
time.Advance(TimeSpan.FromSeconds(1));
store.TrackConnectDisconnect("client-c");
time.Advance(TimeSpan.FromSeconds(1));
store.TrackConnectDisconnect("client-c");
store.IsFlapper("client-c").ShouldBeTrue();
}
// -------------------------------------------------------------------------
// 4. Backoff_increases_exponentially
// -------------------------------------------------------------------------
[Fact]
public void Backoff_increases_exponentially()
{
// Go reference: server/mqtt.go mqttCheckFlapper — backoff doubles on each new flap trigger.
// Level 0 → 1 s, Level 1 → 2 s, Level 2 → 4 s.
var time = new FakeTimeProvider(DateTimeOffset.UtcNow);
var store = CreateStore(time);
// First flap at level 0 (1 s backoff)
store.TrackConnectDisconnect("client-d");
store.TrackConnectDisconnect("client-d");
var s1 = store.TrackConnectDisconnect("client-d");
s1.BackoffLevel.ShouldBe(1); // incremented after applying level 0
s1.BackoffUntil.ShouldNotBeNull();
var backoff1 = s1.BackoffUntil!.Value - time.GetUtcNow().UtcDateTime;
backoff1.TotalMilliseconds.ShouldBeInRange(900, 1100); // ~1 000 ms
// Advance past the backoff and trigger again — level 1 (2 s)
time.Advance(TimeSpan.FromSeconds(2));
var s2 = store.TrackConnectDisconnect("client-d");
s2.BackoffLevel.ShouldBe(2);
var backoff2 = s2.BackoffUntil!.Value - time.GetUtcNow().UtcDateTime;
backoff2.TotalMilliseconds.ShouldBeInRange(1900, 2100); // ~2 000 ms
// Advance past and trigger once more — level 2 (4 s)
time.Advance(TimeSpan.FromSeconds(3));
var s3 = store.TrackConnectDisconnect("client-d");
s3.BackoffLevel.ShouldBe(3);
var backoff3 = s3.BackoffUntil!.Value - time.GetUtcNow().UtcDateTime;
backoff3.TotalMilliseconds.ShouldBeInRange(3900, 4100); // ~4 000 ms
}
// -------------------------------------------------------------------------
// 5. Backoff_capped_at_60_seconds
// -------------------------------------------------------------------------
[Fact]
public void Backoff_capped_at_60_seconds()
{
// Go reference: server/mqtt.go mqttCheckFlapper — cap the maximum backoff at 60 s.
var time = new FakeTimeProvider(DateTimeOffset.UtcNow);
var store = CreateStore(time);
// Trigger enough flaps to overflow past 60 s (level 6 = 64 s, which should cap at 60 s)
for (var i = 0; i < 10; i++)
{
store.TrackConnectDisconnect("client-e");
time.Advance(TimeSpan.FromMilliseconds(100));
}
var state = store.TrackConnectDisconnect("client-e");
var remaining = state.BackoffUntil!.Value - time.GetUtcNow().UtcDateTime;
remaining.TotalMilliseconds.ShouldBeLessThanOrEqualTo(60_001); // max 60 s (±1 ms tolerance)
remaining.TotalMilliseconds.ShouldBeGreaterThan(0);
}
// -------------------------------------------------------------------------
// 6. GetBackoffMs_returns_remaining
// -------------------------------------------------------------------------
[Fact]
public void GetBackoffMs_returns_remaining()
{
// Go reference: server/mqtt.go mqttCheckFlapper — caller can query remaining backoff time.
var time = new FakeTimeProvider(DateTimeOffset.UtcNow);
var store = CreateStore(time);
store.TrackConnectDisconnect("client-f");
store.TrackConnectDisconnect("client-f");
store.TrackConnectDisconnect("client-f"); // threshold hit
var ms = store.GetBackoffMs("client-f");
ms.ShouldBeGreaterThan(0);
ms.ShouldBeLessThanOrEqualTo(1000);
}
// -------------------------------------------------------------------------
// 7. GetBackoffMs_zero_when_not_flapping
// -------------------------------------------------------------------------
[Fact]
public void GetBackoffMs_zero_when_not_flapping()
{
// Not enough events to trigger backoff — remaining ms should be 0.
var store = CreateStore();
store.TrackConnectDisconnect("client-g");
store.TrackConnectDisconnect("client-g");
store.GetBackoffMs("client-g").ShouldBe(0);
}
// -------------------------------------------------------------------------
// 8. ClearFlapperState_removes_tracking
// -------------------------------------------------------------------------
[Fact]
public void ClearFlapperState_removes_tracking()
{
// Go reference: server/mqtt.go — stable clients should have state purged.
var store = CreateStore();
store.TrackConnectDisconnect("client-h");
store.TrackConnectDisconnect("client-h");
store.TrackConnectDisconnect("client-h");
store.IsFlapper("client-h").ShouldBeTrue();
store.ClearFlapperState("client-h");
store.IsFlapper("client-h").ShouldBeFalse();
store.GetBackoffMs("client-h").ShouldBe(0);
}
// -------------------------------------------------------------------------
// 9. Window_resets_after_10_seconds
// -------------------------------------------------------------------------
[Fact]
public void Window_resets_after_10_seconds()
{
// Go reference: server/mqtt.go mqttCheckFlapper — window-based detection resets.
// Track 2 events, advance past the window, add 1 more — should NOT be a flapper.
var time = new FakeTimeProvider(DateTimeOffset.UtcNow);
var store = CreateStore(time);
store.TrackConnectDisconnect("client-i");
store.TrackConnectDisconnect("client-i");
// Advance past the 10 s flap window
time.Advance(TimeSpan.FromSeconds(11));
// Directly set the WindowStart via the returned state to simulate the old window
// being in the past. A single new event in a new window should not cross threshold.
store.TrackConnectDisconnect("client-i");
store.IsFlapper("client-i").ShouldBeFalse();
}
// -------------------------------------------------------------------------
// 10. CheckAndClearStableClients_clears_old
// -------------------------------------------------------------------------
[Fact]
public void CheckAndClearStableClients_clears_old()
{
// Go reference: server/mqtt.go — periodic sweep clears long-stable flapper records.
var time = new FakeTimeProvider(DateTimeOffset.UtcNow);
var store = CreateStore(time);
// Trigger flap
store.TrackConnectDisconnect("client-j");
store.TrackConnectDisconnect("client-j");
var state = store.TrackConnectDisconnect("client-j");
store.IsFlapper("client-j").ShouldBeTrue();
// Manually backdate BackoffUntil so it's already expired
lock (state)
{
state.BackoffUntil = time.GetUtcNow().UtcDateTime - TimeSpan.FromSeconds(61);
}
// A stable-threshold sweep of 60 s should evict the now-expired entry
store.CheckAndClearStableClients(TimeSpan.FromSeconds(60));
store.IsFlapper("client-j").ShouldBeFalse();
store.GetBackoffMs("client-j").ShouldBe(0);
}
}