Skip to content

Commit 7f4960c

Browse files
cartertinneyCopilot
andcommitted
fix: release cancelled MQTT operations
Remove transport callbacks when operations complete through timeout or retry paths, and discard Paho QoS state when the SDK cancels in-flight work. This prevents cancelled publish and subscribe objects from surviving dropped-connection tests or being completed again by late acknowledgements. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 26b9120 commit 7f4960c

4 files changed

Lines changed: 144 additions & 64 deletions

File tree

‎azure-iot-device/azure/iot/device/common/mqtt_transport.py‎

Lines changed: 34 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -376,7 +376,7 @@ def shutdown(self):
376376
self._mqtt_client.on_disconnect = None
377377
# Now disconnect and do some additional cleanup.
378378
self._force_transport_disconnect_and_cleanup()
379-
self._op_manager.cancel_all_operations()
379+
self.cancel_all_operations()
380380

381381
def connect(self, password=None):
382382
"""
@@ -480,7 +480,7 @@ def disconnect(self, clear_inflight=False):
480480
)
481481
# Still clear inflight operations since we're effectively disconnected
482482
if clear_inflight:
483-
self._op_manager.cancel_all_operations()
483+
self.cancel_all_operations()
484484
else:
485485
# This could result in ConnectionDroppedError or ProtocolClientError
486486
err = _create_error_from_rc_code(rc)
@@ -491,7 +491,21 @@ def disconnect(self, clear_inflight=False):
491491
# cause a force disconnect via the on_disconnect handler, thus it is safe to clear
492492
# ops here and now.
493493
if clear_inflight:
494-
self._op_manager.cancel_all_operations()
494+
self.cancel_all_operations()
495+
496+
def cancel_operation(self, callback):
497+
"""Stop tracking the operation associated with a transport callback."""
498+
self._op_manager.cancel_operation(callback)
499+
500+
def cancel_all_operations(self):
501+
"""Cancel SDK operations and discard corresponding Paho QoS state."""
502+
self._op_manager.cancel_all_operations()
503+
504+
# Paho retains QoS 1/2 messages for a future reconnect. Once the SDK has cancelled the
505+
# corresponding operations, those messages must not be retried or kept alive.
506+
with self._mqtt_client._out_message_mutex:
507+
self._mqtt_client._out_messages.clear()
508+
self._mqtt_client._inflight_messages = 0
495509

496510
def subscribe(self, topic, qos=1, callback=None):
497511
"""
@@ -509,7 +523,7 @@ def subscribe(self, topic, qos=1, callback=None):
509523
"""
510524
logger.info("subscribing to {} with qos {}".format(topic, qos))
511525
try:
512-
(rc, mid) = self._mqtt_client.subscribe(topic, qos=qos)
526+
rc, mid = self._mqtt_client.subscribe(topic, qos=qos)
513527
except ValueError:
514528
raise
515529
except Exception as e:
@@ -534,7 +548,7 @@ def unsubscribe(self, topic, callback=None):
534548
"""
535549
logger.info("unsubscribing from {}".format(topic))
536550
try:
537-
(rc, mid) = self._mqtt_client.unsubscribe(topic)
551+
rc, mid = self._mqtt_client.unsubscribe(topic)
538552
except ValueError:
539553
raise
540554
except Exception as e:
@@ -568,7 +582,7 @@ def publish(self, topic, payload, qos=1, callback=None):
568582
"""
569583
logger.info("publishing on {}".format(topic))
570584
try:
571-
(rc, mid) = self._mqtt_client.publish(topic=topic, payload=payload, qos=qos)
585+
rc, mid = self._mqtt_client.publish(topic=topic, payload=payload, qos=qos)
572586
except ValueError:
573587
raise
574588
except TypeError:
@@ -660,9 +674,9 @@ def complete_operation(self, mid):
660674
else:
661675
# Otherwise, store the mid as an unknown response
662676
logger.debug("Response received for unknown MID: {}".format(mid))
663-
self._unknown_operation_completions[
664-
mid
665-
] = mid # TODO: set something more useful here
677+
self._unknown_operation_completions[mid] = (
678+
mid # TODO: set something more useful here
679+
)
666680

667681
# Now that the lock has been released, if the callback should be triggered,
668682
# go ahead and trigger it now.
@@ -680,6 +694,17 @@ def complete_operation(self, mid):
680694
# fully expected. QOS=1 means we might get 2 PUBACKs
681695
logger.debug("No callback set for MID: {}".format(mid))
682696

697+
def cancel_operation(self, callback):
698+
"""Remove pending operations associated with a callback without invoking it."""
699+
with self._lock:
700+
matching_mids = [
701+
mid
702+
for mid, pending_callback in self._pending_operation_callbacks.items()
703+
if pending_callback is callback
704+
]
705+
for mid in matching_mids:
706+
del self._pending_operation_callbacks[mid]
707+
683708
def cancel_all_operations(self):
684709
"""Complete all pending operations with cancellation, removing MID tracking"""
685710
logger.debug("Cancelling all pending operations")

‎azure-iot-device/azure/iot/device/common/pipeline/pipeline_stages_mqtt.py‎

Lines changed: 33 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -124,6 +124,35 @@ def _cancel_connection_watchdog(self, op):
124124
except AttributeError:
125125
pass
126126

127+
def _create_transport_operation_callback(self, op, acknowledgement_name):
128+
@pipeline_thread.invoke_on_pipeline_thread_nowait
129+
def on_complete(cancelled=False):
130+
if op.completed or op.completing:
131+
logger.debug(
132+
"{}({}): Ignoring {} for an already-completed operation".format(
133+
self.name, op.name, acknowledgement_name
134+
)
135+
)
136+
elif cancelled:
137+
op.complete(
138+
error=pipeline_exceptions.OperationCancelled(
139+
"Operation cancelled before {} received".format(acknowledgement_name)
140+
)
141+
)
142+
else:
143+
logger.debug(
144+
"{}({}): {} received. completing op.".format(
145+
self.name, op.name, acknowledgement_name
146+
)
147+
)
148+
op.complete()
149+
150+
def remove_transport_callback(op, error):
151+
self.transport.cancel_operation(on_complete)
152+
153+
op.add_callback(remove_transport_callback)
154+
return on_complete
155+
127156
@pipeline_thread.runs_on_pipeline_thread
128157
def _run_op(self, op):
129158
if isinstance(op, pipeline_ops_base.InitializePipelineOperation):
@@ -259,20 +288,7 @@ def on_disconnect_complete(op, error):
259288

260289
elif isinstance(op, pipeline_ops_mqtt.MQTTPublishOperation):
261290
logger.debug("{}({}): publishing on {}".format(self.name, op.name, op.topic))
262-
263-
@pipeline_thread.invoke_on_pipeline_thread_nowait
264-
def on_complete(cancelled=False):
265-
if cancelled:
266-
op.complete(
267-
error=pipeline_exceptions.OperationCancelled(
268-
"Operation cancelled before PUBACK received"
269-
)
270-
)
271-
else:
272-
logger.debug(
273-
"{}({}): PUBACK received. completing op.".format(self.name, op.name)
274-
)
275-
op.complete()
291+
on_complete = self._create_transport_operation_callback(op, "PUBACK")
276292

277293
try:
278294
self.transport.publish(topic=op.topic, payload=op.payload, callback=on_complete)
@@ -281,20 +297,7 @@ def on_complete(cancelled=False):
281297

282298
elif isinstance(op, pipeline_ops_mqtt.MQTTSubscribeOperation):
283299
logger.debug("{}({}): subscribing to {}".format(self.name, op.name, op.topic))
284-
285-
@pipeline_thread.invoke_on_pipeline_thread_nowait
286-
def on_complete(cancelled=False):
287-
if cancelled:
288-
op.complete(
289-
error=pipeline_exceptions.OperationCancelled(
290-
"Operation cancelled before SUBACK received"
291-
)
292-
)
293-
else:
294-
logger.debug(
295-
"{}({}): SUBACK received. completing op.".format(self.name, op.name)
296-
)
297-
op.complete()
300+
on_complete = self._create_transport_operation_callback(op, "SUBACK")
298301

299302
try:
300303
self.transport.subscribe(topic=op.topic, callback=on_complete)
@@ -303,20 +306,7 @@ def on_complete(cancelled=False):
303306

304307
elif isinstance(op, pipeline_ops_mqtt.MQTTUnsubscribeOperation):
305308
logger.debug("{}({}): unsubscribing from {}".format(self.name, op.name, op.topic))
306-
307-
@pipeline_thread.invoke_on_pipeline_thread_nowait
308-
def on_complete(cancelled=False):
309-
if cancelled:
310-
op.complete(
311-
error=pipeline_exceptions.OperationCancelled(
312-
"Operation cancelled before UNSUBACK received"
313-
)
314-
)
315-
else:
316-
logger.debug(
317-
"{}({}): UNSUBACK received. completing op.".format(self.name, op.name)
318-
)
319-
op.complete()
309+
on_complete = self._create_transport_operation_callback(op, "UNSUBACK")
320310

321311
try:
322312
self.transport.unsubscribe(topic=op.topic, callback=on_complete)
@@ -451,11 +441,7 @@ def _on_mqtt_disconnected(self, cause=None):
451441
self.name
452442
)
453443
)
454-
# TODO: Remove private access to the op manager (this layer shouldn't know about it)
455-
# This is a stopgap. I didn't want to invest too much infrastructure into a cancel flow
456-
# given that future development of individual operation cancels might affect the
457-
# approach to cancelling inflight ops waiting in the transport.
458-
self.transport._op_manager.cancel_all_operations()
444+
self.transport.cancel_all_operations()
459445

460446
# Regardless of cause, it is now a ConnectionDroppedError. Log it and swallow it.
461447
# Higher layers will see that we're disconnected and may reconnect as necessary.

‎tests/unit/common/pipeline/test_pipeline_stages_mqtt.py‎

Lines changed: 43 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
import sys
99
import threading
1010
from azure.iot.device.common import transport_exceptions, handle_exceptions
11+
from azure.iot.device.common.mqtt_transport import OperationManager
1112
from azure.iot.device.common.pipeline import (
1213
pipeline_ops_base,
1314
pipeline_ops_mqtt,
@@ -616,6 +617,16 @@ def test_complete_with_cancel(self, mocker, stage, op):
616617
assert op.completed
617618
assert isinstance(op.error, pipeline_exceptions.OperationCancelled)
618619

620+
@pytest.mark.it("Removes its transport callback when completed by another stage")
621+
def test_removes_transport_callback_on_external_completion(self, stage, op):
622+
stage.run_op(op)
623+
transport_callback = stage.transport.publish.call_args[1]["callback"]
624+
625+
op.complete()
626+
transport_callback()
627+
628+
stage.transport.cancel_operation.assert_called_once_with(transport_callback)
629+
619630
@pytest.mark.it(
620631
"Completes the operation using the exception that was raised, if an exception was raised from the MQTTTransport"
621632
)
@@ -676,6 +687,23 @@ def test_complete_with_cancel(self, mocker, stage, op):
676687
assert op.completed
677688
assert isinstance(op.error, pipeline_exceptions.OperationCancelled)
678689

690+
@pytest.mark.it("Removes its transport callback when completed by another stage")
691+
def test_removes_transport_callback_on_external_completion(self, stage, op):
692+
manager = OperationManager()
693+
stage.transport.subscribe.side_effect = lambda topic, callback: manager.establish_operation(
694+
mid=1, callback=callback
695+
)
696+
stage.transport.cancel_operation.side_effect = manager.cancel_operation
697+
stage.run_op(op)
698+
transport_callback = stage.transport.subscribe.call_args[1]["callback"]
699+
assert manager._pending_operation_callbacks == {1: transport_callback}
700+
701+
op.complete()
702+
transport_callback()
703+
704+
stage.transport.cancel_operation.assert_called_once_with(transport_callback)
705+
assert manager._pending_operation_callbacks == {}
706+
679707
@pytest.mark.it(
680708
"Completes the operation using the exception that was raised, if an exception was raised from the MQTTTransport"
681709
)
@@ -736,6 +764,16 @@ def test_complete_with_cancel(self, mocker, stage, op):
736764
assert op.completed
737765
assert isinstance(op.error, pipeline_exceptions.OperationCancelled)
738766

767+
@pytest.mark.it("Removes its transport callback when completed by another stage")
768+
def test_removes_transport_callback_on_external_completion(self, stage, op):
769+
stage.run_op(op)
770+
transport_callback = stage.transport.unsubscribe.call_args[1]["callback"]
771+
772+
op.complete()
773+
transport_callback()
774+
775+
stage.transport.cancel_operation.assert_called_once_with(transport_callback)
776+
739777
@pytest.mark.it(
740778
"Completes the operation using the exception that was raised, if an exception was raised from the MQTTTransport"
741779
)
@@ -1168,25 +1206,22 @@ def cause(self, request, arbitrary_exception):
11681206
@pytest.mark.it(
11691207
"Cancels all in-flight operations in the transport, if connection retry has been disabled"
11701208
)
1171-
def test_inflight_no_retry(self, mocker, stage, cause):
1172-
stage.transport._op_manager = mocker.MagicMock()
1173-
mock_cancel = stage.transport._op_manager.cancel_all_operations
1209+
def test_inflight_no_retry(self, stage, cause):
1210+
mock_cancel = stage.transport.cancel_all_operations
11741211
stage.nucleus.pipeline_configuration.connection_retry = False
11751212
assert stage._pending_connection_op is None
11761213
assert mock_cancel.call_count == 0
11771214

11781215
# Trigger disconnect
11791216
stage.transport.on_mqtt_disconnected_handler(cause)
11801217

1181-
assert mock_cancel.call_count == 1
1182-
assert mock_cancel.call_args == mocker.call()
1218+
mock_cancel.assert_called_once_with()
11831219

11841220
@pytest.mark.it(
11851221
"Does not cancel any in-flight operations in the transport if connection retry has been enabled"
11861222
)
1187-
def test_inflight_unexpected_with_retry(self, mocker, stage, cause):
1188-
stage.transport._op_manager = mocker.MagicMock()
1189-
mock_cancel = stage.transport._op_manager.cancel_all_operations
1223+
def test_inflight_unexpected_with_retry(self, stage, cause):
1224+
mock_cancel = stage.transport.cancel_all_operations
11901225
stage.nucleus.pipeline_configuration.connection_retry = True
11911226
assert stage._pending_connection_op is None
11921227
assert mock_cancel.call_count == 0

‎tests/unit/common/test_mqtt_transport.py‎

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1008,6 +1008,22 @@ def test_does_not_set_thread_to_none_on_exception_in_non_paho_thread(
10081008
assert mock_mqtt_client._thread is not None
10091009

10101010

1011+
@pytest.mark.describe("MQTTTransport - .cancel_all_operations()")
1012+
class TestCancelAllOperations(object):
1013+
@pytest.mark.it("Cancels SDK operations and clears Paho outgoing message state")
1014+
def test_clears_sdk_and_paho_operations(self, mocker, mock_mqtt_client, transport):
1015+
cancel_sdk_operations = mocker.patch.object(transport._op_manager, "cancel_all_operations")
1016+
mock_mqtt_client._out_message_mutex = threading.Lock()
1017+
mock_mqtt_client._out_messages = {1: mqtt.MQTTMessage(mid=1)}
1018+
mock_mqtt_client._inflight_messages = 1
1019+
1020+
transport.cancel_all_operations()
1021+
1022+
cancel_sdk_operations.assert_called_once_with()
1023+
assert mock_mqtt_client._out_messages == {}
1024+
assert mock_mqtt_client._inflight_messages == 0
1025+
1026+
10111027
@pytest.mark.describe("MQTTTransport - OCCURRENCE: Disconnect Completed")
10121028
class TestEventDisconnectCompleted(object):
10131029
@pytest.fixture(
@@ -2486,6 +2502,24 @@ def stop_tracking_mocks(*args):
24862502
assert mocker.call.cb() not in calls_during_lock
24872503

24882504

2505+
@pytest.mark.describe("OperationManager - .cancel_operation()")
2506+
class TestOperationManagerCancelOperation(object):
2507+
@pytest.mark.it("Removes only operations associated with the provided callback")
2508+
def test_remove_matching_pending_operations(self, mocker):
2509+
manager = OperationManager()
2510+
callback_to_cancel = mocker.MagicMock()
2511+
callback_to_keep = mocker.MagicMock()
2512+
manager.establish_operation(mid=1, callback=callback_to_cancel)
2513+
manager.establish_operation(mid=2, callback=callback_to_keep)
2514+
manager.establish_operation(mid=3, callback=callback_to_cancel)
2515+
2516+
manager.cancel_operation(callback_to_cancel)
2517+
2518+
assert manager._pending_operation_callbacks == {2: callback_to_keep}
2519+
callback_to_cancel.assert_not_called()
2520+
callback_to_keep.assert_not_called()
2521+
2522+
24892523
@pytest.mark.describe("OperationManager - .cancel_all_operations()")
24902524
class TestOperationManagerCancelAllOperations(object):
24912525
@pytest.mark.it("Removes all MID tracking for all pending operations")

0 commit comments

Comments
 (0)