Edit Page

MQTT Durability and Operations

RESTHeart
Prefer not to run it yourself? RESTHeart Cloud is this, hosted, with sign-up, payments and an MCP server already on. Free to start. Get started →

Preview

This page is about what survives what: which messages are guaranteed to reach MongoDB, which are deliberately dropped, and which configuration mistakes are silent.

Durability

A message is acknowledged to the broker once it is in the writer’s buffer, not once it is in MongoDB. With QoS 1 and a persistent session, the module therefore delivers at-least-once up to the buffer: everything the broker hands over is either written to MongoDB or recorded in the dead-letter collection, unless this process dies with messages still in memory.

Why not acknowledge after the write

MQTT acknowledgements are ordered — hivemq-mqtt-client holds a PUBACK back until every earlier message has been acknowledged, for compliance with the protocol.

A message held through a MongoDB outage therefore blocks every acknowledgement behind it; the broker’s in-flight window fills (20 messages with Mosquitto’s defaults) and it stops delivering to this client at all, live SSE consumers included. Holding acknowledgements until the database takes the message puts MongoDB in the critical path of every consumer, which is the opposite of what this module is for.

MqttMongoOutageIT pins the behaviour: with MongoDB stopped, forty messages — twice that window — still reach an SSE client, and all forty are written once it is back.

What this costs

The buffer is memory. A kill -9, an OOM or a power cut loses what is in it, and the broker will not redeliver because it was told the messages were taken.

That is the trade, stated plainly: this is telemetry-grade durability, not a transactional queue. Deployments that cannot lose a message at all need a queue in front of RESTHeart.

During a MongoDB outage

The writer retries the batch it is holding for as long as it runs, with an exponential backoff capped at 30 s, and the messages that keep arriving accumulate in the buffer. Nothing is dropped and nothing is dead-lettered: an unreachable database is not the documents' fault.

When the buffer reaches either of its ceilings — capacity, 10000 messages, or max-bytes, 64 MB — the writer stops taking messages from the router and the broker, which is backpressure working as intended.

Where the messages queue then is the broker. Mosquitto holds max_queued_messages per client, 1000 by default, and discards beyond that. So the reserve available during an outage is the buffer plus the broker’s queue, and sizing the broker is part of configuring persistence: without it, messages are lost in the broker while RESTHeart is still healthy.

An orderly shutdown

It stops taking messages first — what arrives from then on is left unacknowledged, so the broker keeps it for the next instance — and then drains the buffer into MongoDB for up to drain.shutdown-timeout-ms (5 s by default, deliberately inside Docker’s 10 s SIGTERM grace).

What that budget does not cover is lost, and logged as such; in practice that means RESTHeart being stopped while MongoDB is also down.

RESTHeart has no plugin shutdown callback, so the client and the writer’s drain loop are stopped from JVM shutdown hooks. A kill -9 runs no hook at all and loses the buffer.

What the guarantee needs

Three things are required for it to hold up to the buffer:

  • QoS 1 or 2. QoS 0 has no acknowledgement in the protocol at all, so the broker never redelivers and a message lost before the buffer is lost for good.

  • clean-session: false (the default), so the broker keeps the session and redelivers what the last connection did not acknowledge when the next one resumes.

  • A stable client-id. An MQTT session is keyed on it. The default is derived from the RESTHeart instance name (restheart-<instance name>) rather than a fresh UUID per start, which is what makes it stable.

Caution
A client id must be unique across concurrently connected clients: a broker disconnects the existing client when another connects with the same id. Several RESTHeart instances sharing one /core/name will knock each other off the broker in a loop. Give each instance its own name, or set /mqtt-client/client-id explicitly. The module warns at startup when it is left on the stock name default.

id-strategy settles what a redelivery does. At-least-once means the same message can be received twice — after a reconnect that resumes a session mid-flight, for instance — and payload-field keys the document on something the message itself carries, so the second reception overwrites the first instead of adding a copy. Leaving id-strategy at auto means every redelivery is a new document.

Why connecting is a separate step

A broker redelivers everything a resumed session owes the moment it sends CONNACK. Anything not listening at that instant loses those messages.

The module registers consumers at three different moments — mqtt-client builds the client, mqtt-router registers the global publish consumer, mqtt-mongo-writer registers its durable listener at AFTER_STARTUP — so mqtt-connector exists purely to connect after all of them. It is enabled by default so nobody has to remember it; disabling it leaves a module that never reaches the broker, and mqtt-status reports that.

The traps

Most of these are silent: nothing refuses to start, and nothing complains unless you go looking. One of them is not, and is called out below.

  • A config block present without enabled: true leaves the plugin off. The block looks complete and correct; it just isn’t read, because PluginRecord.isEnabled falls back to the plugin’s compiled default (false for every Tier 1/2 plugin) whenever the enabled key is absent.

  • Plugin config blocks are top-level keys named after the plugin. There is no plugins-args: wrapper: PluginsFactory looks up each plugin’s arguments by name directly at the root of the configuration map. That wrapper form is not handled at all, so every setting nested under it is ignored and the plugin runs entirely on defaults. This is the one trap here that announces itself: core logs a WARN at startup naming every plugin whose block a plugins-args wrapper swallowed.

  • mqtt-rest answers 404 forever unless something populates the router’s last-message cache (mqtt-router.subscriptions, a live SSE client, or the writer’s mongo-sink) and mqtt-router.last-message-cache is true.

  • After a restart, mqtt-rest answers 404 again until the next message arrives — the cache is in memory and does not survive the process. There is a broker-side remedy that costs nothing, described below.

  • Restarting RESTHeart does not clear its subscriptions. The module connects with clean-session: false and a stable client id, so the broker keeps the session, subscriptions included, across restarts; that is what lets it redeliver what a stopped instance never acknowledged. A subscription made by a previous run, even one no longer in the configuration, is therefore still delivered to the next, and /mqtt may answer 200 for a topic nothing in the current configuration subscribes to. MQTT has no way to list or clear a session’s subscriptions short of discarding the session and the undelivered messages with it; restart the broker for a clean slate. The same mechanism is why Subscribed to topic filter: …​ appears twice at startup: the subscription is issued before connecting and again once the broker reports a new session, and the second replaces the first.

  • mqtt-mongo-writer with an empty mongo-sink runs and writes nothing. With no sinks, the writer never subscribes to anything on the router at all, so nothing is ever offered to the buffer and it stays empty — not a buffer that fills and drains into nowhere.

  • mqtt-topic-authorizer with no acl denies everything with 403. There is no permissive default.

  • A request with no ?topic= is not unauthenticated territory. mqtt-sse subscribes such a request to its default-topic (sensors/# by default), so the ACL must grant that filter or the request is refused with 403. Granting only specific topics while leaving default-topic at its default is the common mistake.

  • MQTT 5 settings under protocol-version: 3 are dropped. session-expiry-seconds, will.delay-seconds and will.message-expiry-seconds exist only in MQTT 5.0, and the 3.1.1 connection path has nowhere to put them — the values are read and validated, then discarded. A will message configured with a delay fires immediately instead.

The retained-message remedy for the restart 404

If publishers publish the latest state retained, the broker replays it on the SUBSCRIBE this module issues at every startup, and the cache is correct immediately.

Measured: with a retained value, GET /mqtt answers 200 straight after a restart with nothing republished; without one, 404.

A non-retained publish does not clear a retained value; an empty retained message does (mosquitto_pub -r -n -t <topic>), and it also removes the topic from this module’s cache at once.

Tip
This does not replace mqtt-router.subscriptions — with no subscription there is no SUBSCRIBE and nothing is replayed — but it removes the blind window after every restart, which on a slow topic can last a long time.

The startup sentinel

mqtt-status reports five of the traps above at startup, by comparing the configuration against what the plugin registry actually instantiated: the missing enabled key, MQTT 5 keys under protocol-version: 3, mqtt-rest with an unprimed cache, an empty mongo-sink, and an empty acl.

The plugins-args wrapper is reported by core instead. The default-topic one is reported by neither — it is a working ACL doing exactly what it was told, so there is nothing for a sentinel to find.

mqtt-status also warns when mqtt-client is enabled but mqtt-connector is not, and logs a line at INFO when the module is installed but mqtt-client is off.

When it finds nothing wrong, it logs a single line instead, for example:

mqtt module active: mqtt-client, mqtt-router, mqtt-sse, mqtt-topic-authorizer, mqtt-connector; inactive: mqtt-rest, mqtt-mongo-writer
Tip
Check the log before assuming a misconfiguration is a bug, and include that line — or the warnings in its place — in any bug report.

Operational notes

Message loss is by design on the live-data paths

Each is counted so it is visible rather than silent: the router’s global rate limit, the SSE per-connection queue, and a pipeline’s throttle stage. For a dashboard that is the right answer — you want the latest reading, not a backlog.

The persistence path does not lose by default

mqtt-mongo-writer’s buffer defaults to `blocking-queue, which applies backpressure instead; ring-buffer and drop-incoming are there for anyone who would rather drop than slow down, and must be chosen explicitly.

Backpressure is cheap here because the router dispatches each message on its own virtual thread, so a full buffer parks a virtual thread rather than a platform one.

Broker subscriptions are a minimal covering set, not one per topic filter

MQTT 3.1.1 lets a broker deliver one copy of a message per matching subscription, and Mosquitto does exactly that. Since the router matches every incoming message against every registered filter locally, it subscribes on the broker only to filters no other registered filter already covers — so sensors/# in mqtt-router.subscriptions plus an SSE client on sensors/temp is one broker subscription, not two, and one delivery, not two.

A covering filter is subscribed at the highest QoS of anything it covers, so standing in for a durable QoS 1 subscription never quietly downgrades it to QoS 0.

Without this, the configuration these docs recommend duplicated every message: two SSE events, two callbacks into a custom plugin, two MongoDB documents under id-strategy: auto, and a doubled mqtt_router_messages_received.

max-inflight-messages-per-second governs live delivery only

It used to cut before the fan-out, so a number chosen to protect a dashboard silently governed what reached storage as well. It now applies to live listeners only — SSE connections and plugins' subscribe listeners.

The last-value cache behind mqtt-rest is updated before the limit is checked, and a message refused for live delivery is still handed to mqtt-mongo-writer and still persisted. mqtt_router_messages_dropped therefore counts live drops, and mqtt_router_messages_received counts everything the broker delivered — the two overlap deliberately, so their ratio is the live loss rate.

Clustering

On MQTT 3.1.1 there are no shared subscriptions, so every RESTHeart node receives every message. For MongoDB persistence that means payload-field is the only strategy that converges: it is the one keyed on something the message itself carries, so all the nodes compute the same _id.

On MQTT 5.0, shared subscriptions are the cleaner answer — tracked in #602.

Topic authorization on /mqtt-sse

It is enforced end to end: a request for a topic filter granted by the ACL subscribes normally, and a request for an ungranted filter is rejected with 403 and a body of {"msg":"Not authorized for topic: <filter>"} before it ever reaches the router.

Warning
This depends on RESTHeart running the SSE handshake through WildcardInterceptor`s (`SseWildcardInterceptorsExecutor, wired into plugSseService). Without it, SSE handshake requests pass through no interceptor at all, so mqtt-topic-authorizer resolves but is never invoked on that path, and /mqtt-sse ends up authenticated but not authorized per topic. No release includes that fix yet — make sure the RESTHeart build this module is deployed against has it.

Roadmap

Post-v1 work is tracked under #601:

  • MQTT 5 shared subscriptions and message-expiry enforcement (#602 — the publish properties, user properties included, are already captured)

  • an HTTP → MQTT publish endpoint (#603)

  • a WebSocket bridge (#604)

  • polyglot pipeline stages (#605)

  • replay from MongoDB via Last-Event-ID (#606)

  • a dead-letter REST API (#607)

  • the remaining metrics — broker connection state, reconnects, batch latency, dead-letter count and a health check (#608)

  • schema validation and pluggable deserializers (#609)

  • a distributed single-writer mode (#610)

Missing something that is not on this list? That is exactly the feedback we are looking for — open an issue.