feat(mqtt): add session persistence, QoS 2 state machine, and retained store (E2+E3)

Add MqttSessionStore with save/load/delete/list operations, flapper
detection (backoff on rapid reconnects), and TimeProvider-based testing.
Add MqttRetainedStore for per-topic retained messages with MQTT wildcard
matching (+/# filters). Add MqttQos2StateMachine tracking the full
PUBREC/PUBREL/PUBCOMP flow with duplicate rejection and timeout detection.

19 new tests: 9 session persistence, 10 QoS/retained message tests.
This commit is contained in:
Joseph Doherty
2026-02-24 15:13:34 -05:00
parent 662b2e0d87
commit e49e5895c1
5 changed files with 774 additions and 1 deletions

View File

@@ -0,0 +1,190 @@
// MQTT QoS and retained message tests.
// Go reference: golang/nats-server/server/mqtt.go
// Retained messages — mqttHandleRetainedMsg / mqttGetRetainedMessages (~lines 16001700)
// QoS 2 flow — mqttProcessPubRec / mqttProcessPubRel / mqttProcessPubComp (~lines 13001400)
using System.Text;
using NATS.Server.Mqtt;
namespace NATS.Server.Tests.Mqtt;
public class MqttQosTests
{
[Fact]
public void RetainedStore_SetAndGet_RoundTrips()
{
// Go reference: server/mqtt.go mqttHandleRetainedMsg — store and retrieve
var store = new MqttRetainedStore();
var payload = Encoding.UTF8.GetBytes("temperature=72.5");
store.SetRetained("sensors/temp", payload);
var result = store.GetRetained("sensors/temp");
result.ShouldNotBeNull();
Encoding.UTF8.GetString(result.Value.Span).ShouldBe("temperature=72.5");
}
[Fact]
public void RetainedStore_EmptyPayload_ClearsRetained()
{
// Go reference: server/mqtt.go mqttHandleRetainedMsg — empty payload clears
var store = new MqttRetainedStore();
store.SetRetained("sensors/temp", Encoding.UTF8.GetBytes("old-value"));
store.SetRetained("sensors/temp", ReadOnlyMemory<byte>.Empty);
store.GetRetained("sensors/temp").ShouldBeNull();
}
[Fact]
public void RetainedStore_Overwrite_ReplacesOld()
{
// Go reference: server/mqtt.go mqttHandleRetainedMsg — overwrite replaces
var store = new MqttRetainedStore();
store.SetRetained("sensors/temp", Encoding.UTF8.GetBytes("first"));
store.SetRetained("sensors/temp", Encoding.UTF8.GetBytes("second"));
var result = store.GetRetained("sensors/temp");
result.ShouldNotBeNull();
Encoding.UTF8.GetString(result.Value.Span).ShouldBe("second");
}
[Fact]
public void RetainedStore_GetMatching_WildcardPlus()
{
// Go reference: server/mqtt.go mqttGetRetainedMessages — '+' single-level wildcard
var store = new MqttRetainedStore();
store.SetRetained("sensors/temp", Encoding.UTF8.GetBytes("72.5"));
store.SetRetained("sensors/humidity", Encoding.UTF8.GetBytes("45%"));
store.SetRetained("alerts/fire", Encoding.UTF8.GetBytes("!"));
var matches = store.GetMatchingRetained("sensors/+");
matches.Count.ShouldBe(2);
matches.Select(m => m.Topic).ShouldBe(
new[] { "sensors/temp", "sensors/humidity" },
ignoreOrder: true);
}
[Fact]
public void RetainedStore_GetMatching_WildcardHash()
{
// Go reference: server/mqtt.go mqttGetRetainedMessages — '#' multi-level wildcard
var store = new MqttRetainedStore();
store.SetRetained("home/living/temp", Encoding.UTF8.GetBytes("22"));
store.SetRetained("home/living/light", Encoding.UTF8.GetBytes("on"));
store.SetRetained("home/kitchen/temp", Encoding.UTF8.GetBytes("24"));
store.SetRetained("office/desk/light", Encoding.UTF8.GetBytes("off"));
var matches = store.GetMatchingRetained("home/#");
matches.Count.ShouldBe(3);
matches.Select(m => m.Topic).ShouldBe(
new[] { "home/living/temp", "home/living/light", "home/kitchen/temp" },
ignoreOrder: true);
}
[Fact]
public void Qos2_FullFlow_PubRecPubRelPubComp()
{
// Go reference: server/mqtt.go mqttProcessPubRec / mqttProcessPubRel / mqttProcessPubComp
var sm = new MqttQos2StateMachine();
// Begin publish
sm.BeginPublish(100).ShouldBeTrue();
sm.GetState(100).ShouldBe(MqttQos2State.AwaitingPubRec);
// PUBREC
sm.ProcessPubRec(100).ShouldBeTrue();
sm.GetState(100).ShouldBe(MqttQos2State.AwaitingPubRel);
// PUBREL
sm.ProcessPubRel(100).ShouldBeTrue();
sm.GetState(100).ShouldBe(MqttQos2State.AwaitingPubComp);
// PUBCOMP — completes and removes flow
sm.ProcessPubComp(100).ShouldBeTrue();
sm.GetState(100).ShouldBeNull();
}
[Fact]
public void Qos2_DuplicatePublish_Rejected()
{
// Go reference: server/mqtt.go — duplicate packet ID rejected during active flow
var sm = new MqttQos2StateMachine();
sm.BeginPublish(200).ShouldBeTrue();
// Same packet ID while flow is active — should be rejected
sm.BeginPublish(200).ShouldBeFalse();
}
[Fact]
public void Qos2_IncompleteFlow_TimesOut()
{
// Go reference: server/mqtt.go — incomplete QoS 2 flows time out
var fakeTime = new FakeTimeProvider(new DateTimeOffset(2026, 1, 15, 12, 0, 0, TimeSpan.Zero));
var sm = new MqttQos2StateMachine(timeout: TimeSpan.FromSeconds(5), timeProvider: fakeTime);
sm.BeginPublish(300).ShouldBeTrue();
// Not timed out yet
fakeTime.Advance(TimeSpan.FromSeconds(3));
sm.GetTimedOutFlows().ShouldBeEmpty();
// Advance past timeout
fakeTime.Advance(TimeSpan.FromSeconds(3));
var timedOut = sm.GetTimedOutFlows();
timedOut.Count.ShouldBe(1);
timedOut[0].ShouldBe((ushort)300);
// Clean up
sm.RemoveFlow(300);
sm.GetState(300).ShouldBeNull();
}
[Fact]
public void Qos1_Puback_RemovesPending()
{
// Go reference: server/mqtt.go — QoS 1 PUBACK removes from pending
// This tests the existing MqttListener pending publish / ack mechanism
// in the context of the session store.
var store = new MqttSessionStore();
var session = new MqttSessionData
{
ClientId = "qos1-client",
PendingPublishes =
[
new MqttPendingPublish(1, "topic/a", "payload-a"),
new MqttPendingPublish(2, "topic/b", "payload-b"),
],
};
store.SaveSession(session);
// Simulate PUBACK for packet 1: remove it from pending
var loaded = store.LoadSession("qos1-client");
loaded.ShouldNotBeNull();
loaded.PendingPublishes.RemoveAll(p => p.PacketId == 1);
store.SaveSession(loaded);
// Verify only packet 2 remains
var updated = store.LoadSession("qos1-client");
updated.ShouldNotBeNull();
updated.PendingPublishes.Count.ShouldBe(1);
updated.PendingPublishes[0].PacketId.ShouldBe(2);
}
[Fact]
public void RetainedStore_GetMatching_NoMatch_ReturnsEmpty()
{
// Go reference: server/mqtt.go mqttGetRetainedMessages — no match returns empty
var store = new MqttRetainedStore();
store.SetRetained("sensors/temp", Encoding.UTF8.GetBytes("72"));
var matches = store.GetMatchingRetained("alerts/+");
matches.ShouldBeEmpty();
}
}

View File

@@ -0,0 +1,209 @@
// MQTT session persistence tests.
// Go reference: golang/nats-server/server/mqtt.go:253-360
// Session store — mqttInitSessionStore / mqttStoreSession / mqttLoadSession
// Flapper detection — mqttCheckFlapper (~lines 300360)
using NATS.Server.Mqtt;
namespace NATS.Server.Tests.Mqtt;
public class MqttSessionPersistenceTests
{
[Fact]
public void SaveSession_ThenLoad_RoundTrips()
{
// Go reference: server/mqtt.go mqttStoreSession / mqttLoadSession
var store = new MqttSessionStore();
var session = new MqttSessionData
{
ClientId = "client-1",
Subscriptions = new Dictionary<string, int> { ["sensors/temp"] = 1, ["alerts/#"] = 0 },
PendingPublishes = [new MqttPendingPublish(42, "sensors/temp", "72.5")],
WillTopic = "clients/offline",
WillPayload = [0x01, 0x02],
WillQoS = 1,
WillRetain = true,
CleanSession = false,
ConnectedAtUtc = new DateTime(2026, 1, 15, 10, 30, 0, DateTimeKind.Utc),
LastActivityUtc = new DateTime(2026, 1, 15, 10, 35, 0, DateTimeKind.Utc),
};
store.SaveSession(session);
var loaded = store.LoadSession("client-1");
loaded.ShouldNotBeNull();
loaded.ClientId.ShouldBe("client-1");
loaded.Subscriptions.Count.ShouldBe(2);
loaded.Subscriptions["sensors/temp"].ShouldBe(1);
loaded.Subscriptions["alerts/#"].ShouldBe(0);
loaded.PendingPublishes.Count.ShouldBe(1);
loaded.PendingPublishes[0].PacketId.ShouldBe(42);
loaded.PendingPublishes[0].Topic.ShouldBe("sensors/temp");
loaded.PendingPublishes[0].Payload.ShouldBe("72.5");
loaded.WillTopic.ShouldBe("clients/offline");
loaded.WillPayload.ShouldBe(new byte[] { 0x01, 0x02 });
loaded.WillQoS.ShouldBe(1);
loaded.WillRetain.ShouldBeTrue();
loaded.CleanSession.ShouldBeFalse();
loaded.ConnectedAtUtc.ShouldBe(new DateTime(2026, 1, 15, 10, 30, 0, DateTimeKind.Utc));
loaded.LastActivityUtc.ShouldBe(new DateTime(2026, 1, 15, 10, 35, 0, DateTimeKind.Utc));
}
[Fact]
public void SaveSession_Update_OverwritesPrevious()
{
// Go reference: server/mqtt.go mqttStoreSession — overwrites existing
var store = new MqttSessionStore();
store.SaveSession(new MqttSessionData
{
ClientId = "client-x",
Subscriptions = new Dictionary<string, int> { ["old/topic"] = 0 },
});
store.SaveSession(new MqttSessionData
{
ClientId = "client-x",
Subscriptions = new Dictionary<string, int> { ["new/topic"] = 1 },
});
var loaded = store.LoadSession("client-x");
loaded.ShouldNotBeNull();
loaded.Subscriptions.ShouldContainKey("new/topic");
loaded.Subscriptions.ShouldNotContainKey("old/topic");
}
[Fact]
public void LoadSession_NonExistent_ReturnsNull()
{
// Go reference: server/mqtt.go mqttLoadSession — returns nil for missing
var store = new MqttSessionStore();
var loaded = store.LoadSession("does-not-exist");
loaded.ShouldBeNull();
}
[Fact]
public void DeleteSession_RemovesFromStore()
{
// Go reference: server/mqtt.go mqttDeleteSession
var store = new MqttSessionStore();
store.SaveSession(new MqttSessionData { ClientId = "to-delete" });
store.DeleteSession("to-delete");
store.LoadSession("to-delete").ShouldBeNull();
}
[Fact]
public void DeleteSession_NonExistent_NoError()
{
// Go reference: server/mqtt.go mqttDeleteSession — no-op on missing
var store = new MqttSessionStore();
// Should not throw
store.DeleteSession("phantom");
store.LoadSession("phantom").ShouldBeNull();
}
[Fact]
public void ListSessions_ReturnsAllActive()
{
// Go reference: server/mqtt.go session enumeration
var store = new MqttSessionStore();
store.SaveSession(new MqttSessionData { ClientId = "alpha" });
store.SaveSession(new MqttSessionData { ClientId = "beta" });
store.SaveSession(new MqttSessionData { ClientId = "gamma" });
var sessions = store.ListSessions();
sessions.Count.ShouldBe(3);
sessions.Select(s => s.ClientId).ShouldBe(
new[] { "alpha", "beta", "gamma" },
ignoreOrder: true);
}
[Fact]
public void FlapperDetection_ThreeConnectsInTenSeconds_BackoffApplied()
{
// Go reference: server/mqtt.go mqttCheckFlapper ~line 300
// Three connects within the flap window triggers backoff.
var fakeTime = new FakeTimeProvider(new DateTimeOffset(2026, 1, 15, 12, 0, 0, TimeSpan.Zero));
var store = new MqttSessionStore(
flapWindow: TimeSpan.FromSeconds(10),
flapThreshold: 3,
flapBackoff: TimeSpan.FromSeconds(1),
timeProvider: fakeTime);
// Three rapid connects
store.TrackConnectDisconnect("flapper", connected: true);
fakeTime.Advance(TimeSpan.FromSeconds(1));
store.TrackConnectDisconnect("flapper", connected: true);
fakeTime.Advance(TimeSpan.FromSeconds(1));
store.TrackConnectDisconnect("flapper", connected: true);
var backoff = store.ShouldApplyBackoff("flapper");
backoff.ShouldBeGreaterThan(TimeSpan.Zero);
backoff.ShouldBe(TimeSpan.FromSeconds(1));
}
[Fact]
public void FlapperDetection_SlowConnects_NoBackoff()
{
// Go reference: server/mqtt.go mqttCheckFlapper — slow connects should not trigger
var fakeTime = new FakeTimeProvider(new DateTimeOffset(2026, 1, 15, 12, 0, 0, TimeSpan.Zero));
var store = new MqttSessionStore(
flapWindow: TimeSpan.FromSeconds(10),
flapThreshold: 3,
flapBackoff: TimeSpan.FromSeconds(1),
timeProvider: fakeTime);
// Three connects, but spread out beyond the window
store.TrackConnectDisconnect("slow-client", connected: true);
fakeTime.Advance(TimeSpan.FromSeconds(5));
store.TrackConnectDisconnect("slow-client", connected: true);
fakeTime.Advance(TimeSpan.FromSeconds(6)); // first connect now outside window
store.TrackConnectDisconnect("slow-client", connected: true);
var backoff = store.ShouldApplyBackoff("slow-client");
backoff.ShouldBe(TimeSpan.Zero);
}
[Fact]
public void CleanSession_DeletesOnConnect()
{
// Go reference: server/mqtt.go — clean session flag clears stored state
var store = new MqttSessionStore();
// Pre-populate a session
store.SaveSession(new MqttSessionData
{
ClientId = "ephemeral",
Subscriptions = new Dictionary<string, int> { ["topic/a"] = 1 },
CleanSession = false,
});
store.LoadSession("ephemeral").ShouldNotBeNull();
// Simulate clean session connect: delete the old session
store.DeleteSession("ephemeral");
store.LoadSession("ephemeral").ShouldBeNull();
}
}
/// <summary>
/// Fake <see cref="TimeProvider"/> for deterministic time control in tests.
/// </summary>
internal sealed class FakeTimeProvider(DateTimeOffset startTime) : TimeProvider
{
private DateTimeOffset _current = startTime;
public override DateTimeOffset GetUtcNow() => _current;
public void Advance(TimeSpan duration) => _current += duration;
public void SetUtcNow(DateTimeOffset value) => _current = value;
}