Full protocol parity design for MQTT 3.1.1: listener, client creation, parser/dispatch, all packet handlers (CONNECT through DISCONNECT), QoS 0/1/2, will messages, retained messages, session persistence, and JetStream consumer management. Bottom-up implementation strategy.
13 KiB
MQTT Server-Side Orchestration Design
Goal
Implement the MQTT server-side orchestration layer — listener, client creation, protocol parsing, packet dispatch, and all 15+ extension methods — achieving full MQTT 3.1.1 protocol parity with Go's mqtt.go (~5,800 LOC). The data structures, binary codecs, session manager, JetStream integration, and subject converter are already ported and verified.
Architecture
Bottom-up implementation with TDD: build from listener through parser to packet handlers to session/JetStream integration. Each layer is independently testable before the next depends on it. Mirror Go's createMQTTClient() pattern with a dedicated MQTT client creation path separate from the NATS client path.
Decisions
| Decision | Choice | Rationale |
|---|---|---|
| Scope | Full protocol parity | All packet types, QoS 0/1/2, will, retained, sessions |
| Client creation | Dedicated CreateMqttClient() |
Mirrors Go exactly; MQTT clients skip INFO, set headers=true |
| Code organization | Split by concern | MqttParser.cs, MqttPacketHandlers.cs, MqttClientExtensions.cs |
| Strategy | Bottom-up with TDD | Each layer tested before building the next |
Tech Stack
- .NET 10, C# latest
- xUnit 3, Shouldly for testing
- Raw TCP + binary MQTT packets for integration tests (no third-party MQTT library)
- Existing: MqttReader, MqttWriter, MqttConstants, MqttTypes, MqttAccountSessionManager, MqttJsa
1. Listener & Client Creation
StartMqtt() — Replace warning stub
File: Mqtt/MqttHandler.cs (MqttServerExtensions)
- Read
opts.Mqtt.Portandopts.Mqtt.Host - Create
TcpListeneron MQTT port (support ephemeral port 0) - Store as
_mqttListenerfield onNatsServer - Handle TLS if
opts.Mqtt.TlsConfigis set - Launch
AcceptConnections()goroutine withCreateMqttClientcallback - Log:
"Listening for MQTT client connections on {endpoint}"
CreateMqttClient(TcpClient tc) — New method
File: New NatsServer.Mqtt.cs partial class, or extend NatsServer.Listeners.cs
- Create
ClientConnection(ClientKind.Client, this, tc.GetStream()) - Initialize
c.Mqtt = new MqttHandler()with QoS flags fromopts.Mqtt:RejectQoS2Pubfromopts.Mqtt.RejectQoS2PubDowngradeQoS2Subfromopts.Mqtt.DowngradeQoS2Sub
- Set
c.Headers = true(MQTT uses NATS headers for QoS metadata) - Register with
GlobalAccount() - Do NOT send INFO — MQTT clients don't use the NATS INFO line
- Start
ReadLoop()andWriteLoop()viaStartGoRoutine() - Add to server's
_clientsmap - Check max connections limit
NatsServer fields
File: NatsServer.cs
Add alongside existing listener fields:
private TcpListener? _mqttListener;
Shutdown integration
File: NatsServer.Lifecycle.cs
Add _mqttListener to doneExpected counting in Shutdown():
if (_mqttListener != null)
{
doneExpected++;
_mqttListener.Stop();
_mqttListener = null;
}
Same for LameDuckMode().
2. Parser & Dispatch
MqttParse(byte[] buf) — New method on ClientConnection
File: New Mqtt/MqttParser.cs
State machine that processes raw bytes from the read loop:
- Fixed header: Read packet type byte (upper 4 bits = type, lower 4 = flags)
- Remaining length: Variable-length encoding (1-4 bytes, 7 bits per byte, MSB continuation)
- Payload: Type-specific parsing delegated to per-packet parsers
Dispatch switch
After extracting packet type and remaining length:
CONNECT (0x10) → MqttParseConnect() → MqttProcessConnect()
PUBLISH (0x30) → MqttParsePub() → MqttProcessPub()
PUBACK (0x40) → MqttParsePubAck()
PUBREC (0x50) → MqttParsePubRec()
PUBREL (0x62) → MqttProcessPubRel()
PUBCOMP (0x70) → MqttParsePubComp()
SUBSCRIBE (0x82) → MqttParseSubs() → MqttProcessSubs()
UNSUBSCRIBE(0xA2) → MqttParseUnsubs() → MqttProcessUnsubs()
PINGREQ (0xC0) → MqttEnqueuePingResp()
DISCONNECT (0xE0) → handle cleanup + close
Key constraints
- CONNECT must be the first packet; any other packet before CONNECT → close connection
- Partial packets: save state in
MqttHandler.ParseState/RemLen/Buf, resume on next buffer - Invalid packet types → close connection
ReadLoop integration
File: ClientConnection.cs (ReadLoop method)
Add MQTT branch:
if (IsMqtt())
err = MqttParse(buf);
else
err = Parse(buf, handler);
IsMqtt() change
File: ClientConnection.cs
Change from return false to:
internal bool IsMqtt() => Mqtt != null;
3. Packet Handlers
CONNECT (MqttParseConnect + MqttProcessConnect)
File: Mqtt/MqttPacketHandlers.cs
Parse phase — Extract fields in MQTT 3.1.1 spec order:
- Protocol name ("MQTT"), protocol level (0x04)
- Connect flags (clean session, will, will QoS, will retain, password, username)
- Keep-alive interval (2 bytes, multiply by 1.5 for read deadline)
- Client ID (auto-generate NUID if empty + clean session)
- Will topic/message (convert topic to NATS subject via
MqttTopicToNatsPubSubject()) - Username/password
Process phase (MqttProcessConnect on NatsServer):
- Get or create
MqttAccountSessionManagerfor client's account - Lock session by client ID hash (prevents concurrent session takeover)
- If existing session → take over (disconnect old client)
- If clean session → delete old session state
- If !clean session → restore subscriptions and pending messages
- Set
c.Mqtt.Session,c.Mqtt.AccountSessionManager - Run authentication if
opts.Mqtt.Username/Passwordconfigured - Send CONNACK (return code 0 = accepted)
- Deliver retained messages for existing subscriptions
- Set keep-alive read deadline on connection
PUBLISH (MqttParsePub + MqttProcessPub)
Parse: Topic name → convert to NATS subject, packet ID (if QoS>0), payload bytes.
Process by QoS:
- QoS 0:
MqttInitiateMsgDelivery()→ done - QoS 1: Deliver → send PUBACK(pi)
- QoS 2:
MqttStoreQoS2MsgOnce()→ send PUBREC(pi) → [client sends PUBREL] → deliver → send PUBCOMP(pi)
MqttInitiateMsgDelivery: Construct NATS message with Nmqtt-Pub:<qos> header, set c.Pa (publish args), call c.ProcessInboundClientMsg().
Max payload check: Validate total NATS message size against client's max payload limit.
SUBSCRIBE (MqttParseSubs + MqttProcessSubs)
Parse: Packet ID, list of (topic filter + QoS) pairs. Convert each filter to NATS subject via MqttFilterToNatsSubject().
Process per filter:
- Create NATS subscription on converted subject
- If QoS > 0: Create JetStream durable consumer on
$MQTT.msgs.<subject> - Track in
MqttSession.SubsandMqttSession.Cons - Send SUBACK with granted QoS per filter (downgrade QoS 2 → 1 if configured)
UNSUBSCRIBE (MqttParseUnsubs + MqttProcessUnsubs)
Mirror of SUBSCRIBE:
- Remove NATS subscriptions
- Delete JetStream consumers
- Update session state
- Send UNSUBACK
PING
Queue PINGRESP (2 bytes: 0xD0 0x00).
DISCONNECT
- Clear will message (graceful disconnect suppresses will)
- Call
MqttHandleClosedClient()for session cleanup - Close connection with
ClosedState.ClientClosed
Will Message (MqttHandleWill)
On abnormal disconnect (connection drop, not DISCONNECT):
- Publish will topic/message to NATS with configured QoS and retain flag
- Only if will was set in CONNECT and not cleared by DISCONNECT
PUBREL Processing (MqttProcessPubRel)
For QoS 2 inbound flow:
- Look up stored QoS 2 message by packet ID
- Deliver via
MqttInitiateMsgDelivery() - Send PUBCOMP(pi)
- Remove from pending
4. Session Management & JetStream Integration
Session Lifecycle
Creation (during CONNECT):
GetOrCreateMqttAccountSessionManager()— Lazy per-account ASM initializationMqttCreateAccountSessionManager()— Creates ASM with JetStream streams:$MQTT.msgs.<account>— QoS 1/2 message persistence$MQTT.sess.<account>— Session state persistence$MQTT.rmsgs.<account>— Retained messages (MaxMsgsPerSubject=1)
- Lock session by client ID hash
- Create or restore
MqttSession
Persistence (via MqttJsa):
- Serialize session state to
$MQTT.sessstream keyed by client ID hash - Clean session: delete all session data
- Reconnect (!clean): reload from JetStream, rebind subscriptions
Takeover (same client ID reconnects):
- Lock old session → disconnect old client → transfer to new client → resume pending
JetStream Consumer Management
QoS 1/2 subscriptions create durable consumers:
- Durable name:
<sessionIdHash>_<nuid> - Filter:
$MQTT.msgs.<converted_subject> - Delivery subject:
$MQTT.sub.<nuid> - Ack policy: Explicit
- Max ack pending: From account config (default 1024)
Retained Messages
- PUBLISH with retain flag → store in
$MQTT.rmsgs.<subject>(MaxMsgsPerSubject=1) - New SUBSCRIBE →
LoadLastMsgForAsync()to find matching retained messages - PUBLISH with retain + empty payload → delete retained message
Extension Methods to Implement
| Method | Purpose |
|---|---|
StartMqtt() |
Listener startup |
MqttConfigAuth() |
Wire MQTT auth overrides |
MqttHandleClosedClient() |
Session cleanup on disconnect |
MqttUpdateMaxAckPending() |
Propagate config to consumers |
MqttGetJsaForAccount() |
Get/create JSA for account |
MqttStoreQosMsgForAccountOnNewSubject() |
Store QoS msg on new subject |
GetOrCreateMqttAccountSessionManager() |
Lazy ASM creation |
MqttCreateAccountSessionManager() |
ASM + stream creation |
MqttDetermineReplicas() |
Cluster-aware replica count |
MqttProcessConnect() |
CONNECT handling |
MqttHandleWill() |
Will message delivery |
MqttProcessPub() |
PUBLISH handling |
MqttInitiateMsgDelivery() |
Inject into NATS substrate |
MqttStoreQoS2MsgOnce() |
QoS 2 idempotent storage |
MqttProcessPubRel() |
PUBREL handling |
MqttCheckPubRetainedPerms() |
Retained message permission audit |
5. New Files
| File | Purpose | Est. LOC |
|---|---|---|
Mqtt/MqttParser.cs |
Parse + dispatch state machine | 400-500 |
Mqtt/MqttPacketHandlers.cs |
CONNECT/PUB/SUB/UNSUB/PING/DISCONNECT processing | 800-1000 |
Mqtt/MqttClientExtensions.cs |
ClientConnection MQTT methods (IsMqtt, enqueue helpers) | 200-300 |
NatsServer.Mqtt.cs |
Server-side MQTT partial (CreateMqttClient, listener) | 300-400 |
Modified files:
Mqtt/MqttHandler.cs— Replace stubs with real implementationsNatsServer.cs— Add_mqttListenerfieldNatsServer.Lifecycle.cs— Add MQTT listener to shutdownClientConnection.cs— ChangeIsMqtt(), addMqttproperty, ReadLoop branch
Estimated total: 2,000-3,000 LOC new code
6. Testing Strategy
Unit Tests
Parser tests (Tests/Server/Mqtt/MqttParserTests.cs):
- Valid CONNECT packet → all fields extracted
- CONNECT with will message → will topic/message parsed
- Partial CONNECT (fragmented) → state saved, resume on next buffer
- Invalid first packet (not CONNECT) → error
- PUBLISH QoS 0/1/2 → topic conversion, payload, PI
- SUBSCRIBE with multiple filters → all converted
- UNSUBSCRIBE → filters parsed
- Remaining length edge cases (1-byte, 2-byte, 4-byte encodings)
Dispatch tests (Tests/Server/Mqtt/MqttDispatchTests.cs):
- Each packet type dispatches to correct handler
- CONNECT-first enforcement
- Invalid packet type → error
Handler tests (mocked server/session):
- CONNECT: clean session, session resumption, client ID generation
- PUBLISH QoS 0/1/2 flows
- SUBSCRIBE: subscription + consumer creation
- UNSUBSCRIBE: cleanup
- DISCONNECT: will cleared, session cleanup
- Will delivery on abnormal close
Integration Tests
70 existing deferred tests in Mqtt/MqttTests.cs become runnable progressively.
New boot tests:
MqttBoot_AcceptsConnection_ShouldSucceedMqttBoot_StartAndShutdown_ShouldSucceed- End-to-end: client A publishes → client B receives
Test approach
- Unit tests: mocked dependencies, no server boot
- Integration tests:
NatsServer.Start()withopts.Mqtt.Port = 0(ephemeral) - Raw TCP + binary MQTT packets for protocol tests (no third-party MQTT client)
7. Implementation Order (Bottom-Up)
- Listener + client creation —
StartMqtt(),CreateMqttClient(), shutdown wiring - Parser + dispatch —
MqttParse(), ReadLoop integration,IsMqtt() - CONNECT — Parse + process, CONNACK response, auth
- PING — Simplest packet handler (proves dispatch works)
- DISCONNECT — Graceful close, will suppression
- PUBLISH QoS 0 — Basic message flow through NATS substrate
- SUBSCRIBE + UNSUBSCRIBE — NATS subscription management
- PUBLISH QoS 1 — PUBACK flow, JetStream consumer creation
- PUBLISH QoS 2 — PUBREC/PUBREL/PUBCOMP handshake
- Will messages — Abnormal disconnect handling
- Retained messages — Store/retrieve/delete retained
- Session persistence — Clean/dirty session lifecycle
- Session takeover — Same client ID reconnect
- Config integration —
MqttConfigAuth,MqttUpdateMaxAckPending,MqttDetermineReplicas,MqttCheckPubRetainedPerms