Skip to content

enhancement(mqtt source): support end-to-end acknowledgements#25770

Closed
antaresindustries wants to merge 5 commits into
vectordotdev:masterfrom
antaresindustries:mqtt-source-e2e-acknowledgements-poll-task
Closed

enhancement(mqtt source): support end-to-end acknowledgements#25770
antaresindustries wants to merge 5 commits into
vectordotdev:masterfrom
antaresindustries:mqtt-source-e2e-acknowledgements-poll-task

Conversation

@antaresindustries

Copy link
Copy Markdown

Summary

Supersedes #25666 with a revised implementation of the same feature (end-to-end
acknowledgements for the mqtt source, closing #21967). The core
acknowledgements option, manual_acks/deferred-PUBACK mechanism, and at-least-once
guarantee are unchanged from #25666. What's different is how the MQTT event loop
is polled and how backpressure from a slow downstream sink is handled:

  • Dedicated poller task. A single task used to both poll connection.poll()
    and process events (including awaiting out.send_batch()). Because that task
    also has to drain rumqttc's own outgoing request channel (used by
    client.ack()/client.subscribe()), enhancement(mqtt source): support end-to-end acknowledgements #25666 needed non-blocking try_ack/
    try_subscribe plus a hand-rolled retry queue (PendingAcks) and
    pending_resubscribe state to avoid deadlocking itself. This PR splits
    polling onto its own task, so the main task can just client.ack().await/
    client.subscribe().await directly — no retry-queue machinery needed.
  • Bounded backlog with drop-and-redeliver, not unbounded buffering.
    Decoupling polling from processing means a slow downstream sink could let the
    poller-to-main-task backlog grow without bound. Events are split by whether
    dropping them is protocol-safe: a QoS 1/2 publish that will actually be
    deferred-acked goes through a bounded channel and is dropped (with a warning)
    if the backlog saturates — safe, because the broker still considers it
    unacknowledged and redelivers it via its own retry timer. Everything else
    (ConnAck/SubAck/Disconnect, and any publish that wouldn't get broker
    redelivery — acknowledgements disabled, or QoS 0) goes through a separate
    unbounded channel and is never dropped, since losing one of those would
    either desync the connection's protocol state or be true unrecoverable data
    loss.
    • An earlier version of this forced a reconnect on backlog saturation
      instead of dropping. That reintroduces the same problem immediately: the
      broker refloods the freshly reconnected client from the same backlog, so
      under sustained backpressure it becomes a reconnect storm rather than
      actually recovering. Verified against a real broker with a large inflight
      window before settling on drop-and-redeliver.

Net effect: fewer lines than #25666's single-task approach despite the extra
task and channel-splitting, no retry-queue state machine, and it's now backed
by a dedicated test (mqtt_recovers_from_downstream_saturation) that proves
the backlog-saturation path actually recovers instead of deadlocking or losing
data outside the one case where loss is recoverable.

Vector configuration

sources:
  mqtt_in:
    type: mqtt
    host: localhost
    port: 1883
    topic: my/topic
    acknowledgements: true

How did you test this PR?

  • cargo check/cargo clippy -D warnings/cargo fmt --check on
    src/sources/mqtt/*.rs — clean.
  • Unit tests: cargo test -p vector --lib --no-default-features --features sources-mqtt sources::mqtt — all pass, including a new
    protocol_contract_matrix_for_backlog_drop_safety test covering every
    acknowledgements/QoS combination's drop-safety.
  • Integration tests against a real broker (EMQX, via cargo vdev int start mqtt / cargo vdev int test mqtt): all 4 tests pass, including the new
    mqtt_recovers_from_downstream_saturation, which stalls the sink, floods the
    backlog past capacity, and asserts (a) the source recovers instead of
    hanging and (b) at least one duplicate was observed, so the pass is pinned
    on the drop-and-redeliver path actually running rather than incidental
    buffering. Reran the new test repeatedly (stable, ~5s/run) and the full
    suite together.
  • The test broker's max_mqueue_len/max_inflight/retry_interval are raised
    in tests/integration/mqtt/config/compose.yaml (test env only) so the new
    test can actually reach the source's own backlog capacity instead of being
    bottlenecked by the broker's defaults first.

Change Type

  • Bug fix
  • New feature
  • Dependencies
  • Non-functional (chore, refactoring, docs)
  • Performance

Is this a breaking change?

  • Yes
  • No

Default is unchanged (acknowledgements off → manual acks off → rumqttc
auto-acks exactly as before). Opt-in only.

Does this PR include user facing changes?

References

Notes

This branch was rebased onto current master and force-pushed before opening
this PR (no review had started on it yet, so this is the one time that's
appropriate per the contributor notes below).

The mqtt source now participates in Vector's end-to-end acknowledgement
framework. When acknowledgements are enabled, the QoS 1 PUBACK is deferred
via an OrderedFinalizer until the events are delivered to all sinks; with
the source's existing clean_session=false + QoS AtLeastOnce subscription,
unacknowledged messages are redelivered after a reconnect (at-least-once).

Closes vectordotdev#21967
trying to get ahead of lots of smaller comments on how this works
by moving to more state machine like helpers into more dedicated structs
this removes much of the tracing by storing state within the task
…void unbounded growth

The poller task forwarded events to the main task over an unbounded channel, so a
stalled downstream sink let the backlog grow without limit. Switch to a bounded
channel and drop events on saturation instead: a dropped QoS 1/2 publish was never
acknowledged, so the broker's own retry timer redelivers it independently of any
reconnect. Forcing a reconnect on saturation was tried first but causes a
reconnect storm, since the broker immediately refloods the freshly reconnected
client from the same backlog.

Also raises the mqtt integration test broker's max_mqueue_len/max_inflight and
shortens retry_interval so the new saturation-recovery test can actually reach
and exercise this path against a real broker.
@antaresindustries antaresindustries requested review from a team as code owners July 7, 2026 20:42
@github-actions github-actions Bot added domain: sources Anything related to the Vector's sources domain: external docs Anything related to Vector's external, public documentation docs review on hold The documentation team reviews PRs only after a PR is approved by the COSE team. labels Jul 7, 2026

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: e18dc1000a

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/sources/mqtt/source.rs Outdated
Err(mpsc::error::TrySendError::Full(_)) => warn_event_backlog_saturated(),
Err(mpsc::error::TrySendError::Closed(_)) => break,
}
} else if guaranteed_tx.send(event).is_err() {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Bound non-deferred MQTT events instead of buffering unboundedly

When acknowledgements are disabled (the default) or the broker delivers QoS 0, safe_to_drop_under_backlog is false, so every publish goes through this unbounded sender. Because the poller is now independent of out.send_batch, a slow or stalled downstream lets the poller continue reading full payloads while guaranteed_rx grows without limit; the previous single loop stopped polling while send_batch was blocked. Under normal MQTT traffic this can OOM the process, so non-droppable publishes still need backpressure rather than an unbounded queue.

Useful? React with 👍 / 👎.

Comment thread src/sources/mqtt/source.rs Outdated
client.ack(&entry.publish).await.map_err(|_| ())?;
}
},
mqtt_event = droppable_rx.recv() => {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Preserve ConnAck ordering before processing publishes

When a persistent session already has queued QoS 1 messages, the poller can enqueue the ConnAck on guaranteed_rx and publishes on droppable_rx before this select runs. Since the select may take the droppable branch first, those publishes are processed with connection_generation == 0 and connected == false; their finalizers are then skipped by should_ack_finalized_publish, leaving delivered messages unacked and forcing redelivery or broker inflight stalls even though the ConnAck was available. The connection-state events need to be processed before publish events, or the split queues need to preserve protocol order.

Useful? React with 👍 / 👎.

Comment thread src/sources/mqtt/source.rs Outdated
if safe_to_drop_under_backlog(&event, acknowledgements) {
match droppable_tx.try_send(event) {
Ok(()) => {}
Err(mpsc::error::TrySendError::Full(_)) => warn_event_backlog_saturated(),

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Reconnect instead of dropping publishes on a live session

When acknowledgements are enabled and the downstream backlog fills, this path drops the only Vector-side copy but keeps the MQTT connection open. Brokers are only required to resend unacknowledged QoS > 0 publishes after a reconnect (and MQTT 5 forbids same-connection resends), so brokers without EMQX-style retry timers will keep waiting for a PUBACK/PUBREC that Vector can no longer send; once the broker's in-flight window fills, delivery can stall indefinitely. The saturation path needs to force a reconnect or retain the publish until it can be processed.

Useful? React with 👍 / 👎.

Comment thread src/sources/mqtt/source.rs Outdated
// disabled (rumqttc auto-acks in that mode). MQTT PUBACKs are independent
// per packet id (unlike Kafka offsets), so finalization is unordered — a
// slow/stuck batch must not hold back acks for already-delivered publishes.
let (finalizer, mut ack_stream) = UnorderedFinalizer::<FinalizerEntry>::maybe_new(

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Preserve MQTT acknowledgement order

With acknowledgements enabled, using UnorderedFinalizer lets a later batch send its PUBACK/PUBREC before an earlier publish whose downstream delivery is still pending. MQTT requires clients to acknowledge QoS 1/2 publishes in the order they were received, so a slow earlier sink batch can make this source emit out-of-order protocol acknowledgements; use an ordered finalizer or otherwise hold later MQTT acks until prior publishes have finalized.

Useful? React with 👍 / 👎.

Comment on lines +284 to +290
if let Some((status, entry)) = entry
&& protocol_state.should_ack_finalized_publish(
status,
entry.connection_generation,
)
{
client.ack(&entry.publish).await.map_err(|_| ())?;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Drain disconnect events before sending deferred acks

If the poller has already observed a connection error and queued Disconnect, this branch can still win the select first while protocol_state.connected and connection_generation still describe the old connection. A batch finalized in that window queues a PUBACK/PUBREC for a packet id from the dead connection, and rumqttc can send it after reconnecting, including after a fresh session where that acknowledgement is unsolicited. Process pending connection-state events before finalizer completions, or mark the state disconnected as soon as the poller reports the error.

Useful? React with 👍 / 👎.

…reconnect escalation

Addressed PR review findings:

- Merge the droppable/guaranteed event channels back into one, preserving the
  order events were polled in. Splitting into two channels (from the previous
  commit) broke this: tokio::select! doesn't guarantee relative order between
  two channels when both have items ready, so a backlogged publish could reach
  the main task before a ConnAck that was actually polled first, letting
  ProtocolState be acted on while stale.
- Switch UnorderedFinalizer to OrderedFinalizer: PUBACKs must be sent in the
  order their publishes were received (MQTT-4.6.0-2), same as the kafka
  source's ordered offset commits.
- Drop `biased` on the main select: it fixed a narrow stale-ack race but
  starves the ack-stream branch entirely under sustained publish volume,
  since the events channel being ready again next iteration is the common
  case, not the exception. Fair random selection keeps that race narrow
  instead of guaranteeing worse behavior under load.

Also attempted, then reverted, a fix for the remaining gap (dropped messages
rely on broker-specific retry-while-connected behavior, which isn't spec-
guaranteed): forcing a reconnect once the backlog stays saturated long enough.
Verified against a live EMQX broker that this triggers a broker-side
`unexpected_sock_close`/`einval` error after which the session stops
delivering anything further at all -- worse than the problem it was meant to
solve. Reverted to plain dropping and documented the limitation in code,
since forcing a *correct* reconnect needs more investigation than fits here.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 295993ef7e

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

entry.connection_generation,
)
{
client.ack(&entry.publish).await.map_err(|_| ())?;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Hold later MQTT acks after a failed publish

Even with the new OrderedFinalizer, this branch only filters for BatchStatus::Delivered: if publish N finalizes as Errored/Rejected and publish N+1 is delivered while the connection stays up, N is skipped and this line still sends the PUBACK/PUBREC for N+1. MQTT requires acknowledgements to follow receive order (MQTT-4.6.0-2), so a normal downstream rejection can leave an earlier packet unacknowledged while later packets are acknowledged on the same session; brokers may then stall or treat the client as non-conformant. Once an earlier finalizer is not acked, later acks should be held until reconnect or the connection should be closed.

Useful? React with 👍 / 👎.

if actions.flush_finalizer
&& let Some(finalizer) = finalizer
{
finalizer.flush();

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Flush queued finalizer entries on reconnect

This flush() only drops entries that the FinalizerSet task has already moved into its internal ordered future set; entries that finalizer.add sent on the unbounded pending-entry channel but the task has not yet pulled survive the reconnect flush. If a connection drops right after forwarding a publish, that stale receiver can later be inserted ahead of publishes from the new connection, and because OrderedFinalizer waits in insertion order, a slow or never-finalized old batch can hold all later PUBACK/PUBRECs even though the old packet IDs were meant to be discarded.

Useful? React with 👍 / 👎.

// entry to avoid pinning payloads in memory while sinks process.
if let Some((finalizer, receiver)) = finalizer.zip(batch_receiver) {
publish.payload = Default::default();
finalizer.add(

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Coalesce duplicate MQTT publishes before acking

When the broker retries the same QoS 1/2 PUBLISH before the first copy finalizes, this registers another finalizer with the same packet id and connection generation. Both copies can later satisfy should_ack_finalized_publish, so after the first PUBACK/PUBREC completes the broker flow, a later stale ack for the duplicate may collide with that packet id if the broker has already reused it for another in-flight publish. Track pending packet ids and avoid adding a second deferred ack for retransmissions of the same broker message.

Useful? React with 👍 / 👎.

@antaresindustries

Copy link
Copy Markdown
Author

Thanks for the review — the codex findings here were valuable and caught real bugs (ConnAck/publish reordering across the split channels, the stale-ack race, and PUBACKs not being sent in receipt order per [MQTT-4.6.0-2]). All three are fixed in the latest commit here.

The remaining finding (dropping a message under backlog saturation relies on broker-specific retry-while-connected behavior, since the MQTT spec only guarantees redelivery on reconnect, not on an already-open connection) led somewhere I want to flag directly rather than paper over.

I attempted the "obvious" fix: force a reconnect once the backlog stays saturated for too long, rate-limited to avoid a reconnect storm. Verified against a live EMQX broker, this actually made things worse — the non-graceful reconnect (EventLoop::clean()) triggered a broker-side unexpected_sock_close/einval error, after which the session stopped delivering anything further at all. I reverted that escalation and documented the limitation in code instead, since forcing a correct (graceful) reconnect needs more investigation than fits in this PR.

Sitting back from that, I don't think this branch's core trade-off is the right one for a feature whose whole point is a delivery guarantee. The single-task design (#25666's approach, which this branch replaced) never drops an incoming publish at all — when downstream can't keep up, it just stops polling, which throttles the broker via ordinary TCP backpressure, with no broker-specific assumptions anywhere. This branch's poller/main-task split enables blocking client.ack()/client.subscribe() calls (avoiding that branch's PendingAcks retry-queue), but that split requires bounding a channel that can't safely block without risking deadlock — so it either drops messages (this branch) or grows unboundedly (an earlier version of this branch). Today's finding is evidence that closing that gap properly is harder than it looked, and in the meantime this branch trades away the guarantee acknowledgements exists to provide, in exchange for less code in the ack path. That's the wrong trade for this feature.

I'm going back to the #25666 approach as the final implementation (carrying forward the OrderedFinalizer fix from here, since that finding applies to both designs equally) and will close this PR in favor of reopening/recreating that one. Leaving this PR and thread open for now in case anyone wants to dig into the EMQX interaction further — the "force a graceful reconnect instead of an abrupt one" direction may still be worth pursuing independently, since the single-task design also degrades to redelivery-on-natural-reconnect if a broker doesn't retry independently.

@antaresindustries

Copy link
Copy Markdown
Author

Closing in favor of #25785, which goes back to the single-task design (see the discussion on this PR for why: the two-task split here needs to either drop messages under sustained backlog or grow memory unboundedly, and closing that gap properly turned out to be harder than expected — a forced-reconnect attempt broke against a live EMQX broker). #25785 has since had its own review pass and fixes for the ack-ordering and finalizer-flush issues found there.

@github-actions github-actions Bot locked and limited conversation to collaborators Jul 9, 2026
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

docs review on hold The documentation team reviews PRs only after a PR is approved by the COSE team. domain: external docs Anything related to Vector's external, public documentation domain: sources Anything related to the Vector's sources

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Handle Incoming::PubAck in the mqtt source

1 participant