Skip to content

feat(connectors): add RabbitMQ sink - #3973

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

feat(connectors): add RabbitMQ sink#3973
amr8t wants to merge 4 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
}
}

fn is_publish_retryable(error: &Error) -> bool {

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.

These substrings don't match what lapin actually emits. A dropped channel renders as invalid channel state: Closed (not channel closed), heartbeat loss as no heartbeat received from server for too long, AMQP uses RESOURCE_LOCKED with an underscore, and io::ErrorKind::TimedOut prints timed out, not timeout.

So a channel-level exception is classified permanent and returns at L239 before reconnect() is ever called. Since state is only written on success (L350), the dead Channel is then re-read by every subsequent batch and the sink never self-heals.

Suggest matching on lapin::Error variants instead, and clearing state on any publish failure — the classifier fix alone still wedges on an error the list doesn't cover.

};
match confirm.await {
Ok(Confirmation::Ack(None)) => confirmed += 1,
Ok(Confirmation::Ack(Some(_))) | Ok(Confirmation::Nack(_)) => {

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.

Nack probably shouldn't share this arm with Ack(Some(_)). Ack(Some(_)) is the mandatory-return (unroutable) case, whereas a Nack means the broker refused responsibility (internal error, disk alarm) — the one confirm outcome RabbitMQ documents as safe to re-publish.

As written it becomes InvalidRecordValue, which is_publish_retryable rejects, so the batch fails permanently. Splitting the arms with Nack retryable would also fix the message, which currently misreports the cause.

break;
}
};
match confirm.await {

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.

Awaiting the confirm inside the per-message loop serializes a broker round-trip per message, so throughput is capped at 1/RTT regardless of payload or batch size (~2k msg/s on a LAN, well under that cross-AZ). With batch_length = 100 here and 1000 as the runtime default, that's 100–1000 sequential RTTs per batch, and it also stops lapin from coalescing frames.

Worth flagging that the obvious fix isn't safe as-is: lapin pairs Basic.Return to a confirm FIFO with no delivery tag, so with several publishes in flight an unroutable message can be attributed to the wrong one. Pipelining would need mandatory off on that path, or a per-message message_id to correlate on.


warn!("Reconnecting RabbitMQ sink ID: {}", self.id);
let result = async {
let conn = 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.

No timeout on Connection::connect here, nor on basic_publish / confirm.await in the publish loop. RabbitMQ blocks publishers on a disk or memory alarm while still answering heartbeats, so the confirm can hang indefinitely, and a blackholed SYN here waits out the kernel TCP timeout while holding the reconnecting CAS.

Since the FFI entry point is block_on, that parks a connectors-runtime worker thread. surrealdb_sink and clickhouse_sink both expose a timeout config for this.


User headers on consumed Iggy messages are forwarded as AMQP headers: string values become AMQP `LongString`, raw binary values become `ByteArray`. This allows routing through a `headers` exchange on original user headers.

Publishes are confirmed via `ConfirmSelect`. With `mandatory = true`, a message with a routing key that matches no binding is returned by RabbitMQ and the batch fails with a permanent error (delivery is at-least-once: if the connection drops mid-batch, the sink resumes from the first unconfirmed message, so a broker-side outcome may be unknowable and could be delivered more than once).

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 at-least-once claim doesn't hold as shipped. The runtime commits offsets at poll time (AutoCommitWhen::PollingMessages) and discards the plugin's FFI return code, so a permanently-failed batch is dropped and still counted as processed.

The real guarantee is at-most-once across batches, with at-least-once only within one. doris_sink/README.md has the house wording for this.

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