Skip to content

ARTEMIS-6189 QoS 1 & 2 message handling is not resilient - #6613

Open
jbertram wants to merge 4 commits into
apache:mainfrom
jbertram:ARTEMIS-6189
Open

ARTEMIS-6189 QoS 1 & 2 message handling is not resilient#6613
jbertram wants to merge 4 commits into
apache:mainfrom
jbertram:ARTEMIS-6189

Conversation

@jbertram

Copy link
Copy Markdown
Contributor

This PR implements MQTT QoS 1 and QoS 2 resiliency so that in-flight message flows survive broker restarts/crashes and client reconnects.

See the Jira for details about exactly what isn't working.

See the individual commit messages for an overview of what's been changed.

I know this is big, but this was a complex task that required refactoring many core pieces of the MQTT implementation. It expanded a lot as I was working through it. Claude helped along the way.

The full test-suite is being run now, but I wanted to get it out for review sooner than later.

jbertram and others added 4 commits August 13, 2026 13:06
The JournalHashMap collection ID was hardcoded as a primitive long. This
generalizes it to a type parameter, enabling journal hash maps keyed by
arbitrary types such as String. This is needed for MQTT packet-ID
correlation, which is keyed by client ID.

- Collection ID encoding/decoding is now delegated to the persister via
  abstract methods, rather than hardcoded as long read/write
- The internal map storage switches from Netty's LongObjectHashMap to a
  standard HashMap to support non-primitive keys
- Null-safety fix in JournalHashMap.remove() prevents an NPE when
  removing a key that doesn't exist

Co-Authored-By: Claude <noreply@anthropic.com>
Replace the in-memory and per-client resources with journal-persisted
packet-ID correlation and DuplicateIDCache-backed packet ID caches
so that QoS 1 and QoS 2 message flows survive broker restarts.

- Packet ID assignments are persisted to the journal via a
  JournalHashMap so that when a message is redelivered after a broker
  restart the same packet ID is reused
- Inbound QoS 2 duplicate detection and outbound QoS 2 PUBREC tracking
  use DuplicateIDCache instead of in-memory sets, surviving restarts
- The per-client QoS 2 management queue (used for PUBREL persistence)
  is eliminated; PUBREL retransmission is driven by iterating the
  PUBREC cache on reconnect
- Objects related to resilience (caches, journal maps) are only created
  when actually used, avoiding overhead for QoS 0/1-only clients
- Sends and acknowledgements are now explicitly transactional, ensuring
  atomicity between message operations and correlation store and cache
  updates
- Session startup is asynchronous with CountDownLatch synchronization,
  preventing a race where client packets arrive before the session is
  fully initialized after CONNACK
- Duplicate QoS 2 publish detection is moved earlier into the protocol
  handler
- MQTT message filters are constructed once in the protocol manager and
  shared, rather than duplicated per subscription manager instance
- Each subscription item now directly holds its consumer reference,
  eliminating a separate consumer lookup map
- Graceful handling of ActiveMQShutdownException during message deletion
  and rollback redelivery avoids misleading log messages during broker
  restart
- Error logging throughout the MQTT protocol includes client ID and
  packet ID context for easier debugging

Co-Authored-By: Claude <noreply@anthropic.com>
Add comprehensive tests for MQTT QoS 1 and QoS 2 resiliency across
broker restarts and client reconnects. These tests use interceptors to
interrupt message flows at every stage of the QoS handshake and verify
correct redelivery, exactly-once semantics, and proper state cleanup.

- QoS 1 subscriber tests cover broker restart and client disconnect
  both before and after PUBACK, verifying packet ID reuse with DUP flag
- QoS 1 publisher tests are not needed because the broker has no
  intermediate state to maintain; it either persists the inbound message
  or it doesn't, and the client drives retransmission entirely on its
  own
- QoS 2 publisher tests cover interruptions at all four stages of the
  handshake (before/after PUBREC, before/after PUBCOMP) for both broker
  restart and client disconnect
- QoS 2 subscriber tests cover the same stages plus session expiry
  cleanup via zero-interval disconnect, timed scanner, and clean-start
  reconnect
- A soak test runs many publishers and many subscribers sending many
  QoS 2 messages with periodic broker restarts, verifying exactly-once
  delivery
- HiveMQ MQTT 5 client added as a test dependency

Co-Authored-By: Claude <noreply@anthropic.com>
During repeated execution of MQTT tests I fixed these flaky tests:

- Add a notification listener to the cluster remote subscribe test to
  synchronize on SESSION_CREATED, preventing a race in subscription
  takeover
- Fix non-deterministic message ordering in testAutoDeleteRetainedQueue
  by consuming the retained message before publishing non-retained
  messages
- Use Wait.assertFalse for async disconnect detection instead of an
  immediate assertion

Co-Authored-By: Claude <noreply@anthropic.com>
* @return An array of integers representing the list of accepted QoS for each topic
*/
int[] addSubscriptions(List<MqttTopicSubscription> subscriptions, Integer subscriptionIdentifier) throws Exception {
session.awaitStart();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This kind of thing won't scale very well... you will have a thread holding the start. If you have many threads starting you may starve and never be able to complete.

Isn't possible to fully play with callbacks?

IllegalStateException unableToGenerateID();

@Message(id = 850002, value = "StorageManager is null")
IllegalStateException storageManagerIsNull();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I wouldn't use the Bundle for such thing.. StorageManager null is clearly an internal error, not supposed to happen. I would probably just use assert storageManager != null in its palce.

private Queue sessionStore;
private final Map<String, MQTTConnection> connectedClients = new ConcurrentHashMap<>();
private final boolean subscriptionPersistenceEnabled;
private final JournalHashMapProvider<String, PacketIdCorrelationKey, Integer, Object> journalHashMapProvider;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

what's the lifecycle of these records? is there any cleanup in place? how many records are we going to create here? one per session? one per ack? I'm kind of lost here about

public PacketIdCache(MQTTSession session, TYPE type) {
this.session = session;
this.postOffice = session.getServer().getPostOffice();
this.cacheName = getCacheName(session.getServer().getInternalNamingPrefix(), session.getState().getClientId(), type);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

from what I understand you're creating a duplicate_ID_cache based on the clientID.

What's the cleanup. Say I connect now with a clientID.. and never again?

I can create enough records to OME the server? or is there a cleanup?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants