Phase 1: Binary MQTT 3.1.1 wire protocol with PipeReader-based parsing, full packet type dispatch, and MQTT 3.1.1 compliance checks. Phase 2: Auth pipeline routing MQTT CONNECT through AuthService, TLS transport with SslStream wrapping, pinned cert validation. Phase 3: IMessageRouter refactor (NatsClient → INatsClient), MqttNatsClientAdapter for cross-protocol bridging, MqttTopicMapper with full Go-parity topic/subject translation. Phase 4: /connz mqtt_client field population, /varz actual MQTT port. Phase 5: JetStream persistence — MqttStreamInitializer creates 5 internal streams, MqttConsumerManager for QoS 1/2 consumers, subject-keyed session/retained lookups replacing linear scans. All 503 MQTT tests and 1589 Core tests pass.
340 lines
14 KiB
C#
340 lines
14 KiB
C#
using NATS.Server.JetStream;
|
|
using NATS.Server.JetStream.Models;
|
|
using NATS.Server.JetStream.Storage;
|
|
using NATS.Server.Mqtt;
|
|
// Retained/Session store tests use MemStore + StreamConfig directly
|
|
|
|
namespace NATS.Server.Mqtt.Tests;
|
|
|
|
/// <summary>
|
|
/// Tests for MQTT JetStream persistence: stream initialization, consumer management,
|
|
/// QoS 1/2 flow with JetStream backing, session persistence, and retained message persistence.
|
|
/// Go reference: server/mqtt.go mqttCreateAccountSessionManager, mqttStoreSession,
|
|
/// mqttHandleRetainedMsg, trackPublish.
|
|
/// </summary>
|
|
public class MqttJetStreamPersistenceTests
|
|
{
|
|
// -----------------------------------------------------------------------
|
|
// MqttStreamInitializer
|
|
// -----------------------------------------------------------------------
|
|
|
|
[Fact]
|
|
public void StreamInitializer_creates_all_five_streams()
|
|
{
|
|
// Go reference: server/mqtt.go mqttCreateAccountSessionManager creates 5 streams
|
|
var (streamMgr, _, initializer) = CreateJetStreamInfra();
|
|
|
|
initializer.IsInitialized.ShouldBeFalse();
|
|
initializer.EnsureStreams();
|
|
initializer.IsInitialized.ShouldBeTrue();
|
|
|
|
streamMgr.Exists(MqttProtocolConstants.SessStreamName).ShouldBeTrue();
|
|
streamMgr.Exists(MqttProtocolConstants.StreamName).ShouldBeTrue();
|
|
streamMgr.Exists(MqttProtocolConstants.RetainedMsgsStreamName).ShouldBeTrue();
|
|
streamMgr.Exists(MqttProtocolConstants.QoS2IncomingMsgsStreamName).ShouldBeTrue();
|
|
streamMgr.Exists(MqttProtocolConstants.OutStreamName).ShouldBeTrue();
|
|
}
|
|
|
|
[Fact]
|
|
public void StreamInitializer_is_idempotent()
|
|
{
|
|
var (streamMgr, _, initializer) = CreateJetStreamInfra();
|
|
|
|
initializer.EnsureStreams();
|
|
initializer.EnsureStreams(); // should not throw
|
|
|
|
streamMgr.StreamNames.Count.ShouldBe(5);
|
|
}
|
|
|
|
// -----------------------------------------------------------------------
|
|
// MqttConsumerManager — subscription consumers
|
|
// -----------------------------------------------------------------------
|
|
|
|
[Fact]
|
|
public void ConsumerManager_creates_subscription_consumer()
|
|
{
|
|
// Go reference: server/mqtt.go mqttProcessSub — creates durable consumer per QoS>0 sub
|
|
var (streamMgr, consumerMgr, initializer) = CreateJetStreamInfra();
|
|
initializer.EnsureStreams();
|
|
var mqttConsumerMgr = new MqttConsumerManager(streamMgr, consumerMgr);
|
|
|
|
var binding = mqttConsumerMgr.CreateSubscriptionConsumer("client1", "sensor.temp", qos: 1, maxAckPending: 100);
|
|
|
|
binding.ShouldNotBeNull();
|
|
binding.Stream.ShouldBe(MqttProtocolConstants.StreamName);
|
|
binding.FilterSubject.ShouldBe($"{MqttProtocolConstants.StreamSubjectPrefix}sensor.temp");
|
|
}
|
|
|
|
[Fact]
|
|
public void ConsumerManager_removes_subscription_consumer()
|
|
{
|
|
var (streamMgr, consumerMgr, initializer) = CreateJetStreamInfra();
|
|
initializer.EnsureStreams();
|
|
var mqttConsumerMgr = new MqttConsumerManager(streamMgr, consumerMgr);
|
|
|
|
mqttConsumerMgr.CreateSubscriptionConsumer("client1", "sensor.temp", qos: 1, maxAckPending: 100);
|
|
mqttConsumerMgr.GetBinding("client1", "sensor.temp").ShouldNotBeNull();
|
|
|
|
mqttConsumerMgr.RemoveSubscriptionConsumer("client1", "sensor.temp");
|
|
mqttConsumerMgr.GetBinding("client1", "sensor.temp").ShouldBeNull();
|
|
}
|
|
|
|
[Fact]
|
|
public void ConsumerManager_removes_all_consumers_for_client()
|
|
{
|
|
// Go reference: clean session disconnect removes all consumers
|
|
var (streamMgr, consumerMgr, initializer) = CreateJetStreamInfra();
|
|
initializer.EnsureStreams();
|
|
var mqttConsumerMgr = new MqttConsumerManager(streamMgr, consumerMgr);
|
|
|
|
mqttConsumerMgr.CreateSubscriptionConsumer("client1", "sensor.temp", qos: 1, maxAckPending: 100);
|
|
mqttConsumerMgr.CreateSubscriptionConsumer("client1", "sensor.humidity", qos: 1, maxAckPending: 100);
|
|
mqttConsumerMgr.GetClientBindings("client1").Count.ShouldBe(2);
|
|
|
|
mqttConsumerMgr.RemoveAllConsumers("client1");
|
|
mqttConsumerMgr.GetClientBindings("client1").Count.ShouldBe(0);
|
|
}
|
|
|
|
// -----------------------------------------------------------------------
|
|
// QoS 1 with JetStream
|
|
// -----------------------------------------------------------------------
|
|
|
|
[Fact]
|
|
public async Task QoS1_publish_stores_to_stream()
|
|
{
|
|
// Go reference: server/mqtt.go QoS 1 publish stores message in $MQTT_msgs
|
|
var (streamMgr, consumerMgr, initializer) = CreateJetStreamInfra();
|
|
initializer.EnsureStreams();
|
|
var mqttConsumerMgr = new MqttConsumerManager(streamMgr, consumerMgr);
|
|
|
|
var seq = mqttConsumerMgr.PublishToStream("sensor.temp", "72.5"u8.ToArray());
|
|
|
|
seq.ShouldBeGreaterThan((ulong)0);
|
|
|
|
// Verify message is in the stream
|
|
streamMgr.TryGet(MqttProtocolConstants.StreamName, out var handle).ShouldBeTrue();
|
|
var msg = await handle.Store.LoadAsync(seq, default);
|
|
msg.ShouldNotBeNull();
|
|
System.Text.Encoding.UTF8.GetString(msg.Payload.Span).ShouldBe("72.5");
|
|
}
|
|
|
|
[Fact]
|
|
public async Task QoS1_acknowledge_removes_from_stream()
|
|
{
|
|
// Go reference: PUBACK acks the JetStream message
|
|
var (streamMgr, consumerMgr, initializer) = CreateJetStreamInfra();
|
|
initializer.EnsureStreams();
|
|
var mqttConsumerMgr = new MqttConsumerManager(streamMgr, consumerMgr);
|
|
|
|
var seq = mqttConsumerMgr.PublishToStream("sensor.temp", "72.5"u8.ToArray());
|
|
mqttConsumerMgr.AcknowledgeMessage(seq).ShouldBeTrue();
|
|
|
|
// Message should be removed
|
|
streamMgr.TryGet(MqttProtocolConstants.StreamName, out var handle).ShouldBeTrue();
|
|
var msg = await handle.Store.LoadAsync(seq, default);
|
|
msg.ShouldBeNull();
|
|
}
|
|
|
|
[Fact]
|
|
public void QoS1_tracker_records_stream_sequence()
|
|
{
|
|
// Go reference: server/mqtt.go trackPublish — maps packet ID → stream sequence
|
|
var tracker = new MqttQoS1Tracker();
|
|
|
|
var packetId = tracker.Register("sensor/temp", "72.5"u8.ToArray(), streamSequence: 42);
|
|
|
|
tracker.IsPending(packetId).ShouldBeTrue();
|
|
var acked = tracker.Acknowledge(packetId);
|
|
acked.ShouldNotBeNull();
|
|
acked.StreamSequence.ShouldBe((ulong)42);
|
|
}
|
|
|
|
[Fact]
|
|
public void QoS1_tracker_redelivery_preserves_stream_sequence()
|
|
{
|
|
var tracker = new MqttQoS1Tracker();
|
|
tracker.Register("sensor/temp", "72.5"u8.ToArray(), streamSequence: 99);
|
|
|
|
var pending = tracker.GetPendingForRedelivery();
|
|
pending.Count.ShouldBe(1);
|
|
pending[0].StreamSequence.ShouldBe((ulong)99);
|
|
pending[0].DeliveryCount.ShouldBe(2); // incremented
|
|
}
|
|
|
|
// -----------------------------------------------------------------------
|
|
// QoS 2 with JetStream
|
|
// -----------------------------------------------------------------------
|
|
|
|
[Fact]
|
|
public async Task QoS2_incoming_stores_for_dedup()
|
|
{
|
|
// Go reference: server/mqtt.go QoS 2 incoming stored in $MQTT_qos2in for dedup
|
|
var (streamMgr, consumerMgr, initializer) = CreateJetStreamInfra();
|
|
initializer.EnsureStreams();
|
|
var mqttConsumerMgr = new MqttConsumerManager(streamMgr, consumerMgr);
|
|
|
|
var seq = mqttConsumerMgr.StoreQoS2Incoming("client1", 1, "payload"u8.ToArray());
|
|
seq.ShouldBeGreaterThan((ulong)0);
|
|
|
|
var msg = await mqttConsumerMgr.LoadQoS2IncomingAsync("client1", 1);
|
|
msg.ShouldNotBeNull();
|
|
System.Text.Encoding.UTF8.GetString(msg.Payload.Span).ShouldBe("payload");
|
|
}
|
|
|
|
[Fact]
|
|
public async Task QoS2_incoming_removed_after_pubcomp()
|
|
{
|
|
// Go reference: server/mqtt.go QoS 2 state removed on PUBCOMP
|
|
var (streamMgr, consumerMgr, initializer) = CreateJetStreamInfra();
|
|
initializer.EnsureStreams();
|
|
var mqttConsumerMgr = new MqttConsumerManager(streamMgr, consumerMgr);
|
|
|
|
mqttConsumerMgr.StoreQoS2Incoming("client1", 1, "payload"u8.ToArray());
|
|
|
|
var removed = await mqttConsumerMgr.RemoveQoS2IncomingAsync("client1", 1);
|
|
removed.ShouldBeTrue();
|
|
|
|
var msg = await mqttConsumerMgr.LoadQoS2IncomingAsync("client1", 1);
|
|
msg.ShouldBeNull();
|
|
}
|
|
|
|
// -----------------------------------------------------------------------
|
|
// Session persistence with JetStream backing
|
|
// -----------------------------------------------------------------------
|
|
|
|
[Fact]
|
|
public async Task Session_persists_and_recovers_from_jetstream()
|
|
{
|
|
// Go reference: server/mqtt.go mqttStoreSession + mqttLoadSession via JetStream
|
|
var backingStore = new MemStore(new StreamConfig
|
|
{
|
|
Name = MqttProtocolConstants.SessStreamName,
|
|
Subjects = [$"{MqttProtocolConstants.SessStreamSubjectPrefix}>"],
|
|
MaxMsgsPer = 1,
|
|
});
|
|
|
|
var store1 = new MqttSessionStore(backingStore);
|
|
await store1.ConnectAsync("client-js", cleanSession: false);
|
|
store1.AddSubscription("client-js", "topic/a", 1);
|
|
store1.AddSubscription("client-js", "topic/b", 0);
|
|
await store1.SaveSessionAsync("client-js");
|
|
|
|
// Simulate restart with same backing store
|
|
var store2 = new MqttSessionStore(backingStore);
|
|
await store2.ConnectAsync("client-js", cleanSession: false);
|
|
|
|
var subs = store2.GetSubscriptions("client-js");
|
|
subs.Count.ShouldBe(2);
|
|
subs["topic/a"].ShouldBe(1);
|
|
subs["topic/b"].ShouldBe(0);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Clean_session_removes_from_jetstream()
|
|
{
|
|
var backingStore = new MemStore(new StreamConfig
|
|
{
|
|
Name = MqttProtocolConstants.SessStreamName,
|
|
Subjects = [$"{MqttProtocolConstants.SessStreamSubjectPrefix}>"],
|
|
MaxMsgsPer = 1,
|
|
});
|
|
|
|
var store = new MqttSessionStore(backingStore);
|
|
await store.ConnectAsync("client-clean", cleanSession: false);
|
|
store.AddSubscription("client-clean", "topic/x", 1);
|
|
await store.SaveSessionAsync("client-clean");
|
|
|
|
// Clean session connect
|
|
await store.ConnectAsync("client-clean", cleanSession: true);
|
|
|
|
// Simulate restart — should not find session
|
|
var store2 = new MqttSessionStore(backingStore);
|
|
await store2.ConnectAsync("client-clean", cleanSession: false);
|
|
store2.GetSubscriptions("client-clean").ShouldBeEmpty();
|
|
}
|
|
|
|
// -----------------------------------------------------------------------
|
|
// Retained messages with JetStream backing
|
|
// -----------------------------------------------------------------------
|
|
|
|
[Fact]
|
|
public async Task Retained_persists_and_recovers_from_jetstream()
|
|
{
|
|
// Go reference: server/mqtt.go retained messages stored in $MQTT_rmsgs
|
|
var backingStore = new MemStore(new StreamConfig
|
|
{
|
|
Name = MqttProtocolConstants.RetainedMsgsStreamName,
|
|
Subjects = [$"{MqttProtocolConstants.RetainedMsgsStreamSubject}>"],
|
|
MaxMsgsPer = 1,
|
|
});
|
|
|
|
var retained1 = new MqttRetainedStore(backingStore);
|
|
await retained1.SetRetainedAsync("sensors/temp", "72.5"u8.ToArray());
|
|
|
|
// Simulate restart — new store backed by same JetStream
|
|
var retained2 = new MqttRetainedStore(backingStore);
|
|
var msg = await retained2.GetRetainedAsync("sensors/temp");
|
|
|
|
msg.ShouldNotBeNull();
|
|
System.Text.Encoding.UTF8.GetString(msg).ShouldBe("72.5");
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Retained_tombstone_removes_from_jetstream()
|
|
{
|
|
// Go reference: empty payload + retain = delete retained
|
|
var backingStore = new MemStore(new StreamConfig
|
|
{
|
|
Name = MqttProtocolConstants.RetainedMsgsStreamName,
|
|
Subjects = [$"{MqttProtocolConstants.RetainedMsgsStreamSubject}>"],
|
|
MaxMsgsPer = 1,
|
|
});
|
|
|
|
var retained = new MqttRetainedStore(backingStore);
|
|
await retained.SetRetainedAsync("sensors/temp", "72.5"u8.ToArray());
|
|
await retained.SetRetainedAsync("sensors/temp", ReadOnlyMemory<byte>.Empty); // tombstone
|
|
|
|
// Should be gone even from backing store
|
|
var recovered = new MqttRetainedStore(backingStore);
|
|
var msg = await recovered.GetRetainedAsync("sensors/temp");
|
|
msg.ShouldBeNull();
|
|
}
|
|
|
|
// -----------------------------------------------------------------------
|
|
// Flow controller — JetStream integration
|
|
// -----------------------------------------------------------------------
|
|
|
|
[Fact]
|
|
public async Task FlowController_IsAtCapacity_when_max_reached()
|
|
{
|
|
// Go reference: server/mqtt.go mqttMaxAckPending flow control
|
|
using var fc = new MqttFlowController(defaultMaxAckPending: 2);
|
|
|
|
// Acquire 2 slots
|
|
(await fc.TryAcquireAsync("sub1")).ShouldBeTrue();
|
|
(await fc.TryAcquireAsync("sub1")).ShouldBeTrue();
|
|
|
|
// Now at capacity
|
|
fc.IsAtCapacity("sub1").ShouldBeTrue();
|
|
(await fc.TryAcquireAsync("sub1")).ShouldBeFalse();
|
|
|
|
// Release one
|
|
fc.Release("sub1");
|
|
fc.IsAtCapacity("sub1").ShouldBeFalse();
|
|
(await fc.TryAcquireAsync("sub1")).ShouldBeTrue();
|
|
}
|
|
|
|
// -----------------------------------------------------------------------
|
|
// Helpers
|
|
// -----------------------------------------------------------------------
|
|
|
|
private static (StreamManager StreamMgr, ConsumerManager ConsumerMgr, MqttStreamInitializer Initializer) CreateJetStreamInfra()
|
|
{
|
|
var consumerMgr = new ConsumerManager();
|
|
var streamMgr = new StreamManager(consumerManager: consumerMgr);
|
|
consumerMgr.StreamManager = streamMgr;
|
|
var initializer = new MqttStreamInitializer(streamMgr);
|
|
return (streamMgr, consumerMgr, initializer);
|
|
}
|
|
}
|