gobridge

MQTT settlement recovery

How the MQTT transport recovers a delivery that was received but never settled — a refused emit, a failed Delivery.Retry — without wedging ingress on the broker’s Receive-Maximum window.

Split out of MQTT behaviour, which remains the entry point for settlement semantics, the guarantee matrix, resilience, backpressure and ingress headers.


A successful Delivery.Retry on a QoS 1/2 delivery has transport-specific semantics because MQTT has no per-message NACK. On a Persistent or Exclusive session, Retry leaves the PUBLISH protocol-unsettled and requests one connection recycle. Ack and Retry are mutually exclusive and idempotent: a Retry that wins can never be followed by a protocol Ack for that delivery. QoS 0 and every Ephemeral-session Retry remain ErrNotSupported because a reconnect cannot redeliver them safely.

A receiver emit error (the route runner refuses the delivery outright) takes the same path: the un-acked delivery would otherwise be stranded — MQTT brokers do not redeliver on a live connection — pinning a Receive-Maximum slot until an unrelated teardown, and wedging ingress as strands accumulate. What can be done about it is decided by whether the broker session RESUMES:

All three count on MQTTReceiverEmitRejected, separated by its outcome tag.

An accepted delivery that later fails is different from an emit rejection. The runner accepts asynchronously, so a later send or DLQ failure does not reach the receiver’s emit-error counter. For actual QoS 0, Retry returns ErrNotSupported. The runner uses the existing DLQ fallback or an explicitly permitted no-DLQ drop (MessagesDropped{reason=retry_unsupported}). If bounded DLQ persistence also fails, it records exactly one terminal loss as MessagesDropped{reason=retry_unsupported_dlq_failed} and surfaces the error. DLQWriteFailures still records failed persistence; no DLQEntries success is invented. The delivery releases its route slot and does not request a session recycle. Generated IDs may first enter the existing unstable_identity terminal path; a failed DLQ there reaches the same unsupported-retry fallback.

Recovery is decided by the actual delivery. A publisher’s QoS 0 packet through a QoS 1 subscription still cannot recover. Conversely, supported QoS 1/2 Retry on a resuming session leaves the packet unsettled after failed DLQ persistence. Cancellation abandons the delivery without terminal success/drop accounting; only a source capable of redelivery can recover that abandoned input. A process killed abruptly cannot emit a counter for its in-flight crash gap.

Recovery applies these safety bounds without introducing a recovery-specific config knob:

The adapter tracks every current-connection QoS 1/2 packet from receipt until a successful protocol Ack or connection-epoch change. Deep health exposes unsettled_count, oldest_unsettled_age_ms, receive_window_utilization, and recovery_recycle_count. The corresponding metrics are:

Metric Kind/unit Meaning
MQTTUnsettled gauge, packets Current-epoch QoS 1/2 packets awaiting protocol settlement.
MQTTOldestUnsettledAge gauge, seconds Age of the oldest current-epoch unsettled packet.
MQTTReceiveWindowUtilization gauge, ratio unsettled_count / receive_maximum; sustained values near 1 indicate ingress is close to flow-control exhaustion.
MQTTSessionRecoveryRecycle counter Actual recycle attempts started after acquiring the session gate. Queue timeout/cancellation before recycle does not increment.
MQTTReceiverEmitRejected counter Deliveries the route pipeline refused at emit. outcome=recovering is QoS 1/2 on a session that resumes (Persistent/Exclusive): left un-acked, redelivered by the recycle above. outcome=lost is anything nothing can redeliver — QoS 0, or QoS 1/2 on an Ephemeral session — and is ACKED so it stops pinning the receive window.

All of these use the existing session_id tag, plus outcome on MQTTReceiverEmitRejected. Message IDs, topics, and failure reasons are deliberately not dimensions, so cardinality remains bounded.

Ephemeral sessions have a loss window. An Ephemeral session keeps no offline retention: during any disconnect the broker queues nothing for it, so messages it would have delivered are lost with no redelivery on reconnect, and a runtime reconfig swap leaves an unavoidable delivery gap. Persistent and Exclusive sessions (clean_start=false with a non-zero session_expiry_interval) close the offline and reconfig gaps — the broker queues inbound deliveries while the client is away and redelivers them on resume — but no mode makes OUTBOUND in-flight QoS 1/2 state durable: it lives only in memory and a bridge restart or crash loses it.

MQTT QoS 1/2 alone is not durable egress, and neither wired delivery mode is unconditionally loss-proof. autopaho keeps the outbound packet queue in memory, so a publish that is in flight (sent, PUBACK/PUBCOMP not yet received) when the process dies is lost at the protocol level, and MQTT QoS 2 is not exactly-once across a restart. client_id / clean_start=false do not help — they resume broker-side state, not the client-side outbound queue. What each delivery mode recovers, and the conditions it depends on, is stated in the matrix below:

Neither mode proves that a broker-retained message existed before the bridge received it, and neither turns an ambiguous send into a certainty. Pair loss-sensitive egress with shared_outbox (or the redelivery-backed direct_hold), keep producers stamping a stable mqtt.message-id, and keep the downstream idempotent. A file-backed Paho session store is a deferred alternative, not wired today — see ADR 0009.