Skip to content

feat(connectors): add RabbitMQ sink - #3973

Open
amr8t wants to merge 5 commits into
apache:masterfrom
amr8t:rabbitmq_sink
Open

feat(connectors): add RabbitMQ sink#3973
amr8t wants to merge 5 commits into
apache:masterfrom
amr8t:rabbitmq_sink

Conversation

@amr8t

@amr8t amr8t commented Aug 26, 2026

Copy link
Copy Markdown

Replaces #3811 (could not be reopened after rebasing onto master). Rebased to current master and addressed all review comments.

Which issue does this PR address?

Relates to #3747

Summary

Adds the RabbitMQ sink connector via the lapin client. Source will be a separate PR.

Review feedback addressed

  • amqp_url stored as secrecy::SecretString, redacted via iggy_common::serde_secret (never logged or serialized verbatim)
  • Exchange declaration durability exposed as durable_exchange config (default true)
  • Publisher confirms matched explicitly: only Ack(None) counts as success; Ack(Some(_))/Nack(_) (unroutable mandatory publish) fail the batch permanently
  • AMQP delivery mode exposed as delivery_mode config (default persistent)
  • User-supplied Iggy headers forwarded as AMQP headers (LongString for strings, ByteArray for binary) so headers exchanges can route on them
  • iggy_offset encoded as full i64 instead of narrowing to u32
  • Immediate basic_publish errors routed through the same retry flow; the retry loop resumes at the first unconfirmed message instead of republishing confirmed ones
  • JSON payloads serialized via Payload::try_to_bytes() (no deep clone)
  • README documents every config field with type, default, and behavior
  • Integration tests cover durable exchanges, unroutable routing keys, and headers-exchange routing; unit tests cover offset > u32::MAX, header encoding, delivery mode, and retry classification

AI usage

Which tools? opencode
Scope of usage? investigation, code suggestions, implementation of review feedback
How did you verify the generated code works correctly? Read through, compiled, ran unit tests, ran clippy/fmt/sort
Can you explain every line of the code if asked? Yes

@github-actions

Copy link
Copy Markdown

Thanks for the PR. It is labeled S-waiting-on-review and queued for review.

Slash commands (own line, regular comment) move it around the queue:

  • /ready - back to S-waiting-on-review after addressing feedback
  • /author - flip to S-waiting-on-author while you finish changes
  • /request-review @user-or-team - request a reviewer

See CONTRIBUTING.md for details.

@github-actions github-actions Bot added the S-waiting-on-review PR is waiting on a reviewer label Aug 26, 2026
@amr8t amr8t closed this Aug 26, 2026
@github-actions github-actions Bot removed the S-waiting-on-review PR is waiting on a reviewer label Aug 26, 2026
@amr8t amr8t reopened this Aug 26, 2026
Comment thread core/connectors/sinks/rabbitmq_sink/src/lib.rs Outdated
Comment thread core/connectors/sinks/rabbitmq_sink/src/lib.rs Outdated
Comment thread core/connectors/sinks/rabbitmq_sink/src/lib.rs Outdated
Comment thread core/connectors/sinks/rabbitmq_sink/src/lib.rs Outdated
Comment thread core/connectors/sinks/rabbitmq_sink/README.md Outdated
@codecov

codecov Bot commented Aug 31, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 73.75566% with 116 lines in your changes missing coverage. Please review.
✅ Project coverage is 84.89%. Comparing base (03f5b30) to head (3a84928).
⚠️ Report is 66 commits behind head on master.

Files with missing lines Patch % Lines
core/connectors/sinks/rabbitmq_sink/src/lib.rs 73.75% 112 Missing and 4 partials ⚠️
Additional details and impacted files
@@             Coverage Diff              @@
##             master    #3973      +/-   ##
============================================
+ Coverage     84.06%   84.89%   +0.82%     
- Complexity     1358     1405      +47     
============================================
  Files          1217     1225       +8     
  Lines        171902   179743    +7841     
  Branches     139658   146056    +6398     
============================================
+ Hits         144508   152585    +8077     
+ Misses        23465    23105     -360     
- Partials       3929     4053     +124     
Components Coverage Δ
Rust Core 85.76% <73.75%> (+0.80%) ⬆️
Java SDK 67.35% <ø> (+0.67%) ⬆️
C# SDK 75.38% <ø> (+0.40%) ⬆️
Python SDK 90.06% <ø> (ø)
PHP SDK 85.65% <ø> (ø)
Node SDK 96.24% <ø> (+0.34%) ⬆️
Go SDK 69.29% <ø> (+0.99%) ⬆️
Files with missing lines Coverage Δ
core/connectors/sinks/rabbitmq_sink/src/lib.rs 73.75% <73.75%> (ø)

... and 142 files with indirect coverage changes

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@amr8t

amr8t commented Aug 31, 2026

Copy link
Copy Markdown
Author

Thanks for the comments @numinnex ! Addressed, pls take a look

@numinnex

numinnex commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

/ready

@github-actions github-actions Bot added the S-waiting-on-review PR is waiting on a reviewer label Sep 3, 2026
Comment on lines +246 to +248
// Confirms resolve in publish order over already-overlapped RTTs; returns are
// matched by message_id (lapin's Return-to-confirm FIFO has no delivery tag), so a
// post-publish failure re-publishes the already-delivered tail (at-least-once in batch).

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.

The comment claims returns are "matched by message_id", but nothing matches on it. returned_id at L253 is only {:?}-formatted into the error string, and confirmed is never corrected.

lapin staples a return to whichever confirm resolves next: complete_pending pops returned_messages.get_waiting_message() from a FIFO with no delivery tag (lapin-2.5.5/src/acknowledgement.rs:115-121), and for Basic.Ack{multiple:true} — RabbitMQ's normal mode under load — complete_pending_before collects tags into a HashSet<DeliveryTag> before iterating (acknowledgement.rs:155-168, reached via channel.rs:1144-1147). So the offset reported at L254 is nondeterministic and usually not the message that was actually returned; the genuinely unroutable one resolves Ack(None) and is counted into confirmed.

This is the misattribution hazard flagged in the earlier confirm-pipelining thread, and that thread was resolved on the premise that message_id matching had been implemented. It has not been. Either parse returned.delivery.properties.message_id() back to an offset and fail only that message, or drop the claim from the comment and document the offset as unattributable.

// post-publish failure re-publishes the already-delivered tail (at-least-once in batch).
for (confirm, offset) in in_flight {
match timeout(self.timeout, confirm).await {
Ok(Ok(Confirmation::Ack(None))) => confirmed += 1,

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.

confirmed += 1 here can count a message the broker refused.

A break on any earlier arm drops the remaining PublisherConfirms, but dropping one does not remove its broadcaster from Acknowledgements::Inner::pending (acknowledgement.rs:99-111 inserts; only drop_pending/drop_all remove). When that orphaned tag's ack finally arrives, complete_pending (acknowledgement.rs:113-121) unconditionally pops get_waiting_message() and staples a later batch's Basic.Return onto the dead confirm, where register_dropped_confirm parks it unseen (returned_messages.rs:116-130). The live confirm for that later batch then resolves Ack(None) and lands on this line.

Result: an unroutable message is counted in confirmed and messages_published, consume() returns Ok(()), and the runtime has already committed the offset (AutoCommitWhen::PollingMessages, core/connectors/runtime/src/sink.rs:522) while discarding the FFI return code. Silent loss reported as a successful delivery, with no log and no metric.

The window is not narrow. delivery_mode = 2 (the default) defers the ack until RabbitMQ's batched msg-store fsync (~100-200 ms), while the shipped poll_interval = "5ms" re-enters consume() two orders of magnitude sooner, so the next batch is publishing on the same channel while the previous batch's orphaned tags are still unacked. The two preconditions are also correlated: the Ack(Some) break that creates the orphans is the same unroutable path that produces the returns.

Fixed by never reusing a channel that has unresolved confirms — see the comment on the confirm loop below.

Comment on lines +252 to +270
Ok(Ok(Confirmation::Ack(Some(returned)))) => {
let returned_id = returned.delivery.properties.message_id();
last_error = Some(Error::InvalidRecordValue(format!(
"message offset {offset} (id {returned_id:?}) returned as unroutable by RabbitMQ"
)));
last_retryable = false;
break;
}
Ok(Ok(Confirmation::Nack(_))) => {
last_error =
Some(Error::CannotStoreData("message nack'd by RabbitMQ".into()));
last_retryable = true;
break;
}
Ok(Ok(Confirmation::NotRequested)) => {
last_error = Some(Error::CannotStoreData(
"publisher confirms not enabled".into(),
));
last_retryable = false;

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.

Every break in this loop abandons the unread in_flight tail, and the two permanent arms (Ack(Some) here, NotRequested at L266) return at L296-302 without clear_state(), so the same channel is reused on the next poll.

PublisherConfirm::drop hands each abandoned confirm to register_dropped_confirm (lapin-2.5.5/src/publisher_confirm.rs:81-88 -> returned_messages.rs:116-130), which parks the promise in Inner::dropped_confirms or, if a return already resolved it, the whole BasicReturnMessage — including the full Delivery.data payload — in Inner::messages. Both are cleared only by Inner::drain(), whose sole caller is Channel::wait_for_confirms (channel.rs:334-341). This sink never calls it.

With a misconfigured routing key and mandatory: true, a batch_length = 100 batch retains ~99 entries with their payloads, every poll, for the process lifetime. Unbounded RSS growth with no diagnostic. The same orphans are what enable the counted-as-delivered case flagged at L251.

Suggest clear_state().await on every non-Ok exit from this loop (safer than wait_for_confirms(), which can itself block on a stalled broker), so a channel with unresolved confirms is never reused.

.await
.as_ref()
.map(|s| s.channel.clone())
.ok_or_else(|| Error::Connection("RabbitMQ not connected".into()))?

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 can drop a whole batch without consuming a single retry attempt.

reconnect() is a try-lock: a caller that loses the CAS at L372-378 sleeps retry_delay and returns Ok(()) having connected nothing. Control then arrives here, state is still None, and the ? propagates Error::Connection("RabbitMQ not connected") straight out of publish_batch_with_retry with attempts == 0 and publish_errors never incremented — the retry loop, the backoff and the error accounting are all bypassed.

The runtime discards the FFI return code and has already committed the offset at poll time (core/connectors/runtime/src/sink.rs:522 and :745-753), so the batch is unrecoverable and invisible.

Concurrency is real whenever a connector has 2+ (stream, topic) pairs: setup_sink_consumers builds one consumer per pair (runtime/src/sink.rs:519-546) and spawn_consume_tasks spawns one task each against the same plugin instance (:266-299). The window is widest exactly when it matters — the CAS winner can hold the flag for up to timeout_secs (30s default, and unbounded given the untimed channel RPCs noted at L395) while the loser gives up after 1s.

The loser should wait on the winner's outcome and re-read state, or return a retryable error. It should not report success for work it did not do.

props = props.with_headers(headers);
}

let confirm = match timeout(

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.

Wrapping basic_publish in timeout() does not make it abortable, so a publish that "times out" here has usually already been delivered.

before_basic_publish registers the pending confirm and burns a delivery tag before anything is sent (lapin-2.5.5/src/channel.rs:503-509), and send_method_frame_with_body pushes every frame into Frames and wakes the io_loop synchronously on first poll, only then awaiting the write promise (channel.rs:393-408 -> frames.rs:149-170). tokio's Timeout polls the inner future before checking the deadline, so that prologue always runs.

The timeout arm at L237-241 treats this as not-sent: it breaks without clear_state() and the retry republishes messages[confirmed..], so the tail is delivered twice and each cancelled future leaves another orphaned confirm on the live channel (see L252). A publish timeout is a sent-unknown outcome, not a not-sent one.

Related: timeout_secs is accepted unvalidated at L151, so timeout_secs = 0 makes every publish time out while every message still lands.

let result = async {
let conn = timeout(
self.timeout,
Connection::connect(

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 timeout does not bound the connect syscall, which is the case raised in the earlier timeouts thread ("a blackholed SYN here waits out the kernel TCP timeout").

lapin runs the connect under executor.spawn_blocking (lapin-2.5.5/src/connection.rs:300-337), and amq-protocol-tcp-7.2.3/src/lib.rs:43-48 calls a bare TcpStream::connect unless the URI carries ?connection_timeout=<ms>. Since ConnectionProperties::default() leaves the executor unset, that lands on async-global-executor's blocking pool. When the outer timeout fires it cancels the await only; the blocking closure keeps running to the kernel SYN timeout (~127s), leaking one wedged pool thread per attempt, in a pool the connectors runtime cannot see.

Minimal fix is to append ?connection_timeout={timeout_ms} to the AMQP URI (or supply a custom connector). Same applies to the open() path at L433.

Comment on lines +395 to +404
.create_channel()
.await
.map_err(|e| Error::Connection(e.to_string()))?;
channel
.confirm_select(ConfirmSelectOptions::default())
.await
.map_err(|e| Error::Connection(e.to_string()))?;
let exchange_kind = self.exchange_kind()?;
channel
.exchange_declare(

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.

create_channel, confirm_select and exchange_declare are awaited with no timeout, in both reconnect() and open() (L442-455). Only Connection::connect is wrapped.

A broker that completes the TCP handshake and answers heartbeats while stalling channel RPCs — the usual shape of a disk or memory alarm, or a half-open connection behind a load balancer — hangs here, bounded only by the negotiated heartbeat (~2x60s on RabbitMQ defaults) and unbounded if the server negotiates heartbeat=0.

This compounds the reconnect() try-lock: the CAS winner is parked inside this block with reconnecting == true and no deadline, so every concurrent consume task takes the loser path and drops its batch (see the comment at L196).

Wrapping the whole open/reconnect body in a single timeout(self.timeout, async { .. }) covers these three and the await half of the connect.

@slbotbm

slbotbm commented Sep 4, 2026

Copy link
Copy Markdown
Contributor

/author

@github-actions github-actions Bot added S-waiting-on-author PR is waiting on author response and removed S-waiting-on-review PR is waiting on a reviewer labels Sep 4, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

S-waiting-on-author PR is waiting on author response

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants