release the shutdown barrier on worker crash and replay retained publishes the broker never acked

This commit is contained in:
Josh Hawkins
2026-08-17 08:13:03 -05:00
parent e469e93fd0
commit c9c06a355f
2 changed files with 134 additions and 2 deletions
+54
View File
@@ -50,6 +50,7 @@ class MqttClient(Communicator):
self._callback_queue: queue.Queue[tuple[Any, ...]] = queue.Queue() self._callback_queue: queue.Queue[tuple[Any, ...]] = queue.Queue()
self._retained_lock = threading.Lock() self._retained_lock = threading.Lock()
self._pending_retained: dict[str, tuple[Any, bool]] = {} self._pending_retained: dict[str, tuple[Any, bool]] = {}
self._inflight_retained: dict[int, tuple[str, Any]] = {}
self._subscription_mid: int | None = None self._subscription_mid: int | None = None
self._subscription_ready = False self._subscription_ready = False
self._next_connect_time = 0.0 self._next_connect_time = 0.0
@@ -273,6 +274,7 @@ class MqttClient(Communicator):
client.on_disconnect = self._on_disconnect client.on_disconnect = self._on_disconnect
client.on_message = self._on_message client.on_message = self._on_message
client.on_subscribe = self._on_subscribe client.on_subscribe = self._on_subscribe
client.on_publish = self._on_publish
client.will_set( client.will_set(
self.mqtt_config.topic_prefix + "/available", self.mqtt_config.topic_prefix + "/available",
payload="offline", payload="offline",
@@ -329,6 +331,9 @@ class MqttClient(Communicator):
self._publish_offline_availability() self._publish_offline_availability()
self.connected = False self.connected = False
finally: finally:
# nothing drains the queue once the loop is gone, so release any
# waiter here or stop() blocks for the full flush timeout
self._requeue_disconnected_publishes()
self._cleanup_client() self._cleanup_client()
def _publish_offline_availability(self) -> None: def _publish_offline_availability(self) -> None:
@@ -412,6 +417,7 @@ class MqttClient(Communicator):
self.connected = False self.connected = False
self._subscription_ready = False self._subscription_ready = False
self._subscription_mid = None self._subscription_mid = None
self._requeue_inflight_retained()
client = self.client client = self.client
self.client = None self.client = None
@@ -437,6 +443,19 @@ class MqttClient(Communicator):
logger.info("MQTT reconnect scheduled in %.1fs", MQTT_RECONNECT_INTERVAL) logger.info("MQTT reconnect scheduled in %.1fs", MQTT_RECONNECT_INTERVAL)
self._cleanup_client() self._cleanup_client()
def _requeue_inflight_retained(self) -> None:
"""Rebuffer retained publishes paho took but the broker never acked.
Dropping the client drops paho's outbound queue with it, and the session
is clean, so the broker will not resume delivery on the new one.
"""
with self._retained_lock:
inflight = list(self._inflight_retained.values())
self._inflight_retained.clear()
for topic, payload in inflight:
self._queue_retained(topic, payload, True, overwrite=False)
def _buffer_undelivered( def _buffer_undelivered(
self, queued_publish: QueuedPublish, overwrite: bool = True self, queued_publish: QueuedPublish, overwrite: bool = True
) -> None: ) -> None:
@@ -486,6 +505,8 @@ class MqttClient(Communicator):
self._handle_subscribe_event(event[1], event[2]) self._handle_subscribe_event(event[1], event[2])
elif event_type == "message": elif event_type == "message":
self._handle_inbound_message(event[1], event[2]) self._handle_inbound_message(event[1], event[2])
elif event_type == "published":
self._handle_publish_event(event[1])
def _drain_publish_queue(self) -> None: def _drain_publish_queue(self) -> None:
"""Publish queued work only after the session is fully subscribed.""" """Publish queued work only after the session is fully subscribed."""
@@ -548,10 +569,24 @@ class MqttClient(Communicator):
self._schedule_reconnect() self._schedule_reconnect()
return return
# a successful rc only means paho accepted the message; above qos 0 it
# is not durable until the broker acks, so keep a copy for replay
if queued_publish.retain and not message_info.is_published():
with self._retained_lock:
self._inflight_retained[message_info.mid] = (
queued_publish.topic,
queued_publish.payload,
)
if queued_publish.done is not None: if queued_publish.done is not None:
self._wait_for_publish(message_info) self._wait_for_publish(message_info)
queued_publish.done.set() queued_publish.done.set()
def _handle_publish_event(self, mid: int) -> None:
"""Drop the replay copy once the broker has acknowledged the message."""
with self._retained_lock:
self._inflight_retained.pop(mid, None)
def _wait_for_publish(self, message_info: mqtt.MQTTMessageInfo) -> None: def _wait_for_publish(self, message_info: mqtt.MQTTMessageInfo) -> None:
"""Pump the loop until a shutdown-critical publish is acknowledged.""" """Pump the loop until a shutdown-critical publish is acknowledged."""
deadline = time.monotonic() + MQTT_SHUTDOWN_FLUSH_TIMEOUT deadline = time.monotonic() + MQTT_SHUTDOWN_FLUSH_TIMEOUT
@@ -759,6 +794,25 @@ class MqttClient(Communicator):
"""Handle subscribe acknowledgements from paho.""" """Handle subscribe acknowledgements from paho."""
self._callback_queue.put(("subscribed", mid, reason_codes)) self._callback_queue.put(("subscribed", mid, reason_codes))
def _on_publish(
self,
client: mqtt.Client,
userdata: Any,
mid: int,
reason_code: mqtt.ReasonCode, # type: ignore[name-defined]
properties: Any,
) -> None:
"""Handle publish acknowledgements from paho.
Only tracked retained messages need an event. At the default qos 0
nothing is tracked, so this stays off the hot publish path.
"""
with self._retained_lock:
if mid not in self._inflight_retained:
return
self._callback_queue.put(("published", mid))
def _on_message( def _on_message(
self, self,
client: mqtt.Client, client: mqtt.Client,
+80 -2
View File
@@ -399,8 +399,9 @@ class TestMqttClientLifecycle(unittest.TestCase):
mock_client = MagicMock() mock_client = MagicMock()
mock_client.loop.return_value = mqtt.MQTT_ERR_SUCCESS mock_client.loop.return_value = mqtt.MQTT_ERR_SUCCESS
self.client.client = mock_client self.client.client = mock_client
message_info = MagicMock(rc=mqtt.MQTT_ERR_SUCCESS) message_info = MagicMock(rc=mqtt.MQTT_ERR_SUCCESS, mid=1)
message_info.is_published.side_effect = [False, True] # inflight tracking checks once, then _wait_for_publish polls
message_info.is_published.side_effect = [False, False, True]
mock_client.publish.return_value = message_info mock_client.publish.return_value = message_info
barrier = MagicMock() barrier = MagicMock()
@@ -453,6 +454,83 @@ class TestMqttClientLifecycle(unittest.TestCase):
self.client._pending_retained["frigate/available"], ("stopped", True) self.client._pending_retained["frigate/available"], ("stopped", True)
) )
def test_shutdown_barrier_releases_when_worker_crashes(self) -> None:
"""stop() can queue the final publish just as the worker dies, and
nothing drains the queue after that."""
barrier = threading.Event()
self.client._publish_queue.put(
QueuedPublish("frigate/available", "stopped", True, barrier)
)
with patch.object(
self.client,
"_mqtt_loop_worker",
side_effect=RuntimeError("unexpected bug"),
):
self.client._worker_main()
self.assertTrue(barrier.is_set())
def test_unacked_retained_publish_survives_reconnect(self) -> None:
"""Above qos 0 a successful rc only means paho queued the message, and
dropping the client drops its outbound queue with it."""
mock_client = MagicMock()
message_info = MagicMock(rc=mqtt.MQTT_ERR_SUCCESS, mid=12)
message_info.is_published.return_value = False
mock_client.publish.return_value = message_info
self.client.client = mock_client
self.client.connected = True
self.client._publish_direct(
QueuedPublish("frigate/profile/state", "armed", True)
)
self.assertEqual(
self.client._inflight_retained[12], ("frigate/profile/state", "armed")
)
self.client._cleanup_client()
self.assertEqual(self.client._inflight_retained, {})
self.assertEqual(
self.client._pending_retained["frigate/profile/state"], ("armed", True)
)
def test_acked_retained_publish_is_not_replayed(self) -> None:
mock_client = MagicMock()
message_info = MagicMock(rc=mqtt.MQTT_ERR_SUCCESS, mid=12)
message_info.is_published.return_value = False
mock_client.publish.return_value = message_info
self.client.client = mock_client
self.client.connected = True
self.client._publish_direct(
QueuedPublish("frigate/profile/state", "armed", True)
)
self.client._on_publish(mock_client, None, 12, MagicMock(), None)
self.client._drain_callback_queue()
self.assertEqual(self.client._inflight_retained, {})
self.client._cleanup_client()
self.assertEqual(self.client._pending_retained, {})
def test_already_published_retained_is_not_tracked(self) -> None:
"""At the default qos 0 paho reports the message as published inline,
so there is nothing to wait on."""
mock_client = MagicMock()
message_info = MagicMock(rc=mqtt.MQTT_ERR_SUCCESS, mid=12)
message_info.is_published.return_value = True
mock_client.publish.return_value = message_info
self.client.client = mock_client
self.client.connected = True
self.client._publish_direct(
QueuedPublish("frigate/profile/state", "armed", True)
)
self.assertEqual(self.client._inflight_retained, {})
def test_wait_for_publish_survives_disconnect_during_wait(self) -> None: def test_wait_for_publish_survives_disconnect_during_wait(self) -> None:
mock_client = MagicMock() mock_client = MagicMock()
self.client.client = mock_client self.client.client = mock_client