Skip to content

feat(connectors): add RabbitMQ sink - #3811

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

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

Conversation

@amr8t

@amr8t amr8t commented Aug 3, 2026

Copy link
Copy Markdown

Which issue does this PR address?

Relates to #3747

Sumary

This change adds the RabbitMQ sink connector via the lapin client. As requested, doing only sink connector in this PR. Source will be a separate one.

Adds Configurable exchange and various types (topic, direct, fanout).
Adds Configurable Retries with exponential backoff
Added Integration tests against RabbitMQ container covering topic, fanout and direct exchange behavior.

Local Execution

Completed below based on https://github.com/apache/iggy/blob/master/CONTRIBUTING.md

  • Quality Checks
  • Typos Checks
  • License Header Checks
  • Pre-commit Hooks

@github-actions

github-actions Bot commented Aug 3, 2026

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 3, 2026
@codecov

codecov Bot commented Aug 3, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 43.31551% with 106 lines in your changes missing coverage. Please review.
✅ Project coverage is 75.54%. Comparing base (2e1cbfa) to head (88abbad).
⚠️ Report is 29 commits behind head on master.

Files with missing lines Patch % Lines
core/connectors/sinks/rabbitmq_sink/src/lib.rs 43.31% 100 Missing and 6 partials ⚠️

❌ Your patch check has failed because the patch coverage (43.31%) is below the target coverage (50.00%). You can increase the patch coverage or adjust the target coverage.

Additional details and impacted files
@@             Coverage Diff              @@
##             master    #3811      +/-   ##
============================================
- Coverage     75.62%   75.54%   -0.09%     
  Complexity     1046     1046              
============================================
  Files          1337     1338       +1     
  Lines        169125   169312     +187     
  Branches     141481   141745     +264     
============================================
- Hits         127907   127906       -1     
- Misses        37435    37527      +92     
- Partials       3783     3879      +96     
Components Coverage Δ
Rust Core 75.41% <43.31%> (-0.06%) ⬇️
Java SDK 63.67% <ø> (ø)
C# SDK 71.13% <ø> (-1.14%) ⬇️
Python SDK 88.14% <ø> (ø)
PHP SDK 82.97% <ø> (ø)
Node SDK 96.36% <ø> (+0.08%) ⬆️
Go SDK 43.08% <ø> (ø)
Files with missing lines Coverage Δ
core/connectors/sinks/rabbitmq_sink/src/lib.rs 43.31% <43.31%> (ø)

... and 50 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 5, 2026

Copy link
Copy Markdown
Author

/request-review @hubcio

@github-actions
github-actions Bot requested a review from hubcio August 5, 2026 20:29
#[derive(Debug)]
pub struct RabbitMQSink {
id: u32,
amqp_url: String,

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.

(also applies to RabbitMQSinkConfig::amqp_url)

AMQP URLs normally include the username and password. This field is a String on a Serialize config type, so the sink's derived Debug output and any serialization of this type contain the URL. The runtime also exposes the raw plugin_config through its sink-plugin-config endpoint.

Store the URL as secrecy::SecretString, add iggy_common::serde_secret::serialize_secret, and use ExposeSecret only at the Connection::connect call sites, following the existing Postgres and MongoDB sinks. This prevents plugin-side logging and serialization leaks, but does not redact the runtime's raw plugin_config; that endpoint also needs a general redaction mechanism or access restriction.

}
}

async fn reconnect(&self) -> Result<(), Error> {

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.

(also applies to RabbitMQSink::open)

ExchangeDeclareOptions::default() declares a non-durable exchange. RabbitMQ rejects a declaration when an existing exchange with the same name has different durability attributes, closing the channel with PRECONDITION_FAILED. Consequently, a normal pre-created durable exchange cannot be used with this connector; the fixture masks this by declaring the same non-durable exchange.

Expose the declaration properties in the connector configuration with safe defaults, or declare an operator-managed exchange passively. Cover an existing durable exchange in the integration tests.

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 README documents only the connection and routing fields. It omits include_metadata, verbose_logging, max_retries, retry_delay_secs, and max_retry_delay_secs, even though all are public plugin configuration. Operators therefore cannot discover how to disable generated headers or control retry behavior.

Document each supported field, its type, default, and behavior in the configuration table and TOML example.

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 sink sets BasicPublishOptions::mandatory = true, but increments published for every Ok(_) publisher confirmation. RabbitMQ acknowledges an unroutable mandatory publish with Confirmation::Ack(Some(returned_message)); the message has been returned, not routed to a queue. This code therefore returns Ok(()) and lets the consumed Iggy message advance even though RabbitMQ delivered it nowhere.

Match the confirmation explicitly: only Ack(None) is success. Treat Ack(Some(_)) and Nack(_) as errors, and add an integration test with a routing key that has no matching binding.

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.

BasicProperties::default() leaves the AMQP delivery mode unset, which RabbitMQ treats as non-persistent. There is no configuration to request persistent delivery. Even if the exchange and queue are durable, RabbitMQ can discard a publisher-confirmed message on broker restart, while the sink has already reported it as successfully published.

Set persistent delivery mode by default or make it an explicit, documented configuration option. Test restart behavior with a durable exchange and queue.

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 sink constructs a new FieldTable containing only a few generated metadata values and never reads message.headers. User headers are therefore lost on every publish. A headers exchange can route on the generated iggy_* values when metadata is enabled, but it cannot route on the original user-supplied headers.

Encode representable message.headers values into AMQP headers, using ByteArray for raw binary values rather than a lossy string conversion, then add an integration test that publishes a message with a custom header and routes it through a headers exchange.

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.

ConsumedMessage::offset is u64, but the code converts it to u32 and silently substitutes u32::MAX on overflow. Long-lived topics will consequently publish an incorrect offset header for every message after the first 4,294,967,295 offsets.

lapin does not expose an AMQPValue::LongLongUInt, so encode the full u64 offset as a decimal LongString rather than narrowing it. Cover an offset above u32::MAX in a unit test.

Comment on lines +168 to +180
let confirm = channel
.basic_publish(
&self.exchange,
&self.routing_key,
lapin::options::BasicPublishOptions {
mandatory: true,
..Default::default()
},
&body,
props,
)
.await
.map_err(|e| Error::CannotStoreData(e.to_string()))?;

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.

An error from channel.basic_publish(...).await is converted with ? and returned immediately. It never sets last_error, reconnects, or uses the configured retry delay. Only errors while awaiting a publisher confirmation reach the retry path.

Route immediate publish errors through the same retry flow as confirmation errors. Preserve the index of the first unconfirmed message when retrying so this fix does not republish earlier confirmed messages.

Comment on lines +143 to +147
let mut published: u64 = 0;
for message in messages {
let body = message.payload.clone().try_into_vec()?;
let mut props = BasicProperties::default();
if self.include_metadata {

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.

When a later message fails, published records how many earlier messages were confirmed, but the next loop iterates over the whole messages slice again. A transient error on the final message of a 100-message batch therefore republishes the first 99 confirmed messages. This duplication is introduced by the sink’s own retry loop, independent of any runtime retry behavior.

Resume at the first unconfirmed message after reconnecting, and document the remaining at-least-once case where connection loss makes the final publish outcome unknowable. Add a test that forces a failure after at least one confirmation.

let mut last_error: Option<Error> = None;
let mut published: u64 = 0;
for message in messages {
let body = message.payload.clone().try_into_vec()?;

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.

consume only has a borrowed message, but this clones the complete Payload before converting it into bytes. For Payload::Json, that needlessly deep-clones the simd_json::OwnedValue tree before serializing it, adding allocation and CPU cost to every published JSON message.

Use message.payload.try_to_bytes() to serialize JSON directly from the borrowed payload.

@slbotbm

slbotbm commented Aug 12, 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 Aug 12, 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.

2 participants