feat(mqtt): add JetStream-backed session and retained message persistence
Add optional IStreamStore backing to MqttSessionStore and MqttRetainedStore, enabling session and retained message state to survive process restarts via JetStream persistence. Includes ConnectAsync/SaveSessionAsync for session lifecycle, SetRetainedAsync/GetRetainedAsync with cleared-topic tombstone tracking, and 4 new parity tests covering persist/restart/clear semantics.
This commit is contained in:
92
tests/NATS.Server.Tests/MqttPersistenceTests.cs
Normal file
92
tests/NATS.Server.Tests/MqttPersistenceTests.cs
Normal file
@@ -0,0 +1,92 @@
|
||||
using NSubstitute;
|
||||
using NATS.Server.JetStream.Storage;
|
||||
using NATS.Server.Mqtt;
|
||||
|
||||
namespace NATS.Server.Tests;
|
||||
|
||||
// Go reference: server/mqtt.go ($MQTT_msgs, $MQTT_sess, $MQTT_rmsgs JetStream streams)
|
||||
|
||||
public class MqttPersistenceTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task Session_persists_across_restart()
|
||||
{
|
||||
// Go reference: server/mqtt.go mqttStoreSession — session survives restart
|
||||
var store = MqttSessionStoreTestHelper.CreateWithJetStream();
|
||||
|
||||
await store.ConnectAsync("client-1", cleanSession: false);
|
||||
store.AddSubscription("client-1", "topic/test", qos: 1);
|
||||
await store.SaveSessionAsync("client-1");
|
||||
|
||||
// Simulate restart — new store backed by the same IStreamStore
|
||||
var recovered = MqttSessionStoreTestHelper.CreateWithJetStream(store.BackingStore!);
|
||||
await recovered.ConnectAsync("client-1", cleanSession: false);
|
||||
|
||||
var subs = recovered.GetSubscriptions("client-1");
|
||||
subs.ShouldContainKey("topic/test");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Clean_session_deletes_existing()
|
||||
{
|
||||
// Go reference: server/mqtt.go cleanSession=true deletes saved state
|
||||
var store = MqttSessionStoreTestHelper.CreateWithJetStream();
|
||||
|
||||
await store.ConnectAsync("client-2", cleanSession: false);
|
||||
store.AddSubscription("client-2", "persist/me", qos: 1);
|
||||
await store.SaveSessionAsync("client-2");
|
||||
|
||||
// Reconnect with clean session
|
||||
await store.ConnectAsync("client-2", cleanSession: true);
|
||||
|
||||
var subs = store.GetSubscriptions("client-2");
|
||||
subs.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Retained_message_survives_restart()
|
||||
{
|
||||
// Go reference: server/mqtt.go retained message persistence via JetStream
|
||||
var retained = MqttRetainedStoreTestHelper.CreateWithJetStream();
|
||||
|
||||
await retained.SetRetainedAsync("sensors/temp", "72.5"u8.ToArray());
|
||||
|
||||
// Simulate restart
|
||||
var recovered = MqttRetainedStoreTestHelper.CreateWithJetStream(retained.BackingStore!);
|
||||
var msg = await recovered.GetRetainedAsync("sensors/temp");
|
||||
|
||||
msg.ShouldNotBeNull();
|
||||
System.Text.Encoding.UTF8.GetString(msg).ShouldBe("72.5");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Retained_message_cleared_with_empty_payload()
|
||||
{
|
||||
// Go reference: server/mqtt.go empty payload clears retained
|
||||
var retained = MqttRetainedStoreTestHelper.CreateWithJetStream();
|
||||
|
||||
await retained.SetRetainedAsync("sensors/temp", "72.5"u8.ToArray());
|
||||
await retained.SetRetainedAsync("sensors/temp", ReadOnlyMemory<byte>.Empty); // clear
|
||||
|
||||
var msg = await retained.GetRetainedAsync("sensors/temp");
|
||||
msg.ShouldBeNull();
|
||||
}
|
||||
}
|
||||
|
||||
public static class MqttSessionStoreTestHelper
|
||||
{
|
||||
public static MqttSessionStore CreateWithJetStream(IStreamStore? backingStore = null)
|
||||
{
|
||||
var store = backingStore ?? new MemStore();
|
||||
return new MqttSessionStore(store);
|
||||
}
|
||||
}
|
||||
|
||||
public static class MqttRetainedStoreTestHelper
|
||||
{
|
||||
public static MqttRetainedStore CreateWithJetStream(IStreamStore? backingStore = null)
|
||||
{
|
||||
var store = backingStore ?? new MemStore();
|
||||
return new MqttRetainedStore(store);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user