Introduction
crossing streams
mq-bridge-app is a fast, single-command ETL and data-movement tool built in Rust — and,
on top of the same engine, a multi-protocol bridge and traffic workbench for messaging.
It ships in three forms that share one engine and one config format: a desktop app
(visual workbench), a CLI / server (headless bridge and one-line copy), and a
library (embed the engine in Rust, Python, or Node.js). Design a route once, run it
anywhere — no rewrite in between.
Supported integrations include Kafka, RabbitMQ (AMQP), NATS, AWS SQS, MQTT, IBM MQ (optional), HTTP, gRPC, ZeroMQ, MongoDB, Redis Streams, ClickHouse, Postgres CDC, sqlx (MySQL, MariaDB, PostgreSQL, SQLite), cloud object storage, and filesystem endpoints.
A quick taste
At its core is a zero-config copy command that moves data between databases, queues, and
files in a single line of bash — no YAML, no pipeline definition, no code:
mqb copy \
--from 'postgres://user:pass@localhost/db?table=src&sslmode=disable' \
--to 'file://out.jsonl?format=raw' \
--drain
The scheme selects the endpoint and query parameters configure it, so any source→sink pair is just one URL each. And it’s quick: in benchmarks a 1,000,000-row Postgres → JSONL job sustained 338,066 rows/s at ~40 MiB peak RSS — see Performance tuning.
Philosophy
The project has one main bias: move data reliably without forcing the rest of the application to care too much about the transport. Kafka offsets, RabbitMQ nacks, HTTP responses, MongoDB polling, WebSocket frames, and file rows are all different in real life, but route code should still be able to receive a batch, process it, publish it, and commit it.
- Fast by default. Every endpoint is optimized around batch-shaped APIs, and the headless
surfaces ship tuned for throughput: the
copyCLI and the MCP server default tobatch_size: 1024andconcurrency: 4. The library/config primitive defaults tobatch_size: 512,concurrency: 1: batches fill opportunistically — a route takes whatever is already queued rather than waiting — so throughput comes for free while parallelism stays a deliberate per-route choice. - Reliability is built in, not bolted on. Retries, dead-letter queues, deduplication, rate limiting, and cookie/session persistence wrap any endpoint. Ack/nack behaviour and retry/DLQ handling were designed to work with batching, including commit sequencing for cumulative-ack brokers.
- Not a framework. It is not a domain framework, an actor runtime, or a full stream processor. It cares about transport, routing, and delivery behaviour, not about prescribing your domain model.
Where to go next
- New here? Start with Installation and the Quick start.
- Want the end-to-end walkthroughs? See the Tutorials.
- Looking for a specific task? The Cookbook has short recipes.
- Need exact fields and defaults? The Reference is authoritative.
- Running it in production? See Operations, especially the Performance tuning page.
- Driving it from an AI agent? The same binary is an MCP server — the rows move without entering the model’s context.
App vs. engine
mq-bridge is the engine/library; mq-bridge-app is the application — desktop app +
CLI/server + library distribution — built on that engine. This book is the user-facing home;
the engine’s deep API reference lives on docs.rs. The
library bindings let you embed the same engine in Rust, Python, or
Node.js.
Install
mq-bridge-app ships in three forms that share one engine and one config format:
the CLI / server, the desktop app (UI), and the library. Pick the
install path for the form you need. To compile any of them yourself, see
BUILD.md.
- CLI / server — Homebrew,
cargo binstall,cargo install, Docker - Desktop app (UI) — Homebrew cask, or a prebuilt bundle
- Library — Rust, Python, Node.js
CLI / server
The CLI is a single headless binary, installed under two names: mq-bridge-app
and the short mqb, which is what the docs use and what you’ll normally
type. mqb is a small launcher that hands straight over to the real binary, so
the two are interchangeable — existing scripts and MCP registrations keep
working untouched.
Homebrew (macOS, Linux) — recommended
brew install marcomq/tap/mq-bridge-app
brew upgrade mq-bridge-app # later, to update
Prebuilt bottles cover Apple Silicon macOS and x86_64 Linux. Homebrew
refreshes the tap only on brew update, so if brew upgrade doesn’t pick up a
new release yet, run brew update first.
cargo binstall — prebuilt binary
Downloads the prebuilt CLI from Releases instead of compiling from source, so it installs in seconds:
cargo binstall mq-bridge-app
Prebuilt binaries are available for x86_64 Linux, Apple Silicon macOS, and
x86_64 Windows. (cargo-binstall
is a drop-in cargo install replacement.)
cargo install — from source
Requires a Rust toolchain and compiles all supported endpoint client libraries (except IBM MQ), so it may take a while:
cargo install mq-bridge-app
For IBM MQ, install the client library first and build with --features=ibm-mq.
Docker
The CLI is published as a multi-arch image (amd64 + arm64):
docker run --rm --name mq-bridge -p 9091:9091 ghcr.io/marcomq/mq-bridge-app:latest
To read+tail from input.log and forward its content, mount the working directory at
/app and seed the config from one of the templates baked into the image at /config:
touch input.log
docker run --rm --name mq-bridge -p 9091:9091 -v "$(pwd)":/app \
ghcr.io/marcomq/mq-bridge-app:latest --ui --init-config=/config/file-to-http.yml
Note
The default
latestimage is a plain multi-arch image foramd64andarm64. IBM MQ support is published separately as thelatest-ibm-mqandibm-mqtags onamd64only, since there is no redistributable IBM MQ client library for arm64 yet. Start that image in emulation mode with--platform=linux/amd64, or buildmq-bridge-appyourself withcargo build --release --features=ibm-mq.
Desktop app (UI)
The desktop app is a Tauri bundle of the full messaging workbench — the same UI the CLI serves in a browser, only packaged as a native app.
Homebrew cask (macOS, Apple Silicon)
brew install --cask marcomq/tap/mq-bridge
Homebrew quarantines cask apps by default and the desktop binaries are not notarized yet, so macOS blocks the app on first launch. See the note below for how to open it (via System Settings or Terminal).
Prebuilt bundles (macOS, Windows, Linux)
Bundles for every platform are attached to each release on the GitHub Releases page.
- macOS — download the
.dmg/.appbundle. - Windows — download the installer or standalone executable.
- Linux — download the bundle that suits your distribution: AppImage,
.deb,.rpm, or the unpacked archive.
Note
The desktop binaries are currently not notarized, so on macOS Gatekeeper blocks the app on first launch — it’s reported as “damaged” or “cannot be opened because the developer cannot be verified”. This applies to both the Homebrew cask and a downloaded bundle. There are two ways to open it:
Option 1 — System Settings (no Terminal). Try to open the app once so the block is triggered, then go to System Settings → Privacy & Security, scroll to the Security section, and click “Open Anyway” next to the mq-bridge message. Authenticate, then launch the app again.
Option 2 — Terminal. Remove the quarantine attribute directly:
# app in /Applications (where the cask and the .dmg install it) sudo xattr -rd com.apple.quarantine /Applications/mq-bridge.app # app in a user-owned directory (e.g. ~/Downloads) — no sudo needed xattr -rd com.apple.quarantine ~/Downloads/mq-bridge.appIf macOS says the app is “damaged”, the “Open Anyway” button may not appear — use the Terminal method in that case.
Library
Embed the core engine in your own code — produce or consume messages with a unified API, one config format across all three bindings:
- Rust —
mq-bridge(cargo add mq-bridge) - Python —
pip install mq-bridge-py - Node.js —
npm install mq-bridge
The core of the library are the MessageConsumer and MessagePublisher traits,
found in mq_bridge::traits.
Build from source
Building the CLI/server, the desktop (Tauri) app, or the Docker image from source is covered in BUILD.md. For IBM MQ specifically, see the IBM MQ Setup Guide.
Quick Start
mqb copy SOURCE TARGET moves data between two endpoints described as
URIs. The scheme picks the connector; ?query=params configure it. The existing
--from SOURCE --to TARGET form is equivalent and remains supported.
mqb copy \
'postgres://localhost/app?table=users' \
'clickhouse://localhost:8123?table=users&database=analytics'
This copies every row currently in app.users (PostgreSQL) into
analytics.users (ClickHouse). No config file, no UI — just a source and a
destination. Add --drain to exit once the source is empty instead of
running as a continuous bridge (see
Continuous vs. one-shot below).
The common copy controls are deliberately small:
mqb copy SOURCE TARGET [--filter EXPR] [--resume] [--drain]
--filter evaluates a readable expression against each top-level JSON payload,
for example amount > 100 or status == "paid". A false result intentionally
drops and acknowledges the message; malformed JSON and invalid expressions are
errors, while a field that is absent or not a scalar counts as no match and is
warned about once. It is an in-process filter and is not translated into a
database query. Filtering into cloud object storage also changes how the objects
are named — see Filtering.
--resume asks the source to use its native durable position and fails before
the route starts when that is not safe. The generated state identity includes
the credential-redacted source, destination, and filter, so changing pipeline
semantics starts a new checkpoint while rotating a password does not.
The examples below are complete, working commands. Each links to the full connector page for that endpoint, which lists every available option; the generated URL reference is the authoritative source for every parameter’s type, default, and description.
PostgreSQL → ClickHouse
mqb copy --drain \
--from 'postgres://user:pass@localhost/app?table=orders' \
--to 'clickhouse://localhost:8123?table=orders&database=analytics'
Reads all rows from the orders table and bulk-inserts them into ClickHouse’s
HTTP interface. For a resumable non-destructive scan, add
&cursor_column=id on --from and pass --resume; the CLI supplies the stable
cursor id and the SQL source stores the checkpoint in its own database. An
explicit cursor_id or checkpoint_store in the URI still takes precedence. See
PostgreSQL and ClickHouse.
Filtered, resumable Kafka copy
mqb copy \
'kafka://localhost:9092?topic=orders' \
'postgres://localhost/app?table=orders' \
--filter 'status == "paid"' \
--resume
The generated Kafka consumer group is stable for this source, destination, and filter. Kafka offsets advance only after the destination succeeds, or after a message is intentionally filtered out.
PostgreSQL CDC → PostgreSQL
mqb copy \
--from 'postgres-cdc://user:pass@localhost/app?publication=mqb_pub&slot_name=mqb_slot' \
--to 'postgres://user:pass@otherhost/replica?table=orders&auto_create_table=true'
Streams inserts/updates/deletes from a PostgreSQL logical-replication publication into another PostgreSQL table, continuously (CDC is a change stream, so this command doesn’t drain — run it as a long-lived process). See PostgreSQL CDC.
MQTT → Kafka
mqb copy \
--from mqtt://broker.local:1883?topic=sensors/+/temperature \
--to kafka://kafka.local:9092?topic=sensor-readings
Subscribes to an MQTT topic (wildcards supported) and republishes every message to a Kafka topic, continuously. See MQTT and Kafka.
RabbitMQ → HTTP
mqb copy \
--from rabbitmq://guest:guest@localhost:5672/%2f?queue=orders \
--to http://internal-api.local/ingest?method=POST
Consumes messages from a RabbitMQ queue and POSTs each one to an HTTP endpoint, continuously. See RabbitMQ and HTTP.
File (CSV) → MongoDB
mqb copy --drain \
--from file:///data/customers.csv?format=csv \
--to 'mongodb://localhost?database=app&collection=customers'
Reads a CSV file (first row = header) and inserts one document per row into a MongoDB collection, then exits since the source is a finite file. See File and MongoDB.
Continuous vs. one-shot
Without --drain, copy runs as a continuous bridge until Ctrl-C — the
right mode for message brokers (MQTT, Kafka, RabbitMQ) and CDC sources, which
never “end”. With --drain, copy exits once the source yields an empty
batch — the right mode for finite sources (a file, or a full-table read from
a database). --concurrency and --batch-size tune throughput on both
modes.
Escape hatch: driver options and full connection strings
Any query parameter that isn’t a recognised config field (e.g. sslmode,
replicaSet) is left on the connection URL untouched, so driver-specific
options just work — including object-typed fields like tls, which can
never be set from a single scalar query param and so always stays on the URL
(e.g. mongodb://host/?tls=true&database=appdb passes tls=true straight
through to the MongoDB driver). If you already have a complete connection
string (copied from elsewhere, or one whose own options would otherwise be
mis-parsed as config), skip decomposition entirely and pass it verbatim with
?url=<url-encoded string>:
mqb copy \
--from 'mongodb://_/?url=mongodb%3A%2F%2Fuser%3Apass%40host%2Fdb%3Ftls%3Dtrue&collection=orders' \
--to null:
See the generated reference for each connector’s recognised field names.
The three ways to run it
mq-bridge-app is one engine with one config format, exposed three ways. Build and test a
route in the UI, export the JSON/YAML, then run that config in a config-mode service or from
library code. The copy CLI takes the same settings, but expressed as endpoint URIs and flags
rather than a config file.
| Form | What it is | Quick install |
|---|---|---|
| Desktop app (UI) | The visual workbench — build/test routes, run request/response traffic, inspect message history | brew install --cask marcomq/tap/mq-bridge |
| CLI / server | Headless binary: a one-line copy, a drain-then-exit batch job, or a long-lived bridge (also serves the same UI in a browser) | brew install marcomq/tap/mq-bridge-app |
| Library | The engine embedded in your own code — native Rust, Python, or Node.js bindings | cargo add / pip / npm |
See Installation for every install method and platform.
Desktop app (UI)
The desktop app is a Tauri bundle of the full messaging workbench: manage publishers/consumers/routes, run request/response traffic (like Postman for REST), inspect message history, and import Postman/OpenAPI/AsyncAPI definitions. It is the same UI the CLI serves in a browser — only the packaging differs.
The UI is generated dynamically from the Rust configuration structures: the backend uses
schemars to produce a JSON Schema for the AppConfig struct (exposed at /schema.json, also
mqb --schema <path>), and the frontend renders a complete config form from that schema. So
when a new middleware or option is added to the engine, the schema updates automatically and
the UI reflects it with no frontend change.
CLI / server
The CLI (mqb) is a headless binary that runs in three modes. They share the same
engine and config format but differ in how you drive them — and only config mode serves the
browser UI:
| Mode | Invocation | Serves web UI? | Best for |
|---|---|---|---|
| Config mode (default) | mqb [--config x.yml] | Only when asked — via ui_addr in the config, --ui, or a y at the prompt | Long-lived bridge from a config file; Container/Kubernetes deployments |
copy | mqb copy --from … --to … | No (headless) | Ad-hoc one-route job from two endpoint URIs, no config file; add --drain to exit once the source is empty |
mcp | mqb mcp | No (headless) | Expose the bridge as MCP tools so an LLM agent can publish and route from natural language |
# Config mode: run a long-lived bridge from a file
mqb --config config.yml
# Seed config.yml from a template on first run only
mqb --config config.yml --init-config dev/config/file-to-http.yml
# Start empty, then open the UI to build your config
mqb --ui
In config mode the CLI can also serve the browser UI (the same UI as the desktop app) on the
configured port. It is never opened implicitly: set ui_addr in the config, pass --ui, or
answer the prompt — an unattended run without --ui stays headless. See the
CLI reference for the full rule and every flag, and
Configuration grammar for the config format.
What a config file has to contain
Everything a config file defines is started at boot, in both headless and UI runs. A route
can be held back with enabled: false; the UI starts it on demand.
The full form is a routes: map alongside the application settings, which is what the UI writes
out. Routes may also be written at the top level, so a file that is nothing but a route needs
neither the wrapper nor a name — these three are the same bridge:
# 1. the full form
routes:
proxy:
input: { http: { url: "0.0.0.0:8443" } }
output: { http: { url: "https://upstream.internal/" } }
# 2. named at the top level, as in the Configuration grammar
proxy:
input: { http: { url: "0.0.0.0:8443" } }
output: { http: { url: "https://upstream.internal/" } }
# 3. a single unnamed route — it runs as `route`
input: { http: { url: "0.0.0.0:8443" } }
output: { http: { url: "https://upstream.internal/" } }
Application settings (ui_addr, metrics_addr, log_level, plugins, …) stay at the top
level in every form. In form 3 every other top-level key belongs to the route, so route options
such as batch_size and exit_on_empty go there — and a misspelled one is reported by name
rather than ignored.
The configuration-first workflow
The point of one shared config format is that you can test connections and dial in a route in the UI, export the JSON/YAML, then run that exact config unchanged — as a config-mode service or loaded from library code. A known-good route shape from the UI drops straight into production.
copy is the exception: it takes no config file, so a route you built in the UI has to be
mapped by hand onto --from / --to endpoint URIs and route flags. The settings are the same,
only the way you pass them differs.
Library
Beyond running standalone, the core engine is available as a library so you can produce or consume messages with a unified API — no broker-specific SDK, one config format across all three bindings:
- Rust —
mq-bridge(cargo add mq-bridge) - Python —
pip install mq-bridge-py - Node.js —
npm install mq-bridge
The core of the library are the MessageConsumer and MessagePublisher traits in
mq_bridge::traits. See the Language bindings API and the
Embed the library tutorial.
How the UI differs from API clients
The UI overlaps with API clients like Postman, Bruno, and Insomnia, but its centre of gravity
is different: it is designed around message bridging, runtime operation, and long-lived route
management rather than just request composition. It adds broker pub/sub workflows, long-lived
consumers/routes, bridging traffic between protocols, hex-level payload debugging, replay,
local-first git-friendly config, and encrypted config — while leaving scripting and complex
request workflows to the dedicated API clients. Use an API client when your main job is
crafting and sharing API requests; use mq-bridge-app when you need to connect systems, move
messages between protocols, inspect live traffic, and manage bridge-style runtime
configuration.
The desktop / web UI
mq-bridge-app ships a visual workbench for building, testing, and running message routes.
It is the same UI whether you launch the desktop app (a Tauri
bundle) or let the CLI serve it in a browser — only the packaging
differs. Think of it as Postman for message bridging: build and test a route, export the
JSON/YAML, then run that exact config unchanged wherever you deploy.
For how the UI is built (schema-driven forms, the engine acting as its own web server), see How the app is built.
Layout
The window is split into three top-level tabs, a sidebar listing your endpoints, and a top bar:
- Publishers (↑) — endpoints you send to (build/test requests, run request/response traffic).
- Consumers (↓) — endpoints you receive from (live message log, payload inspection).
- App Config (⚙) — application-wide settings, config security, and environment variables.
The top bar shows a runtime status indicator (idle / active consumer(s)), a theme
switcher (light / auto / dark), and Save. The sidebar filters endpoints and offers one-click
import from Postman, OpenAPI, AsyncAPI, and existing mq-bridge configs.
Publishers
Select or add a publisher on the left, then edit it on the right. The header row holds the endpoint type (HTTP, Kafka, NATS, …), method, and URL, with a Send button to fire a request. Below it are per-endpoint tabs:
- Definition — name, endpoint type and connection settings (with Show advanced options), and a Middlewares list you can add to / remove from (metrics, retries, transforms, …).
- Body / Headers — the outgoing payload (with
AUTO/TEXT/JSON/XML/HEXviews) and header rows. - History / Presets — previous sends and saved request presets.
After a Send, the response panel shows status, timing, request/response headers, and the
response body (including a HEX view), with copy / json / curl shortcuts.
Copy to Consumer, Clone, {} (view raw JSON), and Delete act on the selected endpoint.
Consumers
Consumers receive messages live. The header shows the connection state (Connected), a
Capture messages toggle, a Keep last N limit, and Clear / Stop controls. Tabs:
- Definition — connection settings, same shape as a publisher.
- Output — the consumer’s response/output configuration.
- Messages — a live log (time + payload preview). Click a row to inspect it below:
Message Headers, Message Body, and any Response Headers / Body, each with
AUTO/TEXT/JSON/XML/HEXviews plus copy/export.
App Config
Application-wide settings rendered directly from the AppConfig schema:
- AppConfig — default tab, log level, logger, metrics address, and UI address.
- Config Security — the storage mode (
unencrypted,balanced,sensitive,durable, …) controlling how secrets and cached message history are stored. See Encryption at rest and Secrets. - Environment Variables — key/value pairs available for interpolation in endpoint URLs and settings.
Export, Import, Reset, and {?} JSON operate on the whole configuration.
The configuration-first workflow
The point of one shared config format is that you test connections and dial in a route in the
UI, export the JSON/YAML, then run that exact config unchanged — as a
copy command, a config-mode service, or loaded from
library code. A known-good route shape from the UI drops straight
into production.
How the UI differs from API clients
The UI overlaps with API clients like Postman, Bruno, and Insomnia, but its centre of gravity is different: it is built around message bridging, runtime operation, and long-lived route management rather than one-off request composition. It adds broker pub/sub workflows, long-lived consumers/routes, protocol-to-protocol bridging, hex-level payload debugging, replay, local-first git-friendly config, and encrypted config — while leaving scripting and complex request chaining to the dedicated API clients.
Status. The UI/Tauri layer was prototyped quickly and does not mirror the
mq-bridge/ core/CLI standards — treat it as a working demo, not a reference implementation, and test before relying on it in production.
MCP server
data movement as a tool call
mqb mcp runs the bridge as a Model Context Protocol
server, so an agent can move data between any two supported endpoints — 15+
connectors covering databases, queues, brokers, HTTP, and files — from natural
language.
Nothing is preconfigured. publish and start_route take their endpoints inline
as JSON keyed by connector type, so the model picks both ends ad hoc; there is no
YAML to write first and no per-connector tool to install. list_routes,
route_status, route_messages, and stop_route manage what is already running.
The rows never enter the model’s context. The agent describes the job; the engine moves the bytes. Moving a 116.3 MiB dataset costs three tool calls and ~381 tokens — the same ~381 tokens whether the job is 1,000 rows or 1,000,000.
| Registry name | io.github.marcomq/mq-bridge-app | |
|---|---|---|
| Transports | stdio, streamable HTTP | |
| Tools | 8 | see below |
| Connectors | 15+ | see below |
| Install | mqb mcp install | Docker / cargo / Homebrew / binaries — Installation |
Quick start
# 1. get the binary (any install method from the Installation page)
brew install marcomq/tap/mq-bridge-app
# 2. register it with every MCP client detected on this machine
mqb mcp install
# 3. restart the client fully — reopening a tab is not enough
Then ask the agent for the job in words:
“Drain the
eventsRedis stream into theeventstable in Postgres, creating the table if needed.”
Running the server
# stdio — for local clients (Claude Code, Claude Desktop, Cursor)
mqb mcp
# streamable HTTP — for remote/shared clients
mqb mcp --transport http --bind 127.0.0.1:9092
| Flag | Default | Meaning |
|---|---|---|
--transport | stdio | stdio for local clients, http for streamable HTTP (served over hyper) |
--bind | 127.0.0.1:9092 | Listen address; --transport http only |
--report-to-ui | off | Also report route/publisher activity to a running desktop or web UI |
No web UI is started in this mode. Logs go to stderr, because stdio
transport owns stdout for the protocol itself.
Tools
| Tool | Key arguments | Purpose |
|---|---|---|
publish | publisher, message | messages, name | Send one message or a batch to any endpoint. Independent of routes. |
start_route | route (input/output), name, batch_size, concurrency, capture_last | Run a route moving messages from source to sink. Returns the route name. |
list_routes | — | Every route started by this server, with live connection health and rates. |
route_status | name (optional) | Health, totals and rates for one route, or all of them. |
wait_route | name, timeout_ms | Block until a route finishes, then report how it ended. One call instead of a polling loop. |
route_messages | name | The most recent messages captured on a route. Requires capture_last; reads drain the buffer. |
stop_route | name | Stop a route; returns total messages and the rate it achieved. |
server_info | — | Crate version, git hash, build profile and build time. |
Call server_info before quoting any throughput number: a debug binary reports
much slower rates, and the figure would be meaningless.
start_route and route_messages are annotated not read-only — starting a
route moves real data, and reading captured messages consumes the buffer — so a
client should not auto-approve them the way it may auto-approve a true reader.
Endpoints
Every endpoint is a single-key JSON object naming the connector:
{"kafka": {"url": "localhost:9092", "topic": "orders"}}
{"nats": {"url": "nats://localhost:4222", "stream": "ORDERS", "subject": "ORDERS.new"}}
{"sqlx": {"url": "postgres://user:pass@localhost:5432/db", "table": "events"}}
{"file": {"path": "/tmp/out.jsonl", "format": "json"}}
{"null": null}
The key is the connector; the object is its configuration:
| JSON key | Connector | Details |
|---|---|---|
sqlx | PostgreSQL / MySQL / MariaDB / SQLite | connectors · parameters |
postgres_cdc | PostgreSQL logical replication (CDC) | connectors · parameters |
clickhouse | ClickHouse | connectors · parameters |
mongodb | MongoDB | connectors · parameters |
kafka | Apache Kafka | connectors · parameters |
amqp | RabbitMQ (AMQP) | connectors · parameters |
nats | NATS / JetStream | connectors · parameters |
mqtt | MQTT | connectors · parameters |
redis_streams | Redis Streams (alias redis) | connectors · parameters |
aws | AWS SQS / SNS | connectors · parameters |
ibmmq | IBM MQ (optional feature) | connectors · parameters |
zeromq | ZeroMQ | connectors · parameters |
http | HTTP client & server | connectors · parameters |
websocket | WebSocket | connectors · parameters |
grpc | gRPC | connectors · parameters |
file | Files — CSV / JSON / JSONL / raw | connectors · parameters |
object_store | Cloud object storage (alias s3) | parameters |
sled | Embedded local store | endpoints |
memory | In-process topic | endpoints |
null | Discards everything — {"null": null} as an output drains a source | endpoints |
Structural endpoints (fanout, switch, stream_buffer, request, reader,
response, static, ref) are accepted here too and are documented under
Middleware & structural endpoints.
A file/object_store source must repeat the compression/encryption its
data was written with — neither is auto-detected, and a mismatch ends the route as
completed having moved few or no messages rather than failing. Check the
moved-message count.
Messages
A message is a payload plus optional string metadata headers and an optional
message_id (a UUIDv7 is generated when omitted). A string payload is sent
verbatim; any other JSON value is serialized to JSON bytes.
{
"payload": {"id": 1, "sku": "A-100", "qty": 3},
"metadata": {"kind": "order", "correlation_id": "abc-123"}
}
Conventional metadata keys: kind (message type, drives type-based routing),
correlation_id and reply_to (request/reply). Keys starting with mqb.src.
are reserved for provenance and are stripped on input.
Route options
Alongside input and output, a route accepts batch_size, concurrency,
exit_on_empty (drain the source, then exit) and capture_last. Without
exit_on_empty a route polls indefinitely until stop_route.
batch_size and concurrency may be written inside route or as top-level
start_route arguments. They resolve most-specific-first:
- the top-level argument, if given;
- otherwise the same key inside
route, if given; - otherwise the app default — 1024 and 4, rather than the library’s
512/1.
Only a value you never wrote is replaced, so an explicit 1 is honoured either
way. start_route echoes the resolved batch_size/concurrency in its result.
capture_last: N keeps the last N messages that flow through the route for
route_messages to return. It is off by default: captured payloads are held in
memory and handed to the model verbatim, which is exactly what “the rows never
enter the context” otherwise avoids. Use it to sample a stream, not to move it.
Middleware
Either endpoint may carry a middlewares array, so delivery behaviour is added
without changing the route:
{
"input": {"kafka": {"url": "localhost:9092", "topic": "orders", "group_id": "agent"}},
"output": {
"sqlx": {"url": "postgres://user:pass@localhost:5432/db", "table": "orders"},
"middlewares": [
{"retry": {"max_attempts": 5}},
{"dlq": {"endpoint": {"file": {"path": "/tmp/failed.jsonl"}}}}
]
}
}
Available: retry, dlq, deduplication, limiter, transform, compression,
encryption, buffer, delay, weak_join, cookie_jar, metrics. Each is
documented with its options in the Cookbook and
Middleware reference.
Waiting for a job to finish
For a job started with exit_on_empty, call wait_route rather than polling
route_status in a loop:
{"name": "redis-to-postgres", "timeout_ms": 300000}
It returns once the route’s task ends, reporting finished, outcome,
messages, elapsed_s and average_messages_per_second. On timeout it returns
finished: false and leaves the route running, so the agent can wait again
or stop it. The default timeout is 60 s and the ceiling is 5 h; the server polls
internally every 50 ms, which the client never pays for.
This is what keeps agent token cost flat: one call covers a job of any duration, where a polling loop costs a call per tick.
Route status
route_status (and list_routes) report two rates, and they answer different
questions:
| Field | Meaning |
|---|---|
messages | Total messages the route has moved. |
messages_per_second | Instantaneous rate, smoothed over ~0.5 s. Decays to ~0 within a second of a route going idle. |
elapsed_s | The span over which those messages moved: route start → last message seen. Stops growing once the route goes idle. |
average_messages_per_second | messages / elapsed_s — the rate the route actually achieved. |
For a route that is running now, read messages_per_second. For one that has
finished — anything started with exit_on_empty — read
average_messages_per_second: the instantaneous rate of a completed job is ~0 by
the time any status call observes it, which says nothing about how fast it was.
stop_route returns the same two fields as its parting summary.
elapsed_s and average_messages_per_second are null until the route has been
sampled at least once (the sampler runs every 200 ms), so a job that finishes
inside one tick reports no average.
Examples
Publish a batch to NATS JetStream:
{
"publisher": {"nats": {"url": "nats://localhost:4222", "stream": "ORDERS", "subject": "ORDERS.new"}},
"messages": [
{"payload": {"id": 1, "sku": "A-100"}, "metadata": {"kind": "order"}},
{"payload": {"id": 2, "sku": "B-200"}, "metadata": {"kind": "order"}}
]
}
Drain a Redis stream into Postgres, then exit:
{
"name": "redis-to-postgres",
"route": {
"input": {"redis_streams": {"url": "redis://localhost:6379", "stream": "events",
"group": "g1", "read_from_start": true}},
"output": {"sqlx": {"url": "postgres://user:pass@localhost:5432/db",
"table": "events", "auto_create_table": true}},
"exit_on_empty": true
}
}
Tail a live Kafka topic and keep the last 20 messages for inspection:
{
"name": "orders-tail",
"route": {
"input": {"kafka": {"url": "localhost:9092", "topic": "orders", "group_id": "agent"}},
"output": {"null": null}
},
"capture_last": 20
}
Performance
The interface costs one round-trip, not a per-row tax: a route started through
start_route moves data at the rate the copy CLI does, within run-to-run
variance, and what separates them is a fixed ~30-55 ms of startup and completion
polling. In the latest run the MCP path measured marginally above the CLI on the
same dataset — that is variance on a sub-second job, not the tool call being
faster than the command.
| Measurement | Result |
|---|---|
| Tool-call round-trip latency (200 calls) | p50 0.060 ms · p95 0.080 ms · p99 0.583 ms |
1M-row CSV → JSONL via start_route (client wall-clock, 2 runs) | 1,176,489 rows/s (0.850 s ±0.023) |
Same job, server’s own average_messages_per_second | 1,191,579 rows/s |
copy CLI baseline, same dataset (§6 untyped) | 1,133,786 rows/s (2-run median 0.882 s, ±0.001) |
| Agent tool traffic to move the whole dataset | 1,526 bytes (~381 tokens, 3 calls) |
| The same 116.3 MiB through a model’s context | ~30.5M tokens |
Agent token cost is flat in the number of rows moved — three tool calls
(start_route, one route_status, stop_route) totalling ~1.5 KB of JSON-RPC
move 1M rows or 1,000 alike. Passing the same 116.3 MiB through a context window
instead would cost ~30.5M tokens, which no context window holds at any price.
Methodology and the client used to measure it — a real MCP client over stdio, not
an in-process harness — are in
benches/etl/README.md,
scenario 7. Engine-level tuning is on the
Performance tuning page.
Registering with a client
mcp install writes the client config for you, registering the absolute path
of the binary you just ran — so a target/debug build and an installed
release binary each register themselves correctly.
# every client detected on this machine
mqb mcp install
# a single client, project-scoped rather than global
mqb mcp install --client cursor --local
# bake --report-to-ui into the registered command
mqb mcp install --report-to-ui
| Command | Purpose |
|---|---|
mcp install | Register this binary. --client, --local, --report-to-ui, --print-config. |
mcp uninstall | Remove the registration. --client, --local. |
mcp status | Show where it is registered and whether the path is still current. |
| Client | Global config | Project config (--local) |
|---|---|---|
claude (Claude Code) | claude mcp add --scope user, else ~/.claude.json | --scope project, else ./.mcp.json |
claude-desktop | claude_desktop_config.json | not supported |
cursor | ~/.cursor/mcp.json | ./.cursor/mcp.json |
Where the client ships its own CLI (Claude Code) that CLI is driven, since it stays correct across config-schema changes; otherwise the entry is merged into the client’s JSON, leaving every other registered server untouched. Installing twice is a no-op.
With --client omitted, every client detected on the machine is configured.
For anything not listed above, mcp install --print-config prints the snippet:
{
"mcpServers": {
"mq-bridge": {
"command": "mq-bridge-app",
"args": ["mcp", "--transport", "stdio"]
}
}
}
Use an absolute path to the binary if it is not on PATH. With
--transport http, point your client at http://127.0.0.1:9092/ instead.
Restart the client fully after installing — reopening a tab is not enough.
Under stdio the client spawns the server process, so a rebuilt binary is only
picked up after the client restarts the server — an edit to mcp.rs alone will
not change the behaviour of an already-running session.
Errors
Invalid endpoints, duplicate route names, and unknown route names come back as
JSON-RPC -32602 (invalid params); a connection failure at publish time reports
the underlying transport error:
invalid publisher endpoint: IO error: Connection refused (os error 61)
A partially failed batch returns a result flagged is_error with a
{"status": "Partial", "sent": N, "failed": M, "errors": [...]} summary, so a
partial send is not mistaken for success.
Known limitations
This originates in the upstream mq-bridge crate, not in the MCP layer.
-
Finished routes are not reaped. A route started with
exit_on_emptystays inlist_routesafter it has drained and exited; callstop_routeto clear it. It is no longer indistinguishable from a running one, though: since mq-bridge 0.3.6 each entry carries"finished"plus an"outcome". While the route runs these arefalse/null; once its task ends,"finished": trueand"outcome"iscompleted(drained cleanly) orfailed(permanent error — the cause is instatus.error).stop_routeremoves the entry fromlist_routesas it stops the route, so the third outcome,stopped, is not observable through these tools.
MCP Registry (server.json)
mcp install and the registry solve different problems, and neither needs the
other:
mqb mcp install | MCP Registry | |
|---|---|---|
| For | Someone who already has the binary | Someone who has not heard of it yet |
| Does | Writes the client config directly | Publishes discovery metadata only |
Needs server.json | No | Yes |
So install remains the shortest path on a machine that already has the binary
— it is not superseded by publishing to the registry.
server.json
at the repository root is the registry entry. It publishes the server under
io.github.marcomq/mq-bridge-app and offers two package types, both of which
run the same mcp --transport=stdio command:
registryType | Identifier | Requires |
|---|---|---|
oci | ghcr.io/marcomq/mq-bridge-app:<version> | Docker; no Rust toolchain |
cargo | mq-bridge-app | cargo install, so a Rust toolchain |
The registry only stores metadata — it never hosts the artifact — so it verifies that each package really belongs to this project by looking for an ownership marker inside the published artifact itself:
- OCI — a
LABEL io.modelcontextprotocol.server.namein theDockerfilefinal stage. - Cargo — an
mcp-name:line in the crate README, which crates.io renders. It must be visible markdown: crates.io strips HTML comments during rendering, so the hidden<!-- mcp-name: … -->form that works for PyPI and NuGet is silently dropped here.
Both markers must equal the name field in server.json.
Publishing happens in the publish-mcp-registry job of the release workflow. It
runs after the ghcr manifest and the crates.io release exist, rewrites the
versions in server.json from the release tag, authenticates with
mcp-publisher login github-oidc (the io.github.* namespace is proven by the
workflow’s own OIDC token, so there is no secret to rotate) and publishes. To do
it by hand:
brew install mcp-publisher
mcp-publisher login github
mcp-publisher publish
Testing against local brokers
The compose files in the mq-bridge repo bring up the brokers:
cd ../mq-bridge/tests/integration/docker-compose
docker compose -f nats.yml -f postgres.yml -f redis.yml up -d
Verified end-to-end against these: batch publish to file/NATS/Redis Streams,
NATS → file and Redis Streams → Postgres routes (including
auto_create_table), the full route lifecycle, and the error paths above.
Architecture Overview
mq-bridge is designed as a highly extensible, protocol-agnostic message integration layer for Rust. Its architecture enables seamless bridging between diverse messaging systems, databases, and protocols, while allowing users to inject custom business logic and reliability patterns.
Core Principles
- Protocol Abstraction: All business logic operates on a unified
CanonicalMessagetype, decoupling your code from specific broker or database APIs. - Extensibility: New endpoints and middleware can be added with minimal effort via trait-based factories.
- Async-First: Built on Tokio, all I/O and processing is asynchronous and concurrency-aware.
- Unopinionated: The library does not enforce a specific domain or concurrency model, focusing instead on reliable, programmable data movement.
Main Components
1. Route
A Route defines a data pipeline from one input endpoint to one output endpoint. Each route can:
- Specify concurrency and batch size
- Attach middleware for reliability, deduplication, metrics, etc.
- Attach a handler for business logic (transform, filter, respond)
2. Endpoint
Endpoints are protocol adapters for sources (consumers) and sinks (publishers). Supported types include Kafka, NATS, AMQP, MQTT, MongoDB, HTTP, SQLx, ZeroMQ, Files, AWS, IBM MQ, and the memory endpoint (in-process channels and cross-process IPC). Endpoints are created via factory functions and configured via serde (json/yml).
Beyond these protocol adapters there are structural endpoints that compose other endpoints
or shape routing rather than talking to a broker: ref, fanout, switch, request,
response, reader, static, stream_buffer, null and custom. All of them are
documented in REFERENCE.md.
3. Middleware
Middleware wraps consumers and publishers to add cross-cutting features. There is no
Middleware trait: a middleware is a decorator implementing MessageConsumer and/or
MessagePublisher, which is why CustomMiddlewareFactory is defined as apply_consumer /
apply_publisher. Available middleware includes:
- Retries (exponential backoff) and dead-letter queues (DLQ)
- JSON transformation (
transform): mapping, type coercion, schema validation - Deduplication (sled-based), weak joins, buffering, rate limiting
- Metrics, delays, fault injection
- Custom user middleware
The complete list, with fields, defaults and the layer-ordering rules, is in REFERENCE.md.
4. Handler
Handlers are user-defined async functions that process messages. There are two main handler types:
- CommandHandler: 1-to-1 or 1-to-0 transformation, can return a new message for publishing or as response.
- EventHandler: 1-to-N handler for event consumption. Compatible to CommandHandler, but should not return a response.
- TypeHandler: Strongly-typed handler, dispatches based on the
kindmetadata field and deserializes payloads.
Memory Endpoint and IPC Transport
The memory endpoint is not only an in-process channel. Its topic field (serde alias: url) doubles as a transport URL, so the same endpoint type covers both in-process queues and cross-process IPC over Unix domain sockets / Windows named pipes.
Transport URL schemes
| URL | Resolves to | Platform |
|---|---|---|
my-topic | memory://my-topic (no scheme = in-process, for backward compatibility) | all |
memory://my-topic | In-process channel, shared by namespace within the same process | all |
ipc://my-queue | Unix: /run/mq-bridge/my-queue.sockWindows: \\.\pipe\mq-bridge-my-queue | Unix + Windows |
ipc:///var/run/my.sock | That exact socket path (leading / = absolute path, note the three slashes) | Unix |
unix:///var/run/my.sock | That exact socket path; the path must be absolute | Unix only |
pipe://my-pipe | \\.\pipe\my-pipe — used verbatim, no mq-bridge- prefix | Windows only |
Anything else (http://…, an empty URL, a relative unix://path) is rejected at parse time.
Named ipc:// resolution on Unix falls back in order, using the first writable location:
/run/mq-bridge/<name>.sock(systemd standard)$XDG_RUNTIME_DIR/mq-bridge/<name>.sock/tmp/mq-bridge/<name>.sock(less secure)
Because of this fallback, ipc://name can land in different places for different users or services. When both sides must agree deterministically, use an explicit path (ipc:///run/myapp/queue.sock) instead of a bare name.
Roles: consumer is the server, publisher is the client
IPC is unidirectional, publisher → consumer, and the roles are fixed:
- The consumer binds and listens on the socket/pipe (server). It removes a stale socket file before binding, creates the parent directory with mode
0700, and sets the socket to0600. It unlinks the socket on drop. - The publisher connects to it (client).
So the consumer process must be running before the publisher connects — otherwise the publisher fails with a connection error. The consumer serves one connection at a time; if the peer disconnects, it logs a warning and waits for a new connection rather than erroring out.
IPC requires async construction
MemoryConsumer::new, MemoryPublisher::new, and new_local are synchronous and only support memory://. Given an IPC URL they return an error (“requires async endpoint construction”). Use the async constructors:
use mq_bridge::endpoints::memory::{MemoryConsumer, MemoryPublisher};
use mq_bridge::models::MemoryConfig;
let config = MemoryConfig::new_with_url("ipc:///run/mq-bridge/orders.sock", Some(100));
// Server side (start first)
let mut consumer = MemoryConsumer::new_async(&config).await?;
// Client side, in another process
let publisher = MemoryPublisher::new_async(&config).await?;
Routes always build endpoints through the async factories, so an ipc:// URL works in YAML/JSON config with no extra code:
ipc_ingest:
input:
memory:
url: "ipc:///run/mq-bridge/orders.sock"
capacity: 256
output:
kafka:
topic: "orders"
url: "localhost:9092"
Wire format
Batches are serialized with MessagePack (Vec<CanonicalMessage>) and written as length-prefixed frames: a 4-byte big-endian length followed by the payload. Frames larger than 100 MB are rejected.
Framing is handled by tokio_util::codec::LengthDelimitedCodec in endpoints/memory/framed.rs, shared by both platforms. This matters for more than deduplication: the codec owns the partial-frame buffer, which makes reads cancel safe. Routes cancel receive_batch on shutdown via select!, and a hand-rolled read_exact pair would consume part of a frame on cancellation and desync the connection permanently.
Backpressure
A batch is written as one frame. Socket buffers are small — 8 KiB by default on macOS (net.local.stream.sendspace) — so a batch that outgrows the buffer only completes once the consumer drains it. send_batch blocking is therefore normal backpressure, not a fault.
Two consequences worth knowing:
- The consumer must actually be reading, not merely connected. A consumer that has accepted but stopped draining will stall the publisher indefinitely. After 5 seconds blocked, the publisher logs a warning naming the socket; it keeps waiting rather than dropping data.
capacitydoes not create a queue here. There is no buffering between the two processes beyond the kernel socket buffer.
Acknowledgements and redelivery over IPC
enable_nack defaults to true for IPC transports (ipc://, unix://, pipe://) and false for memory://; an explicit enable_nack in config always wins.
Redelivery over IPC is consumer-local. The socket carries publisher → consumer traffic only, so a nack cannot travel back to the producer — the publisher never reads, and writing to it would strand the messages and eventually block the commit on a full socket buffer. A nacked message is therefore requeued inside the consumer and redelivered ahead of new traffic. It does not survive a consumer crash, and the publisher is never told.
If you need redelivery that survives the consumer process, use a real broker endpoint. mq-bridge deliberately does not implement a bidirectional ack protocol over IPC.
Behavioural differences vs memory://
subscribe_modeis not supported over IPC, on either side — the EventStore/broadcast backend is in-process only.request_replyis not supported for IPC publishers.- Only the publisher side may send. Calling
send_batchon a consumer-side IPC transport returns an error rather than writing into a peer that never reads. capacitybounds the consumer-side buffer, not an internal queue — a socket has no backlog of its own.len()reports whole frames already buffered by the codec (readable without touching the socket), and endpointstatus().pendingadds the consumer’s own buffered and awaiting-redelivery messages.
Batching and Concurrency
Batch processing is a core concept in mq-bridge and is required for all endpoint implementations. Every consumer and publisher must implement batch receive and batch send methods (receive_batch, send_batch).
Why batch mode?
- Batching improves throughput and efficiency, especially for high-volume or high-latency backends.
- It enables the bridge to process messages concurrently and in parallel, reducing per-message overhead.
Concurrency
- Each route can be configured with a
concurrencyparameter, which determines how many worker tasks will process batches in parallel. - Batch size is also configurable per route.
Ordering
Two independent guarantees, each decided once per route by the endpoint itself:
- Commits follow
MessageConsumer::commit_requires_order()(defaulttrue). Cumulative-ack sources such as Kafka funnel their commits through one sequencer; individually-acking sources commit concurrently, bounded bycommit_concurrency_limit. - Publishing follows
MessagePublisher::requires_ordered_publish()(defaultfalse). Aboveconcurrency: 1workers callsend_batchin parallel, so whole batches can reach the sink out of source order — rows keep their order within a batch. Thefilesink declarestrueand the route sequences thesend_batchcalls; batch prep and commits stay parallel, so the ordered path measures within noise of an unordered one.
Only file declares it, for two reasons that any other candidate has to clear:
- The cost must be low. The file sink already holds a write lock across the whole batch, so sequencing changes who writes, not how many write at once. Sequencing a sink that ends in a network round trip instead costs the whole concurrency factor — one batch in flight instead of N.
- The guarantee must be reachable.
object_storedoes not declare it: read order there is object-key order, and the uuidv7 key randomises everything below the millisecond, so ordering the writes would buy nothing. Ordered cloud export needs a source-sequenced key first.
Broker sinks keep it off. NATS (both Core and JetStream) publishes a batch through send_batch_helper, which keeps up to SEND_BATCH_CONCURRENCY publishes in flight, so wire order inside one batch is already unordered — only the results are re-sorted. Kafka and AMQP preserve order within a batch (they submit the whole batch, then await confirms), but for them the order-critical part is the cheap submit loop, not the round trip, so a useful fix would have to release the sequence after submit rather than after send_batch. For per-key Kafka ordering today, use concurrency: 1.
Helper Utilities
mq-bridge provides several helper functions to make implementing batching easier, especially if your endpoint only supports single-message operations:
send_batch_helper: Callssendfor each message in a batch and aggregates the results. Used to implementsend_batchwhen only single-message sending is available.receive_batch_helper: Callsreceiveonce and wraps the result as a batch. Used to implementreceive_batchwhen only single-message receive is available.into_commit_func: Converts a batch commit function (BatchCommitFunc) into a single-message commit function (CommitFunc).into_batch_commit_func: Converts a single-message commit function into a batch commit function.
Sample: Using send_batch_helper
use mq_bridge::traits::send_batch_helper;
// Inside your MessagePublisher implementation:
async fn send_batch(&self, messages: Vec<CanonicalMessage>) -> Result<SentBatch, PublisherError> {
send_batch_helper(self, messages, |pub_ref, msg| {
Box::pin(pub_ref.send(msg))
}).await
}
Sample: Using receive_batch_helper
// receive_batch_helper is a default method on MessageConsumer — no import needed.
// Inside your MessageConsumer implementation:
async fn receive_batch(&mut self, max_messages: usize) -> Result<ReceivedBatch, ConsumerError> {
self.receive_batch_helper(max_messages).await
}
Sample: Commit function conversion
use mq_bridge::traits::{into_commit_func, into_batch_commit_func};
let commit: CommitFunc = into_commit_func(batch_commit_func);
let batch_commit: BatchCommitFunc = into_batch_commit_func(commit_func);
How batch receive works internally
The receive_batch method is designed to efficiently collect a batch of messages from the underlying transport. The typical pattern is:
- Wait for the first message: The consumer awaits a message from the backend (e.g., Kafka, NATS, etc.).
- Drain additional messages if available: After the first message is received, the consumer immediately checks if more messages are already available (without waiting). It continues to drain messages up to the batch size or until no more are available.
- Return the batch: The batch is returned as soon as either the batch size is reached or no more messages are immediately available.
This approach minimizes latency for the first message while maximizing throughput for bursts of messages.
Pseudocode:
async fn receive_batch(&mut self, max_messages: usize) -> Result<ReceivedBatch, ConsumerError> {
let mut messages = Vec::with_capacity(max_messages);
// Wait for the first message
let first = self.inner_receive().await?;
messages.push(first);
// Try to drain more messages without waiting
while messages.len() < max_messages {
match self.try_receive_now()? {
Some(msg) => messages.push(msg),
None => break,
}
}
Ok(ReceivedBatch { messages, commit: ... })
}
Example: Receiving and committing a batch
let batch = consumer.receive_batch(100).await?;
// Process each message in the batch...
batch.commit(vec![MessageDisposition::Ack; batch.messages.len()]).await?;
Example: Sending a batch
let messages = vec![msg1, msg2, msg3];
publisher.send_batch(messages).await?;
See the README and tests for more advanced batching and concurrency patterns.
Getting Started
Below are minimal examples for the three main usage patterns in mq-bridge. For more, see the README and tests.
1. Typed Handler (Event-driven, Type-safe)
Use for strongly-typed, event-driven communication. Register Rust types per message kind; the bridge deserializes payloads automatically. Supports request-response where the protocol allows. Multiple types can be handled by a single route.
use mq_bridge::{msg, Handled, Route, publisher::Publisher, models::Endpoint};
use serde::{Deserialize, Serialize};
#[derive(Serialize, Deserialize, Debug, Clone)]
struct OrderPlaced {
order_id: u64,
amount: f64,
}
let input = Endpoint::new_memory("in", 10);
let output = Endpoint::null();
let route = Route::new(input, output)
.add_handler("order_placed", |msg: OrderPlaced| async move {
println!("Order #{}: ${}", msg.order_id, msg.amount);
Ok(Handled::Ack)
});
route.deploy("typed_handler_example").await.unwrap(); // or use route.run()
let input_publisher = Publisher::new(route.input.clone()).await.unwrap();
let event = OrderPlaced { order_id: 42, amount: 19.99 };
input_publisher.send(msg!(&event, "order_placed")).await.unwrap();
// ...
2. Compute Handler (Generic)
Use a generic handler to process, transform, or filter messages against the raw CanonicalMessage. One handler per-route. Suitable for pipelines, ETL, or side-effect processing.
use mq_bridge::{CanonicalMessage, Handled, Route, models::Endpoint};
let input = Endpoint::new_memory("in", 10);
let output = Endpoint::new_memory("out", 10);
let handler = |mut msg: CanonicalMessage| {
msg.set_payload_str(format!("processed: {}", msg.get_payload_str()));
async move { Ok(Handled::Publish(msg)) }
};
let route = Route::new(input, output).with_handler(handler);
route.deploy("compute_handler_example").await.unwrap();
// ...
3. Direct Endpoint Usage (Manual Control)
Use send / send_batch and receive / receive_batch directly on endpoints. Gives full manual control over batching, commit, concurrency, and sequencing. Useful for advanced scenarios or integration with external async runtimes.
use mq_bridge::endpoints::memory::{MemoryConsumer, MemoryPublisher};
use mq_bridge::{CanonicalMessage, traits::MessageDisposition};
let publisher = MemoryPublisher::new_local("my_topic", 100);
let mut consumer = MemoryConsumer::new_local("my_topic", 100);
let msg = CanonicalMessage::new(b"hello world".to_vec(), None);
publisher.send(msg).await.unwrap();
let received = consumer.receive().await.unwrap();
// Process the message...
// Acknowledge (required for most endpoints):
(received.commit)(MessageDisposition::Ack).await.unwrap();
// Batch variant:
let batch = consumer.receive_batch(10).await.unwrap();
batch.commit(vec![MessageDisposition::Ack; batch.messages.len()]).await.unwrap();
Extending mq-bridge
See EXTENDING.md for the full guide, with worked examples in all three languages.
- Custom Endpoints: Implement the
CustomEndpointFactorytrait and register it withextensions::register_endpoint_factory. Any endpoint key mq-bridge does not recognise is looked up in that registry, so a registeredpulsarfactory makesinput: { pulsar: {...} }work with no core change. Endpoints for transports we do not want in this repository’s dependency tree live in their own crates. - Custom Middleware: Implement the
CustomMiddlewareFactorytrait and register it withextensions::register_middleware_factory; use it ascustom: { name, config }in an endpoint’smiddlewareslist. - From Python / Node:
register_endpoint/register_middleware(Python) andregisterEndpoint/registerMiddleware(Node) take a host-language object instead of a Rust type, for endpoints that only have a Python/JS SDK. - Typed Handlers: Use
TypeHandlerto add new message types and logic.
Configuration
- All routes, endpoints, and middleware are defined via YAML, JSON, or environment variables.
- See CONFIGURATION.md for a full reference and examples.
Example: Route Lifecycle
- Define a route either as json or code
- Create endpoints and apply middleware
- Attach a handler (optional)
- Deploy or run the route (spawns async workers)
- Inject or receive messages
- Route processes, transforms, and delivers messages according to config and handler logic
More Information
- See the README for usage patterns and code examples.
- See CONFIGURATION.md for configuration details.
- See the source code for trait definitions and extension points.
How the app is built
mq-bridge-app is a demonstration of the mq-bridge
engine used on itself: the application serves its own management UI through the engine rather
than through a separate web framework. If you want to understand the engine’s request-reply model
in practice, this is a worked example. For the engine internals themselves, see
Learn the architecture.
Backend: mq-bridge as a web server
Instead of using a traditional web framework like Actix or Axum directly for the management API, the application routes HTTP through the engine:
- HTTP input — an
httpinput endpoint listens on the configured UI port and converts incoming HTTP requests intoCanonicalMessages. - WebUiHandler — a custom
Handlerprocesses these messages as a router: serving static files (HTML, JS) or handling API requests (e.g./config,/schema.json). - Response output — the handler returns a response message, sent to a
responseoutput endpoint, completing the HTTP request-response cycle.
This showcases the library’s ability to handle request-reply patterns and act as a lightweight web server.
Frontend: vanilla-schema-forms
The Web UI is generated dynamically from the Rust configuration structures — no hand-written form code:
- Schema generation — the backend uses
schemarsto generate a JSON Schema for theAppConfigstruct at runtime, exposed via/schema.json(also available on the CLI:mqb --schema dev/config/schema.json). - Dynamic form — the frontend uses vanilla-schema-forms to render a complete configuration form from that schema alone.
- No UI code changes — when a new feature or config option is added to the Rust code (e.g. a new middleware), the schema updates automatically and the UI reflects it without any frontend changes.
Project status
This project is in active development. It originally served as the primary reference
implementation and testbed for the mq-bridge engine.
The UI/Tauri layer was largely prototyped quickly and does not mirror the mq-bridge /
mq-bridge-app core/CLI standards — treat it as a working demo, not a reference implementation,
and test before relying on it in production.
Core concepts
Four terms recur throughout this book.
Route
A route is a named data pipeline that defines a flow from one input to one output. It
carries optional middlewares on either side, a handler, and the tuning knobs batch_size,
concurrency, and commit_concurrency_limit. In config, the top-level keys are route names:
orders_bridge: # <- route name
input: { kafka: { topic: "orders", url: "localhost:9092" } }
output: { nats: { subject: "orders.processed", url: "nats://localhost:4222" } }
Endpoint
An endpoint is a source or sink for messages, written as a single-key object naming the
connector. Transport endpoints talk to a broker or store (kafka, nats, sqlx, mongodb,
file, memory, …); structural endpoints (fanout,
switch, response, ref, null, …) compose other endpoints or shape routing. The same
endpoint type can act as consumer or publisher depending on which side of the route it is on.
See the full catalog in Endpoints (connectors).
Middleware
Middleware intercepts and processes messages around an endpoint — retries, dead-letter
queues, transformation, deduplication, rate limiting, encryption, metrics. It attaches as a
middlewares: list on the input, output, or both. Ordering matters: on an output the last
entry is the outermost layer, so dlq goes last. See Middleware.
Handler
A handler is a programmatic component for business logic, used when you embed the library (config-only routes forward messages without one). There are three kinds:
CommandHandler— a 1-to-1 or 1-to-0 transformation. It takes a message and can return a new message to publish or return as a response.EventHandler— a terminal 1-to-N handler for event consumption. It reads messages without removing them for other event handlers, and should not return a response.TypeHandler— a strongly-typed handler. It dispatches on thekindmetadata field and deserializes payloads into concrete Rust types, so handlers don’t repeat parsing code.
let typed_handler = TypeHandler::new()
.add("create_user", |cmd: CreateUser| async move {
// handle create_user; Ok(()) maps to Handled::Ack
})
.add("delete_user", |cmd: DeleteUser| async move {
// handle delete_user
});
let route = Route::new(input, output).with_handler(typed_handler);
Handlers return a Handled value: Handled::Ack (consume), Handled::Publish(msg) (forward
msg down the publisher chain), and so on. Message selection for TypeHandler is driven by the
kind metadata field, which msg!(&value, "kind") sets. See the
Embed the library tutorial and
Language bindings API.
CanonicalMessage
Every handler works with a CanonicalMessage — the unified message type that decouples
your code from any specific transport. It carries a payload (bytes), string metadata
headers, and a message_id (a UUIDv7 when omitted). Conventional metadata keys: kind
(message type / typed routing), correlation_id and reply_to (request/reply). Keys starting
with mqb.src. are reserved for provenance and stripped on input.
Delivery model
Endpoints default to a consumer (queue) pattern; subscriber (pub/sub) behaviour is opt-in per backend. Delivery is at-least-once where a durable position or lease exists — so for ETL, pair it with an idempotent write at the sink for effective exactly-once. Ack/nack and retry/DLQ handling are designed to work with batching; see Learn the architecture.
Build from source
Developer build instructions for mq-bridge-app — the CLI/server, the desktop
(Tauri) app, and the Docker image. For prebuilt installs (cargo binstall,
release bundles, Docker Hub images) see the main README.
Prerequisites
- Rust toolchain (latest stable version recommended)
- Access to the message brokers you want to connect (e.g. Kafka, NATS, RabbitMQ)
CLI / server
-
Clone the repository:
git clone https://github.com/marcomq/mq-bridge cd mq-bridge -
Build and run the CLI (empty):
cargo run --release -p mq-bridge-app -
Build and run with a config:
cargo run --release -p mq-bridge-app -- --config apps/mq-bridge-app/dev/config/file-to-http.yml
Create a config.yml in the project root or set environment variables — or start
without one and use the UI to define config.yml.
For IBM MQ, install the client library first and build with --features=ibm-mq.
See the IBM MQ Setup Guide.
Desktop app (Tauri)
The desktop UI is a Svelte frontend driven by a Tauri backend:
npm --prefix apps/mq-bridge-app install
npm --prefix apps/mq-bridge-app run dev
To build the UI bundle and serve it from the Rust backend:
npm --prefix apps/mq-bridge-app run build:ui
cargo run --release -p mq-bridge-app
Docker image (no local Rust required)
Requires Docker and Docker Compose:
docker compose -f apps/mq-bridge-app/docker-compose.yml up --build
This builds and starts the bridge CLI application.
Postgres CDC → JSONL
Stream every insert/update/delete from a PostgreSQL table into a newline-delimited
JSON file, continuously. This is change-data-capture (CDC): instead of reading a
table snapshot, mq-bridge follows the write-ahead log via logical replication, so
new changes keep flowing until you stop it.
Prerequisites
-
PostgreSQL with logical replication enabled (
wal_level = logical). -
A publication on the source table:
CREATE PUBLICATION mqb_pub FOR TABLE orders;publicationmust already exist;slot_nameis created automatically if missing (it is a permanent, resumable replication slot).
The one-liner
mqb copy \
--from 'postgres-cdc://user:pass@localhost/app?publication=mqb_pub&slot_name=mqb_slot' \
--to file:///data/orders.jsonl?format=json
--from uses the postgres-cdc:// scheme (alias pgcdc://); the URL underneath is a
plain Postgres URL. --to writes each change as one JSON object per line
(format=json); the path comes from the URI path itself, not a query param.
Each change arrives as a CanonicalMessage whose payload is the flat row and whose
postgres.operation metadata marks the operation (insert/update/delete) — the same
convention as MongoDB CDC, so typed handlers work identically across both.
Equivalent config file
The same thing as a route in mq-bridge.yaml, for the config-driven run
forms:
orders_cdc:
input:
postgres_cdc:
url: "postgres://user:pass@localhost:5432/app"
publication: "mqb_pub" # CREATE PUBLICATION mqb_pub FOR TABLE orders;
slot_name: "mqb_slot" # created if missing (permanent slot, resumable)
output:
file:
path: "/data/orders.jsonl"
format: "json"
Notes
- Resumability. The named replication slot persists progress on the server, so a restart resumes from the last confirmed LSN rather than re-copying from the start.
- This runs forever. CDC is a continuous stream; the process stays up tailing new
changes. For a one-shot bounded copy of an existing table, use a plain
postgres://…?table=…source instead (see Quick start). - Performance. For sustained CDC/ETL throughput and how batching interacts with file sinks, see Performance tuning.
See also
- PostgreSQL CDC connector — URL format and examples.
postgres-cdcparameter reference — every recognised field.- File connector — formats and options for the sink.
HTTP webhook → MongoDB
Accept incoming HTTP webhooks on a local port and persist each request body as a document in MongoDB, with automatic retries on transient write failures. This is a config-driven route rather than a one-liner, because it wires middleware into the pipeline.
The route
mq-bridge.yaml:
webhook_to_mongo:
input:
http:
url: "127.0.0.1:8080"
# Force the normal route pipeline instead of the inline HTTP response fast path.
inline_response_fast_path: false
middlewares:
- retry:
max_attempts: 3
initial_interval_ms: 500
output:
mongodb:
url: "mongodb://localhost:27017"
database: "app_db"
collection: "webhooks"
format: "json" # a bit slower, but stores readable documents
Run it in config mode:
mqb --config mq-bridge.yaml
Then POST to it:
curl -X POST http://localhost:8080 \
-H 'content-type: application/json' \
-d '{"event":"signup","user":"alice"}'
The document lands in app_db.webhooks.
What each piece does
httpinput listens on127.0.0.1:8080, i.e. localhost only. The endpoint is unauthenticated, so bind it to0.0.0.0only behind an authenticating proxy or a network you control. Settinginline_response_fast_path: falseforces requests through the full route pipeline (input → middleware → output) instead of the fast inline-response path, so theretrymiddleware and the MongoDB write both run before the HTTP response is sent.retrymiddleware re-attempts a failed write up tomax_attemptstimes, starting atinitial_interval_msand backing off. See the Retries & backoff recipe for the full knob set and its interaction with a dead-letter queue.
Note
Delivery here is at-least-once: a write that actually succeeded but whose acknowledgement was lost gets retried and inserts a second document. For an idempotent sink, set
id_fieldon themongodboutput to a stable business key so the value becomes the document_id: a retry of an already-written message is then rejected by the unique_idinstead of appending a new document — see Upserts.
mongodboutput withformat: "json"stores each payload as a readable JSON document rather than the default full-message serialization.
Variations
- Want a synchronous reply to the caller? Attach a handler and use a
responseoutput — see the Request / reply tutorial. - High request rates? The
httpinput scales with connection concurrency; see Performance tuning.
See also
- Configuration grammar — this is Route 2 of the annotated example.
- MongoDB connector and parameter reference.
- HTTP connector and parameter reference.
Cross-process IPC bridge
The memory endpoint is more than an in-process channel: its url (alias topic)
is a transport URL, so the same endpoint can bridge separate processes on one
machine over a Unix socket or Windows named pipe — no broker required.
Transport URLs
The url field selects the transport:
| URL | Meaning |
|---|---|
"name" | memory://name — in-process, same process only |
"memory://name" | in-process channel |
"ipc://name" | Unix: /run/mq-bridge/name.sock (falls back to $XDG_RUNTIME_DIR/mq-bridge, then /tmp/mq-bridge). Windows: \\.\pipe\mq-bridge-name |
"ipc:///abs/path.sock" | that exact socket path (Unix) |
"unix:///abs/path.sock" | Unix only, path must be absolute |
"pipe://name" | Windows only, \\.\pipe\name |
The consumer side binds/listens and must be started before the publisher
connects. IPC does not support subscribe_mode or request_reply. enable_nack
defaults to true, but redelivery is consumer-local: a nacked message is retried
inside the consumer and is lost if the consumer process dies.
Example: ingest process → Kafka
One process reads from a Unix socket and forwards to Kafka. Start this consumer first so the socket exists before any producer connects:
ipc_ingest:
input:
memory:
url: "ipc://orders"
capacity: 256
output:
kafka:
topic: "orders"
url: "localhost:9092"
mqb --config ipc_ingest.yaml
The named form is used rather than an explicit path so no privileged directory has to exist
up front: /run/mq-bridge/ needs root, and ipc://orders falls back to
$XDG_RUNTIME_DIR/mq-bridge or /tmp/mq-bridge. Give an absolute
ipc:///abs/path.sock only once you have created and permissioned that directory.
Any other process on the same host can now publish into
ipc://orders — via its own mq-bridge route, or programmatically
through a Publisher built on a memory endpoint (see the
embedding tutorial) — and the messages flow through to Kafka.
When to use it
- Decouple a producer and a sink into separate processes without standing up a broker — e.g. an app writes to the socket, a long-lived bridge process owns the Kafka/DB connections and batching.
- Language boundary. A Python or Node process publishes into the socket; a Rust bridge process does the heavy I/O.
capacity bounds the in-flight channel; tune it alongside the guidance in
Performance tuning.
See also
- Configuration grammar — this is Route 9 of the annotated example, with the full URL grammar in comments.
- Endpoints (concepts).
Request / reply
mq-bridge supports request-response patterns for interactive services such as web
APIs. A client sends a request and waits for the matching response, while the bridge
keeps the correlation details away from your handler.
The response output is the most direct option and the safest one under concurrency:
the response stays in the same execution context as the request, so concurrent
requests do not share a reply queue or race on correlation IDs.
How it works
- An input endpoint that supports request-reply (like
http) receives a request. - The message passes through the route’s processing chain — this is where you attach a handler to process the request and generate a response payload.
- The final message is sent to the
output. - If the output is
response: {}, the bridge sends the message back to the original input source, which delivers it as the reply (e.g. the HTTP response body).
Not every backend can do request-reply. It is only supported/tested for endpoints that natively support or emulate it. SQLx, files, AWS, IBM MQ, and Sled do not support request-reply. Check the backend table in the engine README.
Example: MongoDB request/response
A sender writes a request document to MongoDB and waits for a reply. The bridge reads the document, runs the handler, and writes the result back to the reply collection:
mongo_responder:
input:
mongodb:
url: "mongodb://localhost:27017"
database: "app_db"
collection: "requests"
output:
# 'response' sends the processed message back to the reply collection
# (whatever reply_to the sender set).
response: {}
Attaching the handler (Rust)
The response output only echoes what the pipeline produced — you supply the logic by
attaching a handler to the route:
use mq_bridge::models::Handled;
use mq_bridge::CanonicalMessage;
let handler = |mut msg: CanonicalMessage| async move {
let request_body = String::from_utf8_lossy(&msg.payload);
let response_body = format!("Handled response for: {}", request_body);
msg.payload = response_body.into();
Ok(Handled::Publish(msg))
};
// load the route from YAML, then attach `handler` to its output and run it.
See Embed the library for the full load-and-run scaffolding, and
Core concepts for the handler model
(CommandHandler / EventHandler / TypeHandler).
See also
request/responsestructural endpoints — the reference definitions.- Engine README, Patterns: Request-Response — the authoritative write-up.
Embed the library (Rust / Python / Node)
The core engine is a Rust library, and the same engine ships as native bindings for Python and Node.js. The Tokio runtime, broker I/O, routing, and batching all stay in Rust; the binding is a thin layer for handlers and configuration. Behaviour and reliability match the Rust engine regardless of which language calls in.
| Language | Package | Install |
|---|---|---|
| Rust | mq-bridge | cargo add mq-bridge |
| Python | mq-bridge-py | pip install mq-bridge-py |
| Node.js | mq-bridge | npm install mq-bridge |
The config-first workflow
The natural way to embed the library is to design the route as config, then load
that exact config from your code. Design and test a route in the desktop UI (or hand-write
the YAML), export the JSON/YAML, and load it — the running route behaves identically to
the --config CLI form.
Constructor names are kept aligned across languages (Python snake_case, Node
camelCase):
| Purpose | Python | Node.js |
|---|---|---|
| Load a route from a YAML/JSON file | Route.from_file | Route.fromFile |
| Load from an in-memory YAML/JSON string | Route.from_str | Route.fromStr |
| Load from a parsed dict / JS object | Route.from_config | Route.fromConfig |
| Build a publisher endpoint | the matching Publisher.* | the matching Publisher.* |
The name argument is optional: pass it to select one entry from a
routes:/publishers: document, or omit it to treat the config as a single bare
route/endpoint body.
Rust
The Rust crate exposes the full engine. The main types:
Route::new(input, output)— a pipeline from one endpoint to one endpoint, with.with_batch_size(n),.with_handler(h),.add_handler(kind, f),.deploy(name)/.run().Endpoint— protocol adapters (Endpoint::new_memory,Endpoint::null, …) plus the serde-configured variants.Publisher::new(endpoint)— publish into a route’s input or any endpoint.- Handlers:
CommandHandler(1-to-1 / 1-to-0),EventHandler(terminal 1-to-N), andTypeHandler(dispatches on thekindmetadata field, deserializing payloads). CanonicalMessage— the unified message type all handlers work with;msg!(&value, "kind")builds one with akind.
The MessageConsumer and MessagePublisher traits (in mq_bridge::traits) are the
core abstractions. See the Rust docs on docs.rs for the
full API.
See also
- Language bindings API — the reference for this material.
- Writing endpoints & middleware — plugging your own endpoint or middleware into the engine from Rust, Python, or Node.
- Core concepts and Learn the architecture — the handler and message model.
- The three ways to run it — library vs CLI-server vs desktop.
Upserts & insert-if-absent
For ETL, at-least-once delivery plus an idempotent write at the sink gives you effective exactly-once: a replayed or retried record must not create a duplicate row. The most robust place to enforce this is the sink’s own unique constraint — it’s already shared across every writer, so no extra state store is needed.
MongoDB — id_field
Point id_field at a top-level payload field and its value becomes the document _id.
Re-inserting the same business key then hits the unique _id index and is treated as an
idempotent success (the duplicate is skipped, not errored):
orders_to_mongo:
input: { kafka: { topic: "orders", url: "localhost:9092" } }
output:
mongodb:
url: "mongodb://localhost:27017"
database: "shop"
collection: "orders"
format: json
id_field: "order_id" # payload {"order_id": "A-1", ...} → _id = "A-1"
The field’s JSON type is preserved (a number stays a BSON integer). The payload must contain the
field, otherwise the message is dead-lettered rather than written with a random _id. Use
id_field on sink collections only — a business-key _id is incompatible with the
consumer competing-consumer mode, which requires a UUID _id.
SQL (sqlx) — ON CONFLICT / ON DUPLICATE KEY
insert_query is user-supplied, so you write the dialect’s upsert directly. This requires a
pre-existing UNIQUE/PRIMARY KEY on the key column, and is incompatible with bulk_copy
(COPY cannot express ON CONFLICT) — so you trade peak throughput for deduplication.
-- PostgreSQL — insert if absent (drop duplicates):
INSERT INTO orders (id, body) VALUES (${payload:id}, ${payload:body}) ON CONFLICT (id) DO NOTHING
-- PostgreSQL — upsert (last write wins):
INSERT INTO orders (id, body) VALUES (${payload:id}, ${payload:body})
ON CONFLICT (id) DO UPDATE SET body = EXCLUDED.body
-- MySQL / MariaDB:
INSERT INTO orders (id, body) VALUES (${payload:id}, ${payload:body})
ON DUPLICATE KEY UPDATE body = VALUES(body)
-- SQLite:
INSERT INTO orders (id, body) VALUES (${payload:id}, ${payload:body}) ON CONFLICT (id) DO NOTHING
A plain INSERT without a conflict clause instead fails the row as a non-retryable error
(captured by a dlq, or logged and dropped without one). ${payload:field} binds a
typed value from the JSON payload; ${metadata:key} binds a metadata string.
ClickHouse — ReplacingMergeTree
ClickHouse has no unique constraints; dedup is a table-engine property. Create the target as
ReplacingMergeTree(version) keyed by your business key via ORDER BY, using a monotonic column
as the version (an ingest timestamp, or postgres.lsn from a CDC source):
CREATE TABLE orders (id UInt64, body String, version UInt64)
ENGINE = ReplacingMergeTree(version) ORDER BY id;
-- mq-bridge just inserts rows; duplicates for the same id collapse on merge.
SELECT * FROM orders FINAL;
Read with FINAL (or argMax) to see the deduplicated result. ClickHouse also natively dedupes
identical re-inserted blocks (default one-hour window) — treat that only as retry-safety, and
rely on ReplacingMergeTree for logical dedup.
Branch on insert vs. duplicate (MongoDB report_outcome)
To act on whether a record was newly inserted or already existed, set report_outcome: true;
the Mongo publisher tags the returned message with mongodb.outcome = inserted or existed.
Wrap it in a request endpoint to forward that tagged message
into a switch that routes on the outcome:
orders_upsert_branch:
input: { kafka: { topic: "orders", url: "localhost:9092" } }
output:
request:
to:
mongodb:
url: "mongodb://localhost:27017"
database: "shop"
collection: "orders"
format: json
id_field: "order_id" # deterministic _id → insert-if-absent
report_outcome: true # → mongodb.outcome = inserted | existed
forward_to:
switch:
metadata_key: "mongodb.outcome"
cases:
inserted: { ref: "enrich_new_order" }
existed: { ref: "handle_duplicate" }
report_outcome is sink-only and pairs with id_field. See also
Deduplication for the middleware-based complement, the
Postgres CDC tutorial for CDC-specific idempotency. When the
same key can change more than once in a transaction, enable source_metadata: true and order by
both mqb.src.postgres_lsn and mqb.src.postgres_ordinal; an LSN-only version cannot order those
changes. The linked deduplication guide shows the complete predicate. Also see
Delivery guarantees for which source/sink pairings add up to
effectively-once.
Dead-letter queues
By default, a message that fails permanently is logged at error level and dropped — the
route tolerates it and keeps going. A dlq middleware is the only
mechanism that retains those failures for inspection or replay.
Basic DLQ
Add dlq to the output middleware list. The DLQ endpoint is a full endpoint, so failed
messages can land anywhere — a file, another queue, a database:
output:
middlewares:
- dlq:
endpoint:
file: { path: "dead-letters.jsonl" }
kafka: { topic: "orders", url: "localhost:9092" }
From the copy CLI, dlq’s endpoint is a URL-encoded endpoint URI:
--to 'kafka://broker:9092?topic=orders|dlq?endpoint=file%3A%2F%2F%2Ftmp%2Ffailed.jsonl'
Pair it with retry — and mind the order
retry alone does not retain a failed message; once its attempts are exhausted it hands the
still-failing message on to be dropped (or to a following dlq). Put retry before dlq so the
dlq is the outermost layer and captures what retry gives up on:
output:
middlewares:
- retry: { max_attempts: 3 }
- dlq: { endpoint: { file: { path: "rejected.jsonl" } } } # last = outermost
On an output, publisher middlewares wrap in list order, so the last entry is outermost. See the ordering rule.
What gets dead-lettered
- ✅
NonRetryablefailures (data/type errors the sink rejects, poison payloads). - ✅
Retryablefailures whose retries are exhausted. - ❌ Connection errors are not dead-lettered — they propagate so the route can reconnect. (If the DLQ send itself fails with a connection error, that error propagates rather than silently dropping the message.)
Catching transform rejections
A transform failure on an output is non-retryable and flows to a following
dlq, so invalid records are captured with the reason:
output:
middlewares:
- transform: { schema_file: "schemas/user.json" }
- dlq: { endpoint: { file: { path: "rejected.jsonl" } } }
kafka: { topic: "users", url: "localhost:9092" }
No DLQ? Watch status and logs
Without a dlq, a systematic failure (e.g. every row hitting a column-type mismatch) drains
the input while delivering nothing to the sink and still ends completed. Route status retains the drop
count and last rejection cause, and the logs contain a burst of
Dropping message … due to non-retryable error. Do not use the outcome alone as a delivery
check. See
Troubleshooting.
Deduplication
Two complementary ways to keep replayed or retried records from creating duplicates: the
deduplication middleware (filters before the sink),
and the sink’s own unique constraint (the robust choice for multi-writer ETL, covered in
Upserts & insert-if-absent).
The deduplication middleware
Drops messages whose ID was already seen within a TTL. Input only. Requires the dedup
feature:
input:
middlewares:
- deduplication: { store: "sled:///var/lib/mq-bridge/dedup", ttl_seconds: 3600 }
kafka: { topic: "orders", url: "localhost:9092" }
Picking a store
store selects the backend by URL scheme, and the scheme decides whether deduplication is
process-local or shared across every instance of the route:
store | Scope | Extra feature |
|---|---|---|
sled:///path (or a bare path) | per-process only | — |
mongodb://host/db[/collection] | shared between instances | mongodb |
postgres / mysql / mariadb / sqlite ://…[/table] | shared between instances | sqlx |
The collection/table defaults to mqb_dedup_<route>. Point a shared store at the deployment
your sink already uses rather than standing up extra infrastructure. sled_path is the legacy
spelling of a local sled store, equivalent to store: "sled://<path>".
# Shared across every instance of this route.
- deduplication: { store: "mongodb://localhost:27017/etl", ttl_seconds: 3600 }
A sled store is per-process, not cluster-wide, so for multi-writer pipelines use either a
shared store above or the sink constraint (below). Even with a shared store, the sink’s own
unique constraint remains the more robust choice when the sink has one — it is already the
authority, with no second write.
Sink-side dedup (the robust path)
The most robust place to dedup is the sink’s own unique constraint — it’s already shared across every writer:
- MongoDB —
id_fieldmaps a business key to the unique_id; a duplicate is an idempotent skip. - SQL —
ON CONFLICT (key) DO NOTHING/ON DUPLICATE KEY UPDATE. - ClickHouse —
ReplacingMergeTree(version)collapses duplicates by sort key at merge time.
Full examples in Upserts & insert-if-absent.
Deduplicating CDC replays
A postgres_cdc change event’s message_id is a stable hash that includes the table,
key, operation, commit LSN, and intra-transaction ordinal. Replayed changes therefore deduplicate
through the deduplication middleware while distinct changes in one transaction remain distinct —
but only within that middleware’s reach: a replay arriving more than ttl_seconds after the
original is no longer remembered, and a local sled store only remembers what this route
instance saw. The sink’s own constraint (id_field / ON CONFLICT) is the durable guarantee;
the middleware only saves the sink the write.
An LSN-only sink predicate is not enough when one transaction changes the same key more than once:
those changes share a commit LSN, so the first accepted row can block a later row. Enable
source_metadata: true on the postgres_cdc source and order sink versions by the pair
(mqb.src.postgres_lsn, mqb.src.postgres_ordinal) instead. Persist both metadata values and
compare the pair lexicographically in the upsert predicate:
Both columns must be typed and NOT NULL. lsn as text sorts 0/9… above 0/16…, which
is backwards, and a NULL on either side makes the whole WHERE predicate NULL, so the update
is skipped and the row silently stops advancing:
ALTER TABLE orders
ADD COLUMN lsn pg_lsn NOT NULL DEFAULT '0/0',
ADD COLUMN ordinal bigint NOT NULL DEFAULT 0;
On a table that already has these columns as text or as nullable, backfill before switching the
predicate on — UPDATE orders SET lsn = '0/0' WHERE lsn IS NULL (same for ordinal), then
ALTER COLUMN … TYPE pg_lsn USING lsn::pg_lsn and SET NOT NULL. A '0/0' floor means the first
change event for each existing row wins, which is what a re-snapshot should do.
INSERT INTO orders (id, body, lsn, ordinal)
VALUES (${payload:id}, ${payload:body}, ${metadata:mqb.src.postgres_lsn}::pg_lsn, ${metadata:mqb.src.postgres_ordinal}::bigint)
ON CONFLICT (id) DO UPDATE
SET body = EXCLUDED.body, lsn = EXCLUDED.lsn, ordinal = EXCLUDED.ordinal
WHERE (EXCLUDED.lsn, EXCLUDED.ordinal) > (orders.lsn, orders.ordinal)
The row comparison decides on lsn and only consults ordinal when the two LSNs are equal.
See the Postgres CDC → JSONL tutorial for the full CDC idempotency picture, and Delivery guarantees for what identity each source provides and which sinks absorb a duplicate write.
Checkpoints & resumable copies
A checkpoint is a durable record of how far a source has been read, so a restart continues
where the last run stopped instead of re-copying from the beginning. It is what turns a one-shot
copy into a repeatable incremental sync you can put on a timer.
Two settings control it, on the source endpoint:
| Setting | Meaning |
|---|---|
cursor_id | The checkpoint’s key. Without it, nothing is persisted — the source still reads correctly, but every restart begins from scratch (a warning is logged). |
checkpoint_store | Where the position is stored. Optional on most sources; see Picking a store. |
Positions are namespaced as <source>:<cursor_id>, so several routes may share one store without
colliding. checkpoint_store may embed credentials and is treated as a secret.
Which sources checkpoint
| Source | Enable with | Position stored |
|---|---|---|
| SQLx (PostgreSQL / MySQL / MariaDB / SQLite) | cursor_column + cursor_id | Last value of cursor_column |
| ClickHouse | cursor_column + cursor_id + external checkpoint_store | Last value of cursor_column |
MongoDB (consume: capture_new / capture_all) | cursor_id | Change-stream resume token. For capture_all, only the change-stream phase is checkpointed; the initial snapshot is not resumable. |
Object store (s3://, gs://, az://) | cursor_id + external checkpoint_store | Last fully-acked object key |
| Postgres CDC | (automatic) | Confirmed LSN — the replication slot is authoritative; cursor_id only adds a local copy |
An SQLx source with no cursor_column is a destructive work queue, not a resumable read —
progress is the deletion of claimed rows, so there is nothing to checkpoint. See
Endpoints.
Picking a store
checkpoint_store selects the backend by URL scheme; a value with no scheme is a plain
table/collection name in the source datastore.
| Value | Backend | Use when |
|---|---|---|
| (absent) | Source datastore, mqb_cursors_<source> | Default. You can write to the source database. |
my_cursors or /my_cursors | Source datastore, that name | Same, with a name you choose. |
file:///var/lib/mqb/cursors.json | Local JSON file | The source is read-only, or a dev/CLI one-off. |
postgres://…/db/table, mysql://… | External SQL table | Shared operational store; the table name is optional. |
mongodb://host/db/collection | External MongoDB collection | Same, for Mongo shops. |
s3://bucket/prefix (gs://, az://, abfs://) | Cloud object store, one object per cursor | Ephemeral/containerized runners with no local disk. |
Notes:
- ClickHouse requires an external store. It cannot cheaply upsert cursor rows, so a
source-datastore checkpoint is rejected;
cursor_idwithout acheckpoint_storesilently disables resume (with a warning). - Object-store sources must point
checkpoint_storeat a different bucket or prefix than they read — a cursor object written under the source prefix would be listed and re-read as data. The source rejects an overlapping location. - A file store is written atomically (temp file + rename) and concurrent writers in one process are serialized, so several routes may share one file.
- Cloud object-store checkpoints need the
object-storefeature compiled in.
Using it with copy
cursor_id, cursor_column, and checkpoint_store are ordinary endpoint config fields, so on
the CLI they are just query parameters on --from:
# Incremental table → table sync. Re-run it as often as you like: each run copies
# only rows whose `id` is greater than the last successfully written row.
mqb copy \
--from 'postgres://user:pass@localhost/app?table=orders&cursor_column=id&cursor_id=orders_sync' \
--to 'clickhouse://localhost:8123?table=orders&database=analytics' \
--drain
# Read-only source: keep the cursor next to the job instead of in the source DB.
mqb copy \
--from 'mysql://ro_user:pass@reporting/app?table=events&cursor_column=event_id&cursor_id=events_export&checkpoint_store=file%3A%2F%2F%2Fvar%2Flib%2Fmqb%2Fcursors.json' \
--to 'file:///data/events.jsonl' \
--drain
# MongoDB bulk read that survives a restart mid-copy.
mqb copy \
--from 'mongodb://localhost:27017/app?collection=orders&consume=capture_all&cursor_id=orders_dump' \
--to 'file:///data/orders.jsonl'
A checkpoint_store URL inside a URI must be percent-encoded (:// → %3A%2F%2F), since it is
a query-parameter value. In a YAML config it is written plainly:
orders_sync:
input:
postgres:
url: "postgres://user:pass@localhost/app"
table: orders
cursor_column: id
cursor_id: orders_sync
checkpoint_store: "file:///var/lib/mqb/cursors.json"
output:
clickhouse: { url: "http://localhost:8123", table: orders, database: analytics }
--drain and checkpoints
--drain exits when the source yields an empty batch — i.e. when the checkpoint has caught up
with the table. That is exactly the shape you want for a cron/systemd-timer job: each invocation
drains the backlog since last time and exits 0. Without --drain, the same command runs forever,
polling for new rows every polling_interval_ms (100 ms by default) — or backing off
exponentially up to max_polling_interval_ms while drained, if you set it.
MongoDB capture_all follows a change stream once its initial read is done, so a --drain run
ends when that stream goes quiet rather than at a known end of data. capture_new only ever
emits changes made after it starts and is continuous by nature. For a one-shot read with a real
end, use consume: snapshot — non-destructive, no replica set, and not resumable (it rejects
cursor_id).
An object-store source ends a drain run when it reaches the end of the objects it listed, which can be before the prefix is exhausted — the checkpoint makes this safe rather than lossy: each run resumes at the last fully-acked object key, so repeated runs advance until one reports zero messages. Loop the job until it moves nothing if you need a single pass to cover everything.
Delivery semantics
Checkpoints are at-least-once, never at-most-once:
- The position advances only after the sink acknowledges, and only across the contiguous run of acks from the front of the batch. The first nack stops the advance.
- On a partial failure the in-memory read cursor rolls back to the committed boundary, so nacked rows are re-read on the next poll rather than skipped until a restart.
- A crash between “rows written” and “checkpoint flushed” replays that batch. Make the sink idempotent — see Upserts & insert-if-absent and Deduplication.
- If persisting the cursor fails, the route logs a warning and keeps running; rows may be reprocessed on restart.
Gotchas
cursor_columnmust be monotonic and non-decreasing for new rows (WHERE col > $last ORDER BY col ASC). An autoincrement id or an append-only timestamp works; a mutableupdated_atdoes not give you deletes, and a column that can go backwards loses rows.- Cursor polling captures appends only. Updates and deletes to already-copied rows are not
observed. For those, use CDC (
postgres_cdc, MongoDBcapture_*). - Equal-value groups must fit in a batch. If more rows share one
cursor_columnvalue thanbatch_size, the reader refuses to advance rather than skipping the remainder. It reportscursor_column '…' has a group of equal values larger than batch_sizeand retries that poll indefinitely rather than exiting, so a--drainjob hangs instead of failing — watch for the repeating log line. Raisebatch_sizeabove the largest group, or pick a more unique column. - The cursor column must be integer or text. Other types the SQL
Anydriver can’t decode fail permanently; expose the column asBIGINT/TEXTthrough a view. cursor_columnanddelete_after_readare mutually exclusive — one is non-destructive, the other consumes.- Changing
cursor_idorcheckpoint_storestarts over. The position is keyed by both; a new key means a full re-copy. Reuse the same pair to continue a sync, and give unrelated jobs distinctcursor_ids. capture_all’s initial snapshot is not incrementally checkpointed. What gets persisted is the change-stream resume token, written once streaming begins; a run interrupted during the snapshot re-snapshots from the beginning. Size the sink’s idempotency accordingly.- ClickHouse and SQL Server: ClickHouse is polling-only and needs an external store;
cursor_columnmode is not supported on Microsoft SQL Server at all.
See also
- Endpoints (concepts) — read modes and CDC
- PostgreSQL parameters · MongoDB · ClickHouse · Postgres CDC
- CLI commands —
copyflags and URI grammar
Retries & backoff
The retry middleware retries failed sends with exponential
backoff. Output only.
output:
middlewares:
- retry: { max_attempts: 5, initial_interval_ms: 200, max_interval_ms: 10000, multiplier: 2.0 }
kafka: { topic: "orders", url: "localhost:9092" }
From the copy CLI:
Attach the chain to --to, since retry is output-only:
--to 'kafka://localhost:9092?topic=orders|retry?max_attempts=5&initial_interval_ms=200'
| Field | Default |
|---|---|
max_attempts | 3 |
initial_interval_ms | 100 |
max_interval_ms | 5000 |
multiplier | 2.0 |
What gets retried
Only Retryable and connection errors are retried; NonRetryable failures pass straight
through. Once attempts are exhausted, the error is marked so a following dlq treats
it as permanent.
Always pair retry with dlq
retry does not retain a message it gives up on — it hands the still-failing message on to
be dropped, or to a following dlq. Put dlq after retry so it’s the outermost layer and
captures the exhausted failures:
output:
middlewares:
- retry: { max_attempts: 3 }
- dlq: { endpoint: { file: { path: "rejected.jsonl" } } }
Don’t over-tune
Several endpoints already retry connection/timeout errors internally, and the retry
middleware adds backoff on top — so you rarely need a large max_attempts. Cap
max_interval_ms to match your latency budget so retries never stall a route indefinitely. See
Performance tuning → Retry & backoff.
Transform & schema mapping
The transform middleware reshapes JSON payloads
declaratively, so renaming fields and fixing types doesn’t need a custom handler. It runs two
optional stages over a single parse: mapping (rename, move, nest) then schema
(coerce, apply defaults, validate). Input and output.
Rename + type-fix a CSV feed
csv_to_kafka:
input:
file: { path: "users.csv", format: csv }
output:
middlewares:
- transform:
mapping:
firstName: "$.first_name"
lastName: "$.last_name"
id: "$.user_id"
"address.city": { path: "$.city", default: "unknown" }
schema_file: "schemas/user.json"
- dlq:
endpoint: { file: { path: "rejected.jsonl" } }
kafka: { topic: "users", url: "localhost:9092" }
With the CSV producing {"first_name":"John","last_name":"Smith","user_id":"42"}, the mapping
yields {"firstName":"John","lastName":"Smith","id":"42","address":{"city":"unknown"}} ($.city
is absent, so address.city falls back to its default) and the schema then coerces id to the
integer 42.
- Paths accept
$.field,$.a.b,$.items[0](the$.prefix is optional). Dots in the output key nest the result. An absent optional source field is omitted, not emitted as null. - Coercions are the lossless ones only:
string → integer,string → number,string → boolean,number → string.
Decode an embedded JSON string
A field carrying a JSON document as a string is decoded via contentMediaType (JSON Schema
2020-12) — opt-in per field, never done by plain coercion:
- transform:
schema:
type: object
properties:
payload:
type: string
contentMediaType: application/json
contentSchema:
type: object
properties:
qty: { type: integer }
The string is replaced by the parsed document, and contentSchema (if given) is applied with the
same coercion/defaults/validation — so an inner qty: "7" arrives as 7.
Handling failures
Failures are non-retryable and name the field, e.g.
transform failed at $.items[1].qty [coercion]: cannot coerce string "oops" to integer.
- On an output, the message is failed so a following
dlqcaptures it. - On an input, it’s dropped from the batch and acknowledged, keeping invalid data out.
on_error: pass_throughinstead forwards the original payload with the reason in themqb.transform_errormetadata key — which aswitchcan route on.
Full option list, the supported schema subset, and the exact coercion rules are in the
middleware reference → transform.
Content-based routing (switch)
The switch structural endpoint picks one destination per
message. Output only. It has two modes and uses exactly one of them: value lookup on a
metadata key, or when predicates over the payload. Naming both is a startup error.
output:
switch:
metadata_key: "country_code"
cases:
US: { kafka: { topic: "us_orders", url: "kafka-us:9092" } }
EU: { nats: { subject: "eu_orders", url: "nats-eu:4222" } }
default: { file: { path: "/var/data/unroutable_orders.log" } }
A message whose key is missing or unmatched goes to default; without a default it is
dropped.
From the CLI, the same endpoint is a URI:
switch:?metadata_key=country_code&case.US=<uri>&default=<uri> — see
Structural endpoints in the URI.
Route on the payload (when)
when takes an ordered list of predicates and sends the message to the first one that matches.
The expression language is the one --filter uses: payload
fields by bare name including nested paths, metadata under meta., and and / or.
output:
switch:
when:
- if: "amount > 10000"
to: { kafka: { topic: "large_orders", url: "kafka:9092" } }
- if: "order.status == 'refunded'"
to: { nats: { subject: "refunds", url: "nats://localhost:4222" } }
default: { file: { path: "/var/data/orders.jsonl" } }
A predicate parses the payload, while value lookup is a HashMap get on metadata — which is why
the modes cannot be mixed in one endpoint, and why a metadata key you already have is the
cheaper branch. A message matching no predicate goes to default, and without a default it
is dropped, exactly as in value-lookup mode.
The CLI spells this as when=<expression> / to=<uri> pairs:
switch:?when=amount > 10000&to=<uri>&default=<uri>.
Promote a payload value into metadata
Value-lookup mode matches on metadata, not payload fields. When you want that mode — an exact-match table rather than predicates — promote the value into metadata first. Two common ways:
- An endpoint that already emits a status key — e.g.
http_status_codefrom an HTTPrequest, ormongodb.outcomefrom a Mongo upsert (see Upserts). transformwithon_error: pass_through, which setsmqb.transform_erroron failed records so you can shunt them aside:
output:
middlewares:
- transform: { schema_file: "schemas/order.json", on_error: pass_through }
switch:
metadata_key: "mqb.transform_error"
cases: {} # (no exact-match cases)
default: { kafka: { topic: "orders", url: "localhost:9092" } }
Split HTTP responses by status
Pair switch with request, which forwards a response (or,
on error/timeout, the original message) tagged with a status key:
output:
request:
to: { http: { url: "https://api.internal/score" } }
forward_to:
switch:
metadata_key: "http_status_code"
cases:
"200": { nats: { subject: "ok", url: "nats://localhost:4222" } }
"404": { file: { path: "not-found.jsonl" } }
default: { file: { path: "other.jsonl" } }
See also Fan-out to send to all destinations instead of picking one.
Fan-out
The fanout structural endpoint publishes each message to
every listed endpoint. Output only. The value is a plain list — each branch may have its own
middleware and may itself be structural.
output:
fanout:
- kafka: { topic: "audit", url: "localhost:9092" }
- file: { path: "audit.jsonl" }
- nats: { subject: "audit", url: "nats://localhost:4222" }
All branches receive the same message. Use it to mirror a stream to an archive while it also flows to its primary sink, to tee traffic to an audit log, or to emulate a static subscriber set (one branch per subscriber).
Fan-out vs. switch
Give one branch its own reliability
Because each branch is a full endpoint, you can wrap just the fragile one with retry/dlq while the others stay plain:
output:
fanout:
- kafka: { topic: "orders", url: "localhost:9092" }
- middlewares:
- retry: { max_attempts: 5 }
- dlq: { endpoint: { file: { path: "audit-failed.jsonl" } } }
http: { url: "https://audit.internal/ingest", method: "POST" }
From the CLI
copy can build a fan-out too — fanout:?to=<uri>&mirror=<uri>, where a mirror branch’s
response and failures are discarded. See
Structural endpoints in the URI.
Weak join / correlation
The weak_join middleware correlates messages by a metadata
key and emits them as one joined message. Input only. It has two modes.
Count mode
Wait for any N messages sharing a correlation key, then emit them as a JSON array:
input:
middlewares:
- weak_join: { group_by: "correlation_id", expected_count: 3, timeout_ms: 5000 }
kafka: { topic: "fragments", url: "localhost:9092" }
Branch mode
Set branch_by to wait for named branches (e.g. one message from inventory and one from
pricing), then emit a branch-keyed JSON object. required overrides expected_count:
- weak_join:
group_by: "correlation_id"
expected_count: 2
timeout_ms: 5000
branch_by: "source"
required: ["inventory", "pricing"]
on_timeout: discard
From the copy CLI, object/array fields take a JSON literal:
--from '...|weak-join?group_by=cid&expected_count=2&timeout_ms=1000&required=["inventory","pricing"]'
On timeout
An incomplete group is either emitted partially (on_timeout: fire, the default) or dropped
(on_timeout: discard).
Durability caveat. Messages are acknowledged on receipt, so a crash before the group completes loses the buffered members —
weak_joincorrelates in memory and is not a durable join. For correlation that must survive a restart, land the fragments in a store and join there.
Full field list in the middleware reference → weak_join.
Encryption at rest
Two ways to encrypt data, depending on whether you want it encrypted in transit between endpoints or at rest in a file / object.
The encryption middleware — payloads in transit
The encryption middleware seals each message payload
into a self-describing AEAD envelope on the output side and decrypts it on the input side.
Metadata and routing keys stay in the clear. Requires the encryption feature.
output:
middlewares:
- encryption: { key: "${env:MQB_ENC_KEY}" }
nats: { subject: "secure.orders", url: "nats://localhost:4222" }
The key is a base64-encoded 32-byte key; ${env:VAR} reads it from the environment (see
Secrets & interpolation). Cipher defaults to xchacha20poly1305 (aes256gcm
also available).
A sealed payload is binary, so a file/object_store sink that stores it must use
format=normal — format=json/text render the payload as a JSON value and it cannot be read
back (the reader reports “unsupported encryption envelope version 91”). Stacking it with
compression works, but the reading route must list the two middlewares in the reverse
order, since middlewares apply in list order on both ends.
From the copy CLI it is an inline middleware; keep the key in ${env:VAR} so it never reaches
the process list or shell history ({/} percent-encode to %7B/%7D):
MQB_ENC_KEY=… mqb copy \
--from 'file:///tmp/orders.jsonl' \
--to 'nats://localhost:4222?stream=secure&subject=secure.orders|encryption?key=$%7Benv:MQB_ENC_KEY%7D'
Key rotation: seal with a new key_id/key while listing the old key under decrypt_keys
on the consuming side. Each payload is authenticated independently — tampering, a torn frame, or
a wrong/missing key is a hard consumer error, not a silent drop.
The AEAD binds only the payload (empty associated data), so a sealed payload can be replayed under different metadata. Use
deduplicationor a sink uniqueness constraint if that matters.
File / object encryption at rest
To store data compressed and encrypted at rest, use the file / object_store endpoints’
own compression + encryption fields — they apply compress-then-encrypt per batch, which
is what you want (ciphertext does not compress):
output:
file:
path: "data.enc"
format: raw
compression: lz4 # none | gzip | lz4 | zstd (`compression` feature)
encryption: { key: "${env:MQB_ENC_KEY}" }
Do not stack the
encryptionmiddleware on top of a sink’s batchcompressionon the same route — ciphertext won’t compress. Use the endpoint fields above instead.
An encrypted file is written as length-prefixed sealed frames (one per batch) and is only
readable through a matching consumer. object_store derives its object extension from these
fields (e.g. .jsonl.gz / .jsonl.lz4, plus a trailing .enc when encryption is on).
Reading it back
A file source must declare the same compression/encryption the data was written
with. A mismatch (wrong key, wrong codec, or a missing field) is a permanent decode failure — the
route ends failed with the error in its status, rather than completing as if the file were
empty. See Troubleshooting
and the Compression recipe.
Secrets & interpolation
Keep credentials out of committed config, and template message bodies from request data. There
are two related interpolation systems: config-value env references (resolved when the config
loads) and ${namespace:selector} message templating (resolved per message, in static
bodies and a few endpoint fields).
Config values from the environment
Reference environment variables anywhere in JSON/YAML/UI values with
${ENV_VARIABLE_NAME:-default_if_not_found}:
orders_out:
output:
kafka:
topic: "orders"
url: "${KAFKA_URL:-localhost:9092}"
# a password sourced from the environment, never committed
sasl_password: "${KAFKA_PASSWORD}"
For local development, drop a .env file in the working directory — it is loaded automatically.
In containers/Kubernetes, set the vars in the environment (and override any config field with
MQB__{ROUTE}__{PATH}; see Configuration grammar).
This is the recommended way to keep secrets out of source — see the
deploying security checklist.
Encryption keys from the environment
The encryption middleware and the file endpoints read their key with the
${env:VAR} form:
- encryption: { key: "${env:MQB_ENC_KEY}" }
Message templating with ${namespace:selector}
The static endpoint’s body is a template compiled once at
startup. Tokens use the ${namespace:selector} form:
| Token | Resolves to |
|---|---|
${payload:a.b.c} | a field of the incoming JSON payload (dotted path; array indices allowed) |
${metadata:key} | a metadata value |
${message:id} | the message id (UUID string) |
${gen:uuid} | a fresh UUID v7 |
${gen:now} / ${gen:timestamp} | current time (RFC3339 UTC / Unix epoch ms) |
${gen:counter} | a per-endpoint counter, starting at 0 |
${gen:random(1,100)} | a random integer in [min, max] |
${env:VAR} | an environment variable, resolved once at startup |
output:
static:
body: '{"error":"not found","id":"${message:id}","at":"${gen:now}"}'
raw: true
metadata: { content-type: "application/json" }
payload/metadata/messageread the request, so they’re the useful ones on an output (e.g. an error reply echoing the request); on an input (a load-test source) onlygen/envproduce values.- When the body’s
content-typeis a JSON type, interpolated request values are JSON-escaped by default so external data can’t break the structure — append| rawto a token to splice it verbatim. - To emit a literal
${…}, write$${…}. Any${…}with an unknown namespace is left untouched.
SQL / query token mapping
The sqlx insert_query supports the same request-binding tokens so a typed value goes into the
statement: ${payload:field} binds a typed JSON value, ${metadata:key} binds a metadata
string. See Upserts & insert-if-absent.
Compression
There are two kinds of compression, both behind the compression feature:
- the batch
compressionfield on thefile/object_storeendpoints — compresses whole write batches, so the output stays readable withzcat/lz4 -d; - the
compressionmiddleware — compresses each message payload, so it works over any transport (Kafka, NATS, HTTP, …), not just files.
Endpoint batch compression
The file and object_store endpoints can compress each batch on write with the
compression field:
output:
file:
path: "data.jsonl"
format: json
compression: lz4 # none | gzip | lz4 | zstd
| Codec | Notes |
|---|---|
none | default — no compression |
gzip | widest compatibility; standard .gz stream |
lz4 | fastest; standard .lz4 stream |
zstd | best ratio for the CPU cost |
object_store derives its default object extension from the codec (e.g. .jsonl.gz /
.jsonl.lz4).
Reading it back
A file source must declare the same compression the data was written with:
input:
file:
path: "data.jsonl"
format: json
compression: lz4
A mismatch — wrong codec, or a missing field — is a permanent decode failure: the route
ends failed with the error in its status rather than silently completing as if the
file were empty. Reading a compressed file with no compression set is likewise
rejected up front by sniffing the leading magic bytes, so raw compressed bytes are never
emitted as messages.
File compression supports only the default consume mode. csv works too: the header
row is written into the first member, so the decoded stream is a normal CSV file.
The compression middleware
To compress payloads over the wire — a Kafka topic, a NATS subject, an HTTP body — attach
the compression middleware instead. It compresses each
message payload on the output side and decompresses it on the input side; metadata and routing
keys are untouched. It works on input and output:
orders_bridge:
input:
middlewares:
- compression: { algorithm: zstd }
kafka: { topic: "orders", url: "localhost:9092" }
output:
middlewares:
- compression: { algorithm: zstd }
nats: { subject: "orders.out", url: "nats://localhost:4222" }
| Field | Default | Notes |
|---|---|---|
algorithm | zstd | none | gzip | lz4 | zstd; none is a passthrough |
max_decompressed_bytes | unset | consumer-side bomb guard; exceeding it is a permanent error |
Put the same algorithm on both sides of a route. Each payload is framed independently, so
unlike the endpoint field this is only readable through a matching consumer — a truncated or
corrupt frame is a permanent consumer error rather than an endlessly re-read poison message.
From the copy CLI it is an inline middleware like any other:
mqb copy \
--from 'nats://localhost:4222?stream=orders&subject=orders.in|compression?algorithm=zstd' \
--to 'file:///tmp/orders.jsonl'
A compressed payload is binary, so a file/object_store sink that stores it must use
format=normal. With format=json or text the payload is rendered as a JSON value and cannot
be read back. Verified round trip:
mqb copy --drain \
--from 'file:///tmp/in.jsonl?format=json' \
--to 'file:///tmp/packed.bin?format=normal|compression?algorithm=zstd'
mqb copy --drain \
--from 'file:///tmp/packed.bin?format=normal|compression?algorithm=zstd' \
--to 'file:///tmp/out.jsonl?format=json'
Compression and encryption
Do not stack the encryption middleware on top of a sink’s batch
compression on the same route — ciphertext does not compress. For compressed and
encrypted data at rest, use the endpoints’ own fields, which apply
compress-then-encrypt per batch:
output:
file:
path: "data.enc"
format: raw
compression: lz4
encryption: { key: "${env:MQB_ENC_KEY}" }
An encrypted file is written as length-prefixed sealed frames (one per batch) and is
only readable through a matching consumer; object_store adds a trailing .enc since
the object is ciphertext, not a directly decompressible .gz.
See also
- Encryption at rest — the encryption side and key handling.
- Middleware & structural endpoints — the authoritative
compression/encryptionfield docs. - Performance tuning → Compression & encryption cost.
Endpoints (connectors)
An endpoint is a source (consumer) or sink (publisher) for messages. Every endpoint —
whatever the underlying protocol — is driven behind the same receive_batch / send_batch
shape, so any source can feed any sink. In config, an endpoint is a single-key object naming
the connector:
input: { kafka: { url: "localhost:9092", topic: "orders" } }
output: { mongodb: { url: "mongodb://localhost:27017", database: "app", collection: "orders" } }
This page catalogs the transport endpoints and their behaviour. For per-connector query
parameters used by copy (name, type, default, required, description), see the generated
URL parameter reference
and the hand-written connector pages.
Structural endpoints (ref, fanout, switch, response, …) are documented separately in
Structural endpoints.
Supported transports
Kafka, NATS, AMQP (RabbitMQ), MQTT, MongoDB, Postgres CDC (logical replication),
PostgreSQL / MySQL / SQLite / MariaDB (SQLx), ClickHouse, HTTP, WebSocket, gRPC, ZeroMQ,
Redis Streams, AWS SQS/SNS, cloud object storage (S3 / GCS / Azure), IBM MQ, files, and
in-memory channels (memory, in-process and cross-process IPC).
Consumer vs. subscriber, and nack support
Endpoints default to a Consumer pattern (a queue: messages are distributed among workers). To get Subscriber (pub/sub) behaviour, per-backend config is required.
| Backend | Subscriber Config (Pub/Sub) | Request-Reply | Nack Support |
|---|---|---|---|
| AMQP | Set subscribe_mode: true | Emulated (Property) | Yes (Basic.nack) |
| AWS | N/A (Use SNS) | No | Yes (Visibility Timeout) |
| File | Set mode: subscribe | No | Simulated (In-Memory) |
| gRPC | N/A | No | No |
| HTTP | N/A | Native (Implicit) | Yes (HTTP 500) |
| IBM MQ | Set topic | No | Yes (Tx Rollback) |
| Kafka | Omit group_id | Emulated (Header) | Eventual (Skip Offset) |
| Memory (in-process) | Set subscribe_mode: true | Emulated (Metadata) | Yes (Re-queue), by default disabled |
Memory (IPC: ipc://, unix://, pipe://) | Not supported | Not supported | Yes (Re-queue), by default enabled, consumer-local |
| MongoDB | Set consume: capture_new (replica set) | Emulated (Metadata) | Yes (Unlock) |
| MQTT | Set clean_session: true | Emulated (Property) | Eventual (Skip Ack) |
| NATS | Set subscriber_mode: true | Native (Inbox) | Yes (JetStream Nak) |
| Postgres CDC | N/A (streams committed changes) | No | Yes (confirmed LSN not advanced) |
| Redis Streams | Set subscriber_mode: true | No | Eventual (PEL, un-acked) |
| Sled | Set delete_after_read: false | No | Yes (Tx Rollback) |
| SQLx | Not supported | No | Eventual (Skip Delete) |
| WebSocket | N/A | No | No |
| ZeroMQ | Set socket_type: "sub" | Native (REQ/REP) | No |
- Request-Reply — Native uses protocol-level correlation (HTTP connection, NATS reply
subject). Emulated publishes a new message to a reply destination (the
reply_tometadata field) carrying acorrelation_id. - Nack — Yes is explicit negative acknowledgement triggering redelivery. Eventual means redelivery depends on timeout or a connection drop. Simulated is handled in-memory. Consumer-local means the nacked message is retried inside the consumer process and is lost if that process dies — the producer is never notified.
Database sources: change capture vs. polling
Databases have no native pub/sub, so a database source is read one of two ways:
- Change Data Capture (CDC) tails the database’s own change log — inserts and
updates/deletes — and resumes from a durable log position.
- PostgreSQL — the
postgres_cdcendpoint streams a logical-replication slot (pgoutput). It emits flat JSON rows tagged withpostgres.operationmetadata (insert/update/delete/truncate). Acking a batch confirms the LSN back to the server; a nack or interrupted run does not advance the confirmed LSN, so replication resumes from the last acknowledged position — at-least-once. Enable with thepostgres-cdcfeature; requireswal_level = logicaland a publication. See the Postgres CDC → JSONL tutorial. - MongoDB —
consume: capture_all(the default) reads existing documents first and then keeps capturing (no gap, at-least-once);consume: capture_newwatches an existing collection for changes from now on. Both emitinsert/update/replace/deletetagged withmongodb.operationand checkpoint undercursor_id. Change streams need a replica set; without one, useconsume: snapshotfor a one-shot read.
- PostgreSQL — the
- Cursor polling pages an existing table by a monotonic
cursor_column(WHERE col > $last ORDER BY col ASC), persisting the last read value undercursor_id. Captures appends only — updates and deletes are not observed. Available on SQLx (PostgreSQL / MySQL / MariaDB / SQLite) and ClickHouse; the poll interval backs off exponentially betweenpolling_interval_msandmax_polling_interval_ms. SQLite and ClickHouse are polling-only.
SQLx read mode is chosen by config, not by driver. With no
cursor_column, an SQLx source is a competing-consumers work queue: it atomically claims rows via alocked_untillease and deletes them on ack. This requires the table to carry the queue schema — anid, apayload, and alocked_untilcolumn — on every driver; it is not a plain full-table read. To read an arbitrary table non-destructively (the ETL path), setcursor_columnto switch to cursor polling. A source table missinglocked_untilfails fast with a permanent error rather than reconnecting forever.
MongoDB consume modes
consume | mechanism | modifies source | ends on drain | needs a replica set | use for |
|---|---|---|---|---|---|
capture_all (default) | _id snapshot, then change stream | no | yes — when the stream goes quiet | yes | bulk read / ETL |
capture_new | change stream, new changes only | no | no — continuous by nature | yes | ongoing CDC |
snapshot | pages the collection by _id, one shot | no | yes | no | reading a standalone mongod |
consumer | claim → lock → re-fetch → delete | yes | yes | no | work queues, competing readers |
These are not interchangeable. consumer buys exclusivity via four round trips per batch and
deletes what it reads, so it is for work queues, not for reading a collection; where you
need a single-reader bulk read or ETL pass, the default capture_all is roughly 5x faster
(500k docs: consumer → null 23,667 rows/s vs. capture_all → jsonl 120,308 rows/s). Note
concurrency does not speed up a MongoDB source; batches are fetched serially.
Both capture_* modes read the oplog and therefore require a replica set — a single-node
one is enough — and refuse to start without one. On a standalone mongod, use snapshot for a
one-shot non-destructive read. snapshot delivers what exists when the run starts and is not a
tail: it pages by _id, which is assigned client-side and so does not follow commit order, so
it rejects cursor_id rather than resuming above a stored _id and silently skipping whatever
a concurrent writer commits below that mark. Incremental reads need commit order — i.e. the
oplog, i.e. a replica set.
Removed in 0.4.0:
consume: subscriber. It polledseq > last_seqand advanced its watermark to the highest seq it had seen, so a batch whose seq block was reserved first but committed second was skipped permanently — silent loss, with no error and no gap in the delivery count. Usecapture_newfor ephemeral fan-out (it now reads arbitrary collections, not only documents written by the bridge’s own publisher). The deprecatedchange_stream: trueboolean resolves tocapture_new.
Cloud object storage (S3 / GCS / Azure)
The object_store endpoint (alias s3) reads and writes Amazon S3, Google Cloud Storage,
Azure Blob, Cloudflare R2, and anything else the object_store crate speaks. Enable with the
object-store feature. Credentials and backend options come from the environment
(AWS_ACCESS_KEY_ID, AWS_REGION, GOOGLE_SERVICE_ACCOUNT, AZURE_STORAGE_ACCOUNT, …); the
URL scheme picks the backend (s3://, gs://, az://).
- As a sink, each flushed batch is encoded with the file formats (
normalJSONL,json,text,raw) and written as one immutable object at<prefix>/[YYYY/MM/DD/]<uuidv7>.<ext>. Objects are write-once. - As a source, objects under the prefix are listed in key order, fetched whole, split on
the delimiter, and emitted. Progress is a durable cursor holding the last fully-acked object
key: set
cursor_idand an externalcheckpoint_store(file://,s3://,postgres://,mongodb://) so a restart resumes. Objects are never deleted or rewritten.
archive_to_s3:
input: { memory: { topic: "events" } }
output: { object_store: { url: "s3://my-bucket/events", format: normal } }
replay_from_s3:
input:
object_store:
url: "s3://my-bucket/events"
cursor_id: "replayer-1"
checkpoint_store: "file:///var/lib/mqb/s3-cursor.json"
output: { nats: { subject: "events.replay", url: "nats://localhost:4222" } }
Point
checkpoint_storeat a different bucket/prefix than the source reads; a cursor object under the source prefix would be listed and re-read as data.
File and object formats
Both file and object_store share the encodings: normal/json/text write the
{message_id, payload, metadata} wrapper (the message id survives the round trip); raw
writes payloads verbatim (bare documents, no wrapper). csv is supported as a source (and on
file sinks). The file endpoints also carry their own compression and encryption fields —
see the Compression and
Encryption at rest recipes.
Memory endpoint and IPC
The memory endpoint is both an in-process channel and a cross-process IPC transport over
Unix domain sockets / Windows named pipes. Its topic field (alias url) doubles as a
transport URL. See the Cross-process IPC bridge tutorial and
Learn the architecture
for the URL schemes, roles (consumer is server, publisher is client), wire format and
backpressure.
IBM MQ
IBM MQ is included in the full feature set via the ibm-mq feature, which loads the IBM MQ
client library at runtime via dlopen — no IBM SDK is needed to build. The redistributable
client only has to be present at runtime, and only if you actually use an IBM MQ endpoint (it
is loaded lazily on first connect). The loader finds the client via the platform default name,
MQ_INSTALLATION_PATH (e.g. /opt/mqm), or an explicit MQB_IBM_MQ_LIB path. To link
statically at build time, use ibm-mq-static (requires the IBM MQ SDK). IBM MQ has its own
TLS shape (IbmTlsConfig): tls.cert_file (alias key_repository) is a CMS key repository
path, not a PEM file. See the
IBM MQ setup guide.
Connection sharing
Publishers that target the same server reuse one underlying transport client by default,
consolidating TCP connections, background threads, and batching. Sharing applies to Kafka,
NATS, MongoDB, SQLx, HTTP, and gRPC; the client is keyed by connection-level settings (URL,
auth, TLS, client options), never by topic/subject/collection. Set shared: false on a
publisher to give it a dedicated connection — see
Configuration grammar and
Performance tuning.
Idempotent writes
For ETL, at-least-once delivery plus an idempotent write gives effective exactly-once. The
most robust place to enforce this is the sink’s own unique constraint. See the
Upserts & insert-if-absent and
Deduplication recipes for the per-sink mechanisms
(id_field on MongoDB, ON CONFLICT/ON DUPLICATE KEY on SQL, ReplacingMergeTree on
ClickHouse, deterministic message_id + postgres.key on Postgres CDC).
Delivery guarantees
What mq-bridge promises about duplicates and loss, what it needs from you, and which
source/sink combinations give you which guarantee.
Short version.
mq-bridgeis at-least-once. A message is acked only after the output chain reports success, so nothing is lost on a crash — but a crash between the write and the ack replays the message. Combine at-least-once delivery with an idempotent write at the sink and you get effective exactly-once: the record lands once no matter how many times it is delivered. Everything below is about how to arrange that.
Enabling effective exactly-once
There is no global exactly_once switch. The guarantee follows from ordinary endpoint
configuration: provide a replay-stable identity when the source does not already have one, then
configure an idempotent sink write. At startup, mq-bridge inspects the route and reports the
inferred guarantee as effectively-once or at-least-once; it does not silently change how the
sink writes data.
| Sink | Configuration recognised as effectively-once |
|---|---|
| MongoDB | id_field set to a payload field or replay-stable template such as ${metadata:mqb.id} |
| PostgreSQL / SQLite | sqlx.insert_query uses a unique key with ON CONFLICT |
| MySQL / MariaDB | sqlx.insert_query uses a unique key with ON DUPLICATE KEY |
| File / object store | name_by: source_position (the object_store default over a replayable Kafka, Postgres CDC, SQL cursor, MongoDB CDC or consume-mode file source). Needs positions that repeat across runs — a file source in subscribe or group_subscribe mode stamps a per-run epoch, so its names never collide and a replay is not recognised |
For a source without stable identity, derive one once and consume it at the sink:
input:
middlewares:
- id: "${payload:order_id}" # writes metadata mqb.id
file: { path: "orders.jsonl" }
output:
sqlx:
url: "sqlite://orders.db"
table: orders
insert_query: >
INSERT INTO orders (id, body)
VALUES (${metadata:mqb.id}, ${payload:body})
ON CONFLICT (id) DO NOTHING
The target column must actually have a PRIMARY KEY or UNIQUE constraint. DO NOTHING gives
insert-once semantics; an appropriate DO UPDATE clause gives convergent upsert semantics.
What exactly-once actually requires
It is four separate properties, and they fail independently:
- Deterministic identity — a key for the record that is the same on every replay. Without this, nothing downstream can recognise a duplicate.
- An idempotent (or transactional) write — the sink must absorb a repeat of the same key.
- A durable source position tied to the write — so recovery resumes at the right place.
- Fencing — a restarted or duplicated instance must not race the old one.
mq-bridge gives you 1 and 2 across most of the matrix, 3 for two sources, and does not
implement 4. See What is not provided.
Sources: what identity you get
message_id is a u128. When the source can derive it from the record it carries, it is stable
across replay and usable as a deduplication key. When it cannot, it defaults to a fresh
fast_uuid_v7 value per read — which identifies this delivery, not that record, and is
therefore useless for dedup across a restart.
| Source | message_id stable across replay? | Replayable position (mqb.src.*)? |
|---|---|---|
kafka | Yes — mq_bridge.message_id header, else a 16-byte key, else partition<<64 | offset | Yes — topic/partition/offset |
postgres_cdc (and sqlx with publication) | Yes — hash of schema.table + replica key + commit LSN | Yes — slot/LSN/ordinal |
nats | JetStream: yes (stream sequence). Core NATS: only if the producer set Nats-Msg-Id | No |
mongodb (consumer/subscriber) | Yes — the stored document _id | No |
mongodb (capture_new/capture_all) | No — fresh id per read | No |
amqp | Only if the producer set the AMQP message_id property; the delivery_tag fallback resets per channel | No |
redis_streams | Only if the producer wrote a mq_bridge.message_id field; the entry ID is not used | No |
http / websocket / grpc | From the request when it carries an id | No (request/reply, not replay) |
mqtt, aws (SQS), zeromq, ibm_mq, file, object_store, clickhouse | No — fresh id per read | No |
Two consequences worth internalising:
- A
fileorobject_storesource cannot deduplicate onmessage_id. Re-reading the same file produces entirely new ids. Derive a business key instead — see Giving a source an identity. mq-bridgewritesmq_bridge.message_idon the sink side for Kafka, NATS and Redis Streams, so amq-bridge → broker → mq-bridgehop preserves identity end to end even when the broker itself has no id concept.
Giving a source an identity
For the sources in the bottom rows, the id middleware derives one from the message itself and
stores it in the mqb.id metadata key:
input:
middlewares:
- id: "${payload:order_id}"
file: { path: "orders.jsonl" }
Re-reading the same record now yields the same mqb.id, even though each read mints a fresh
message_id. Three properties make it the right carrier:
- It is a string, so it holds the business key in its original form.
message_idis au128and could only hold a hash of it — which a sink cannot then use as a readable_id. - It propagates.
mqb.idsits deliberately outside themqb.src.*namespace, so publishers do not strip it: an identity describes the record, not the hop it arrived on. Kafka already forwards every non-mqb.src.*metadata entry as a header, so it survives that hop today. - It is opt-in. No
identry means no wrapper and no cost.
Once set, read it anywhere metadata is available — ${metadata:mqb.id} in a deduplication
key, a Kafka partition_key, or a handler.
Watch the ordering. Consumer middlewares wrap in reverse, so the entry closest to the end
of the list touches an incoming message first. Anything reading mqb.id must be listed
before the id that produces it:
input:
middlewares:
- deduplication: { store: "sled:///var/lib/mqb/dedup", ttl_seconds: 3600, key: "${metadata:mqb.id}" }
- id: "${payload:order_id}"
file: { path: "orders.jsonl" }
Reversed, mqb.id is still unset when deduplication reads it, and dedup silently falls back to
message_id.
Identity is not a version. mqb.id answers “which record is this”, not “which revision”.
Do not point a CDC route’s dedup key at it: every update to a row shares one business key, so
they would collapse into one. That is why postgres_cdc builds its message_id from
schema.table + key + **lsn** — dedup on a change stream needs identity and version, and
should keep defaulting to message_id. Use mqb.id for sink keying and correlation.
id is input-only; on an output it is a startup error rather than a silent no-op. The key is set
only when every selector in the template resolves — a partial render such as "acme-" would hand
one identity to every message missing the field, so it is dropped instead.
Sinks: how a duplicate write is absorbed
| Sink | Mechanism | How to enable |
|---|---|---|
mongodb | Unique _id index; dup-key (11000) is treated as an idempotent success | id_field |
sqlx (PostgreSQL / MySQL / SQLite) | The table’s own UNIQUE/PRIMARY KEY | ON CONFLICT / ON DUPLICATE KEY in insert_query |
clickhouse | ReplacingMergeTree collapses at merge time | Table DDL — no mq-bridge config |
file, object_store | Deterministic, sortable part names + covered-range recovery | name_by: source_position (needs a source that reproduces the same positions on a re-read — a file source only in consume mode; the object_store default under auto) |
kafka | enable.idempotence dedups producer retries within one session — this is not exactly-once semantics | On by default |
nats, amqp, mqtt, redis_streams, aws, ibm_mq, zeromq | None | Deduplicate at the next consumer instead |
Picking a combination
Any source → a database sink. The easy case. You do not need a replayable source position at
all — the sink’s unique constraint is already shared across every writer and is the authority. Give
it a deterministic key (id_field, or a ON CONFLICT column) and you are done.
Any source → files or object storage. A filesystem has no unique constraint, so this route
needs a replayable source position and therefore works only from kafka or postgres_cdc. See
Files & object storage.
A source → a broker sink (Kafka, NATS, MQTT, …). The sink cannot deduplicate. Either filter
before it with the deduplication middleware, or accept
at-least-once and make the downstream consumer idempotent.
A route with a handler. Sink-side idempotency happens after the handler runs. If that matters, see Handlers.
Deduplication & idempotent writes
For ETL, at-least-once delivery plus an idempotent write gives you effective exactly-once: a replayed or retried record must not create a duplicate row. The most robust place to enforce this is the sink database’s own unique constraint — it is already shared across every writer, so no extra state store is needed. Both database sinks lean on this instead of an application-side cache.
MongoDB — id_field
Point id_field at a top-level payload field and its value becomes the document _id. Alternatively,
use the template form id_field: "${metadata:mqb.id}" to consume an identity produced by the input id middleware. Re-inserting
the same business key then hits the unique _id index and is treated as an idempotent success (the
duplicate is skipped, not errored):
orders_to_mongo:
input: { kafka: { topic: "orders", url: "localhost:9092" } }
output:
mongodb:
url: "mongodb://localhost:27017"
database: "shop"
collection: "orders"
format: json
id_field: "order_id" # payload {"order_id": "A-1", ...} → _id = "A-1"
The equivalent configuration using the shared identity carrier is:
input:
middlewares:
- id: "${payload:order_id}"
output:
mongodb:
# ... connection and collection ...
id_field: "${metadata:mqb.id}"
A plain id_field preserves the payload value’s BSON type; the template form renders a string and
accepts only replay-stable payload or metadata tokens.
The field’s JSON type is preserved (a number stays a BSON integer). The payload must be JSON and
contain the field, otherwise the message is dead-lettered rather than written with a random _id —
silently minting one would defeat deduplication. Use id_field on sink collections only: a
business-key _id is not compatible with the consumer/subscriber competing-consumer modes, which
require a UUID _id.
SQL (sqlx) — ON CONFLICT / ON DUPLICATE KEY
The insert_query is user-supplied, so you write the dialect’s upsert directly. This requires a
pre-existing UNIQUE/PRIMARY KEY on the key column, and is incompatible with bulk_copy (COPY
cannot express ON CONFLICT) — so you trade peak throughput for deduplication.
# PostgreSQL — insert if absent (drop duplicates):
insert_query: "INSERT INTO orders (id, body) VALUES (${payload:id}, ${payload:body}) ON CONFLICT (id) DO NOTHING"
# PostgreSQL — upsert (last write wins):
insert_query: "INSERT INTO orders (id, body) VALUES (${payload:id}, ${payload:body}) ON CONFLICT (id) DO UPDATE SET body = EXCLUDED.body"
# MySQL / MariaDB:
insert_query: "INSERT INTO orders (id, body) VALUES (${payload:id}, ${payload:body}) ON DUPLICATE KEY UPDATE body = VALUES(body)"
# SQLite:
insert_query: "INSERT INTO orders (id, body) VALUES (${payload:id}, ${payload:body}) ON CONFLICT (id) DO NOTHING"
A plain INSERT without a conflict clause instead fails the row as a non-retryable error: a
configured dlq captures it, and without one it is logged and dropped. Add the conflict clause
when replays are expected. ${payload:field} binds a typed value from the JSON payload;
${metadata:key} binds a metadata string.
ClickHouse — ReplacingMergeTree
ClickHouse has no unique constraints; dedup is a table-engine property. Create the target as
ReplacingMergeTree(version) keyed by your business key via ORDER BY, using a monotonic column as
the version (e.g. an ingest timestamp, or postgres.lsn from a CDC source). ClickHouse collapses
rows with the same sort key at merge time, keeping the highest version; read with FINAL (or
argMax) to see the deduplicated result:
CREATE TABLE orders (id UInt64, body String, version UInt64)
ENGINE = ReplacingMergeTree(version) ORDER BY id;
-- mq-bridge just inserts rows; duplicates for the same id collapse on merge.
SELECT * FROM orders FINAL;
ReplacingMergeTree deduplicates by business key at merge time. Separately, ClickHouse also dedupes
identical re-inserted blocks natively: a retried send_batch that resends the same block is dropped
server-side (default one-hour window) — insert_deduplication_token lets you make that explicit, but
mq-bridge does not set one, so rely on ReplacingMergeTree for logical dedup and treat block-level
dedup only as retry-safety.
Postgres CDC — deterministic id + postgres.key
The postgres_cdc source resumes from the slot’s durable confirmed_flush_lsn. In-band standby
feedback is asynchronous and is not flushed when the stream stops, so the last acks are made durable
by the consumer’s Drop, which stops the stream and advances the slot synchronously. Re-delivery is
therefore avoided only on a restart that actually runs that teardown — a host process that exits
without dropping the route (or one on a current-thread Tokio runtime, where the blocking advance is
skipped) replays everything since the last asynchronous feedback tick. Set checkpoint_store to a
file:// path for a second, per-ack durable position that survives teardown regardless. Treat the
source as at-least-once and make the sink idempotent.
Each change event carries the full row (so the primary key is in the payload), postgres.lsn (a
monotonic version), postgres.operation/schema/table, and — when the table has a primary key /
replica identity — postgres.key (the key value). The event’s message_id is a stable hash of
schema.table + key + lsn, so a replayed change deduplicates through the deduplication middleware,
and Mongo id_field or a SQL ON CONFLICT on the key column make the sink write idempotent. Use
postgres.lsn as the version to drop stale replays
(... DO UPDATE ... WHERE excluded.lsn > orders.lsn).
Known edge: if the same primary key is changed twice within a single transaction, both events
share that transaction’s commit LSN, so they produce the same message_id. The deduplication
middleware then treats the second as a duplicate and drops it. The sink still converges to the final
row state, but the intermediate change is not delivered — if you need every intra-txn revision, do not
rely on the message_id/middleware path for those rows.
The deduplication middleware
The middleware is a complement, not a replacement: it filters duplicates before the sink, and it is consumer-only (configuring it on an output is a startup error). Prefer the sink constraint for multi-writer ETL; reach for the middleware when the sink has no constraint to lean on, or when you need to keep duplicates away from a handler.
Two things decide whether it actually works:
key. The default keys onmessage_id, which most sources mint fresh per read (see the source table) — the route then looks configured and dedupes nothing. Setkeyto a business key, either directly ("${payload:order_id}") or via"${metadata:mqb.id}"when anidmiddleware already derived one. An unresolvable template also falls back tomessage_idwith only a warning, so a typo fails silently.store.sledis single-instance. Point it at a shared MongoDB or SQL deployment for anything scaled out.
input:
middlewares:
- deduplication:
store: "mongodb://localhost:27017/shop"
ttl_seconds: 86400
key: "${payload:order_id}"
kafka: { topic: "orders", url: "localhost:9092" }
middlewares sits beside the endpoint type, not nested inside it. Requires the dedup feature.
MongoDB — branch on insert vs. duplicate (report_outcome)
Sometimes you need to act on whether a record was newly inserted or already existed — enrich only
fresh rows, or reply with the existing entry for duplicates. Set report_outcome: true and the Mongo
publisher returns the message tagged with metadata mongodb.outcome = inserted (fresh write) or
existed (dup-key on the unique _id). Wrap it in a request endpoint to forward that tagged
message into a switch that routes on mongodb.outcome:
orders_upsert_branch:
input: { kafka: { topic: "orders", url: "localhost:9092" } }
output:
request: # calls `to`, forwards its response to `forward_to`
to:
mongodb:
url: "mongodb://localhost:27017"
database: "shop"
collection: "orders"
format: json
id_field: "order_id" # deterministic _id → insert-if-absent
report_outcome: true # → mongodb.outcome = inserted | existed
forward_to:
switch:
metadata_key: "mongodb.outcome"
cases:
inserted: { ref: "enrich_new_order" } # e.g. build entry X, reply
existed: { ref: "handle_duplicate" } # e.g. reply with parts of X
default: { file: { path: "orders-unrouted.jsonl" } }
report_outcome is sink-only and pairs with id_field; without a deterministic _id there is no
duplicate to detect. Do not also set request_reply: true — that switches the publisher to the
reply-polling path, which never reports an outcome and times out with nothing answering. Left
unwrapped by request, the tagged message is returned as the route’s response as usual.
Keep the default arm: when the Mongo send errors, request forwards the original message,
which carries no mongodb.outcome, and a switch with no default drops unmatched messages.
The outcome is only truthful on the first attempt. If the process dies after the insert committed
but before the branch’s downstream send committed, the replay hits a duplicate key, Mongo answers
existed, and a genuinely-new record takes the duplicate branch. Write the existed branch as
“may or may not have been handled — check and repair”, not “definitely already done”.
Files & object storage — name_by
Check first whether you need any of this. If your records already carry a business key — an
idfield in the payload, ormqb.idfrom theidmiddleware — then a key-addressed sink (Mongoid_field, SQLON CONFLICT) already gives you effective exactly-once, and the order objects happen to land in does not matter. Positional naming below is for the case where the sink itself has to recognise a replay, or where a downstream reader depends on replay order. Turning it on when you do not need it only adds restrictions.
A filesystem has no unique constraint, so the file and object_store sinks get replay safety
from the name they write under — which works only as far as the source repeats its positions:
a re-read that numbers the same records differently produces different names, and different names
are not a replay to anything downstream. name_by picks the scheme:
name_by | Name | Consequence |
|---|---|---|
write_time | <uuidv7>.<ext>, optionally under YYYY/MM/DD/ | unique per write; sorts by write order |
source_position | part-<topic>-<partition>-<start>-<end>.<ext>, zero-padded, flat | repeats exactly where the source repeats its positions, making a re-write a no-op; sorts by source order |
auto (default) | source_position where the input carries a replay position, else write_time | see below |
Under source_position the sink groups each batch into runs of consecutive source positions and
writes one immutable part per run, named for the range it covers:
kafka_to_s3:
input:
kafka: { topic: "orders", url: "localhost:9092", source_metadata: true }
output:
object_store:
url: "s3://my-bucket/orders"
name_by: source_position # → part-orders-<partition>-<start>-<end>.jsonl (zero-padded)
auto applies to object_store only. Either scheme leaves that sink writing whole immutable
objects — write_time one per flushed batch, source_position one per contiguous run within it —
so only the name and the grouping change, and deriving them is safe. The file sink is different: source_position turns
path from a file into a directory of part files, which is a change of structure rather than of
name, so a file sink stays on write_time until you ask for parts explicitly.
All of this belongs to the source_position path. Before its first write such a sink lists the
prefix once and parses the ranges out of the part names already there, so a replay skips them
without re-encoding or re-uploading. Filtering is per record, not per batch, so batch boundaries
are free to differ across restarts — that is what makes it work without a checkpoint protocol, and
it is why the listing cannot wait for a name to collide: a replay on different boundaries names
different objects and collides with nothing. A repeat that does land on the same name is caught
anyway, since the PUT uses PutMode::Create and treats AlreadyExists as success. A write_time
sink never lists — its names are unique per write, so there is nothing to recognise; on the
source_position path a fresh prefix pays one empty listing per publisher. A listing that fails is
logged rather than failing the batch, leaving same-name-only deduplication until it succeeds: a
transient failure is retried on the next batch, a denied ListObjects is not retried. Local files
stage to a temp name, fsync, then rename (atomic within a filesystem); object stores have no
atomic rename, so a single PUT under the final name is the commit.
This needs a replayable source position. Today that means:
| Source | Position | Restriction |
|---|---|---|
kafka | topic / partition / offset | — |
postgres_cdc | commit LSN + in-transaction ordinal | sqlx with publication maps onto the same consumer |
mongodb | cluster time + ordinal; the initial snapshot uses its _id scan index | capture_new / capture_all only — consumer and snapshot have no position |
file | record index within the file | all modes; only consume deduplicates across runs (see below) |
sqlx | the cursor_column value | cursor polling only; the column must be a unique, non-negative integer |
A route whose output resolves to source_position turns source_metadata on for its input
automatically; set it explicitly only when you want the mqb.src.* keys for something else. An input
from the table above is what auto looks for; anything else keeps the sink on write_time. An
explicit name_by: source_position over an input that has no position is rejected when the route
starts, because there is then no way to honour what was asked for. NATS and AMQP also accept
source_metadata and emit provenance keys, but a subject or routing key is not a replayable offset,
so they cannot drive positional naming.
Under write_time, an object_store sink names objects in write order, and key order is the
only order a bucket has. At concurrency: 1 that is source order; above it the name is minted inside
the worker pool, so it is arrival order, and replaying a change stream through the bucket can reorder
updates to the same key. For an input that carries a replay position, auto already avoids this. For
one that does not — NATS, MQTT, HTTP, gRPC, ZeroMQ, IBM MQ, Redis Streams — there is no positional
name to fall back to, and concurrency: 1 is the only remedy. A concurrent route into a
write_time object-store sink logs a warning at startup either way: over an input with a position
it points at name_by: source_position, over one without it at concurrency: 1.
Deprecated:
idempotency: true|falseis the old spelling ofname_by: source_position|write_time. It is still read, but an explicitname_bywins over it.
Numbers inside a part name are zero-padded so that ASCII sort equals numeric sort — a bucket is listed lexicographically and that listing order is the replay order. A bucket written by a version before this padding contains unpadded names; the two forms do not sort correctly against each other, so do not mix them in one prefix.
MongoDB capture_all reads the collection, then streams changes. Both phases are numbered into
the key so the snapshot always sorts ahead of the changes; a change can never replay before the
document it modifies. The snapshot does not resume across runs — it re-scans from the start — but the
numbering is deterministic, so a restart reproduces the same names and the covered-range recovery
skips them. The exception is inserts or deletes landing in the collection between the two runs: those
shift the numbering, and some documents are then written twice.
The sqlx cursor source uses the cursor_column value itself as the position — the same shape
Kafka Connect’s S3 sink gives a Kafka offset. That only works for a unique, non-negative integer
column: a text cursor pages fine but has no contiguous numeric order, and a repeated value would
resolve two rows to one position and drop one of them. The reader rejects both at read time rather
than naming records wrongly. Consecutive ids coalesce into one part file; gaps split it.
The file source numbers records by their index in the file, not their byte offset (byte offsets
are not consecutive, so every record would become its own part).
That index repeats across runs only in consume mode, which always reads from byte 0 — and repeating
is the point: a re-read produces the same names, so the sink recognises them as already covered and
writes nothing. subscribe starts at the current end of the file and group_subscribe resumes at a
stored byte offset, so their index restarts at 0 over records an earlier run already numbered. Those
two modes therefore carry a run epoch in the object name.
The epoch keeps names distinct and keeps runs in order (a later run reads later records, so its objects sort after the earlier run’s). What it does not give you is deduplication across a restart: records re-read after a crash are written again under new names. That is ordinary at-least-once, and it is the honest guarantee for a source with no durable per-record position — these modes are allowed, not rejected, because that guarantee is fine for plenty of pipelines.
For the file sink, name_by: source_position changes what path means: it is the directory that receives
the part files, not the file that is appended to. The sink creates it on startup, so pointing it at an
existing regular file fails there with “Failed to create part-file sink directory”.
compression and encryption work as usual.
Current limits, all of which the sink rejects or logs rather than silently mishandling:
- No
csv(each part would need its own header row). date_partitionis ignored — parts are written flat under the prefix, since the name already carries the range. Logged at startup.- One part file per contiguous run per batch. There is no size-based rolling yet, so a large
backfill produces many small files (~1000 per 1M rows at
batch_size: 1024). Forpostgres_cdcthis is per-transaction, so a high-commit-rate stream produces one small file per commit. Rolling would require buffering across batches the route has already acked, which is not safe today.
Handlers
A handler is not a stage between the consumer and the publisher — it is a publisher wrapper. The order on the output side is:
handler → output middlewares → sink
So every sink-side idempotency mechanism on this page runs after the handler has already executed. For a pure ETL route that is fine. For a handler with side effects — enriching, calling an API, emitting an event — it means duplicates reach your code.
To keep duplicates away from a handler, put deduplication on the route’s input. It filters them
out of the batch before the route ever calls the publisher chain, and the duplicates are acked
straight back at the source. Set key to a business key, as above.
This is still at-least-once, deliberately. The middleware reserves a key on receive and only promotes it to “processed” when the message is acked. That in-flight reservation expires after five seconds, precisely so that a crash between reserve and commit frees the key for redelivery rather than losing the message. A crash after the handler ran but before the commit will re-run the handler.
To make a handler effectively-once, its own effect has to be idempotent:
- If the handler writes to a store with a unique key, you already have it — the write is absorbed on replay. The handler may run twice; it cannot land twice.
- If the handler makes a non-idempotent external call (charge a card, send mail),
mq-bridgehas no primitive for this today. You need an idempotency-key cache around the effect itself.
What is not provided
- Exactly-once across systems. Not achievable without a transaction spanning the source’s offset commit and the sink’s write. Nothing here claims it.
- Kafka transactional EOS.
enable.idempotenceis on, buttransactional.idandsend_offsets_to_transactionare not used, so Kafka→Kafka is not exactly-once. - Fencing on checkpoint stores. The
file/s3/mongodb/postgrescheckpoint stores have no lease, owner id or epoch. Kafka consumer groups and Postgres replication slots fence structurally; the checkpoint stores do not, so a zombie instance after a partial partition can re-emit. - A transactional outbox. When source position and sink write live in the same database, they
could be committed together —
mq-bridgehas no way to express that today.
See also
- REFERENCE.md — every middleware and structural endpoint, with fields and defaults.
- ARCHITECTURE.md — internal design and extension points.
Connectors
Hand-written per-connector docs: purpose, URL format, and practical
examples. For the full, auto-generated list of every recognised query
parameter (type, default, required, description), see the matching page
under ../reference/.
- PostgreSQL / MySQL / MariaDB / SQLite (including PostgreSQL CDC)
- ClickHouse
- MQTT
- Kafka
- RabbitMQ (AMQP)
- NATS
- Redis Streams
- HTTP
- WebSocket
- gRPC
- MongoDB
- AWS SQS / SNS
- ZeroMQ
- IBM MQ
- File (CSV / JSON / JSONL)
Every connector recognised by mqb copy now has a hand-written page.
For the full, auto-generated parameter tables see the matching page under
../reference/.
PostgreSQL / MySQL / MariaDB / SQLite
The sqlx connector reads and writes rows in a relational database table.
The same connector backs all four schemes — the URL scheme just selects the
driver. Postgres additionally has a dedicated CDC connector
for streaming change data instead of table reads.
URL format
postgres://[user:pass@]host[:port]/database?table=<name>
The scheme (postgres/postgresql, mysql, mariadb, sqlite) and
everything up to the query string is passed straight through as the driver
connection string; table (and any other recognised field below) is pulled
out of the query string into config. For SQLite, host/database are
replaced by a file path, e.g. sqlite:///var/data/app.db?table=orders.
Examples
Full-table read (source), one-shot:
mqb copy --drain \
--from postgres://user:pass@localhost/app?table=orders \
--to null:
Write with auto-created table (destination):
mqb copy --drain \
--from file:///data/orders.csv?format=csv \
--to 'postgres://user:pass@localhost/app?table=orders&auto_create_table=true'
Resumable incremental read, keyed by a monotonic column, continuous:
mqb copy \
--from 'postgres://user:pass@localhost/app?table=orders&cursor_column=id&cursor_id=orders_export' \
--to kafka://kafka.local:9092?topic=orders
Each restart resumes from the last id seen (persisted via cursor_id)
instead of re-copying from the start.
Custom multi-column insert, MySQL:
mqb copy --drain \
--from postgres://user:pass@src/app?table=orders \
--to 'mysql://user:pass@dst/app?table=orders&insert_query=INSERT+INTO+orders+%28id%2C+sku%2C+qty%29+VALUES+%28%24%7Bpayload%3Aid%7D%2C+%24%7Bpayload%3Asku%7D%2C+%24%7Bpayload%3Aqty%7D%29'
(insert_query is shown URL-encoded above — the SQL is
INSERT INTO orders (id, sku, qty) VALUES (${payload:id}, ${payload:sku}, ${payload:qty}).)
Key options
| Option | Purpose |
|---|---|
table | Required. Table to read from / write to. |
cursor_column + cursor_id | Non-destructive, resumable incremental reads instead of a one-shot full-table copy. |
checkpoint_store | (Consumer, cursor_column mode) Where to persist the resume cursor. Absent → a mqb_cursors_<table> table in the source database; a bare name reuses the source datastore with that table; a URL (file://, postgres://, mysql://, mongodb://, s3:///gs:///az:///abfs://) selects an external backend. Treated as a secret since it may embed credentials. |
auto_create_table | Publisher creates the destination table if missing. |
insert_query | Custom INSERT with ${payload:field} / ${metadata:key} tokens for multi-column writes. |
bulk_copy | PostgreSQL only — use COPY FROM STDIN for high-throughput bulk loads. |
delete_after_read | Consumer deletes rows after they’re processed (mutually exclusive with cursor_column). |
Any other query parameter (e.g. sslmode=disable) is left on the connection
URL untouched and passed to the driver as-is.
Full field list, types, and defaults: reference/postgres.md.
PostgreSQL CDC
A separate connector for streaming logical-replication changes (insert/
update/delete) instead of reading a table snapshot. Uses postgres-cdc://
(alias pgcdc://) to select the endpoint kind; the connection URL underneath
it is a plain Postgres URL.
postgres-cdc://[user:pass@]host[:port]/database?publication=<name>&slot_name=<name>
Stream changes from a publication into Kafka, continuous:
mqb copy \
--from 'postgres-cdc://user:pass@localhost/app?publication=mqb_pub&slot_name=mqb_slot' \
--to kafka://kafka.local:9092?topic=app-changes
Replicate a table into another PostgreSQL instance, continuous:
mqb copy \
--from 'postgres-cdc://user:pass@localhost/app?publication=mqb_pub&slot_name=mqb_slot' \
--to 'postgres://user:pass@otherhost/replica?table=orders&auto_create_table=true'
publication must already exist on the source (CREATE PUBLICATION mqb_pub FOR TABLE orders;); slot_name is created automatically if missing.
Full field list: reference/postgres-cdc.md.
ClickHouse
Reads from or bulk-inserts into a ClickHouse table over ClickHouse’s HTTP interface (port 8123 by default; 8443 for HTTPS).
URL format
clickhouse://host[:port]?table=<name>[&database=<name>]
clickhouse:// is rewritten to http:// (and clickhouses:// to
https://) before being handed to the ClickHouse client — the scheme only
selects the endpoint kind on the CLI.
Examples
Bulk insert from a full-table Postgres read, one-shot:
mqb copy --drain \
--from postgres://user:pass@localhost/app?table=orders \
--to 'clickhouse://localhost:8123?table=orders&database=analytics'
Async insert for high-throughput streaming writes, continuous:
mqb copy \
--from kafka://kafka.local:9092?topic=events \
--to 'clickhouse://user:pass@ch.local:8123?table=events&database=analytics&async_insert=true'
Resumable, non-destructive read of an existing table into Kafka:
mqb copy \
--from 'clickhouse://localhost:8123?table=events&database=analytics&cursor_column=id&cursor_id=events_export' \
--to kafka://kafka.local:9092?topic=events
Each restart resumes from the last id seen instead of re-reading from the
start. (Per-column mapping via columns is a map field, so it can’t be set
from a query param — use a YAML route config for that.)
Key options
| Option | Purpose |
|---|---|
table | Required. May be schema-qualified (db.table). |
database | Defaults to default. |
columns | Map target columns to ${payload:field} / ${metadata:key} tokens instead of inserting the whole JSON payload as one row. |
async_insert | Server-side buffered inserts for higher publisher throughput. |
cursor_column + cursor_id | Non-destructive, resumable reads of an existing table. |
checkpoint_store | (Consumer, cursor_column mode) Where to persist the resume cursor. ClickHouse can’t do per-row cursor upserts, so a durable checkpoint needs an external store URL: file://, postgres:///mysql://, mongodb://, or s3:///gs:///az:///abfs://. Treated as a secret since it may embed credentials. |
Full field list: reference/clickhouse.md.
MQTT
Publishes to or subscribes from an MQTT broker (v5 by default, v3 supported).
URL format
mqtt://[user:pass@]host[:port]?topic=<topic>
mqtt:// is rewritten to tcp:// (and mqtts:// to ssl://) before being
handed to the MQTT client — the scheme only selects the endpoint kind on the
CLI. MQTT topic wildcards (+, #) are supported on the source side.
Examples
Subscribe to a wildcard topic and forward to Kafka, continuous:
mqb copy \
--from mqtt://broker.local:1883?topic=sensors/+/temperature \
--to kafka://kafka.local:9092?topic=sensor-readings
Publish a file’s lines to a topic, one-shot:
mqb copy --drain \
--from file:///data/events.jsonl?format=json \
--to mqtts://user:pass@broker.local:8883?topic=events
Fixed client ID and QoS 2 for exactly-once delivery semantics:
mqb copy \
--from 'mqtt://broker.local:1883?topic=alerts&client_id=mqb-alerts-01&qos=2' \
--to null:
Key options
| Option | Purpose |
|---|---|
topic | MQTT topic (wildcards on the source side). |
client_id | Fixed client ID; auto-generated if omitted. |
qos | Quality of Service (0, 1, or 2). Defaults to 1. |
protocol | V3 or V5. Defaults to V5. |
delayed_ack | Consumer-only: ack after processing instead of on receipt (default). |
Full field list: reference/mqtt.md.
Kafka
Produces to or consumes from a Kafka topic via librdkafka.
URL format
kafka://broker[:port]?topic=<topic>
kafka:// is stripped before being handed to librdkafka’s
bootstrap.servers, which expects a bare host:port list, not a URI — the
scheme only selects the endpoint kind on the CLI. For a multi-broker
cluster, use the ?url= escape hatch (see
Quick Start):
kafka://_/?url=broker1:9092,broker2:9092&topic=orders.
Examples
Forward an MQTT stream into a Kafka topic, continuous:
mqb copy \
--from mqtt://broker.local:1883?topic=sensors/+/temperature \
--to kafka://kafka.local:9092?topic=sensor-readings
Consume with a durable consumer group, continuous:
mqb copy \
--from 'kafka://kafka.local:9092?topic=orders&group_id=mqb-orders-sync' \
--to 'postgres://user:pass@localhost/app?table=orders&auto_create_table=true'
Without group_id, the consumer runs in ephemeral subscriber mode
(unique group ID, starts from the latest offset).
SASL-authenticated broker:
mqb copy \
--from postgres://user:pass@localhost/app?table=orders \
--to 'kafka://kafka.local:9093?topic=orders&username=svc&password=secret'
TLS (an object-typed field, not a scalar) can’t be set via a query param;
use the ?url= escape hatch to pass a librdkafka connection string with TLS
options embedded.
Key options
| Option | Purpose |
|---|---|
topic | Topic to produce to or consume from. |
group_id | Consumer group ID; omit for ephemeral subscriber mode. |
username / password | SASL authentication. |
tls | TLS configuration (object; not settable via a scalar query param — use ?url= to pass driver-level TLS options). |
delayed_ack | Publisher-only: don’t wait for broker acknowledgement. |
Full field list: reference/kafka.md.
RabbitMQ (AMQP)
Publishes to or consumes from a RabbitMQ queue over the AMQP 0-9-1 protocol.
URL format
rabbitmq://[user:pass@]host[:port]/<vhost>?queue=<name>
amqp:///amqps:// (the native AMQP scheme) and rabbitmq:///
rabbitmqs:// (an alias) are both accepted; rabbitmq(s) is rewritten to
amqp(s) before being handed to the driver. The default vhost / must be
percent-encoded as %2f in the URL path, per the AMQP URI spec.
Examples
Consume a queue and forward each message to an HTTP endpoint, continuous:
mqb copy \
--from rabbitmq://guest:guest@localhost:5672/%2f?queue=orders \
--to http://internal-api.local/ingest?method=POST
Publish to an exchange instead of a default-exchange queue:
mqb copy --drain \
--from file:///data/events.jsonl?format=json \
--to 'amqp://guest:guest@localhost:5672/%2f?exchange=events&queue=events'
Fan-out subscriber mode (ephemeral queue bound to the exchange):
mqb copy \
--from 'amqp://guest:guest@localhost:5672/%2f?exchange=events&subscribe_mode=true' \
--to kafka://kafka.local:9092?topic=events
Key options
| Option | Purpose |
|---|---|
queue | Queue to consume from / publish to. |
exchange | Exchange to publish to or bind the queue to. |
subscribe_mode | Consumer-only: fan-out (ephemeral queue) instead of point-to-point. |
prefetch_count | Consumer-only: messages to prefetch. Defaults to 100. |
no_persistence | Non-durable queues / non-persistent messages. |
Full field list: reference/rabbitmq.md.
NATS
Publishes to or consumes from NATS subjects. Uses JetStream by default
(durable, acked); set no_jetstream=true for fire-and-forget Core NATS.
URL format
nats://host[:port]?subject=<subject>[&stream=<name>]
For a multi-server cluster, pass a comma-separated list via the ?url= escape
hatch: nats://_/?url=nats://n1:4222,nats://n2:4222&subject=orders. Consumers
require stream (the JetStream stream name), even with no_jetstream=true
(validated but unused there). If the bridge auto-creates a stream it is scoped
to {stream}.>, so prefix your subject accordingly.
Examples
Load a JSONL file into a JetStream subject, one-shot:
mqb copy --drain \
--from file:///data/orders.jsonl?format=json \
--to 'nats://localhost:4222?subject=orders&stream=ORDERS'
Consume a durable JetStream stream into Postgres, continuous:
mqb copy \
--from 'nats://localhost:4222?subject=orders&stream=ORDERS' \
--to 'postgres://user:pass@localhost/app?table=orders&auto_create_table=true'
Core NATS request/reply (no JetStream), continuous:
mqb copy \
--from http://0.0.0.0:8080/rpc \
--to 'nats://localhost:4222?subject=rpc.echo&no_jetstream=true&request_reply=true'
Key options
| Option | Purpose |
|---|---|
subject | Subject to publish to or subscribe to. |
stream | JetStream stream name. Required for consumers. |
no_jetstream | Use Core NATS (fire-and-forget) instead of JetStream. |
deliver_policy | Consumer-only: all (default), last, new, last_per_subject. |
subscriber_mode | Consumer-only: ephemeral subscriber instead of a durable consumer. |
request_reply + request_timeout_ms | Publisher-only request/reply pattern. |
deduplicate | Publisher-only (JetStream): send Nats-Msg-Id so JetStream dedupes redeliveries. |
username / password / token | Authentication. |
tls | TLS configuration (object; set with a JSON literal ?tls={...}). |
Full field list: reference/nats.md.
Redis Streams
Publishes to or consumes from a Redis stream (XADD / XREADGROUP).
Consumers use a consumer group by default for durable, acked delivery;
subscriber_mode=true reads ephemerally via XREAD.
URL format
redis://[user:pass@]host[:port]?stream=<key>
redis:// (plain) and rediss:// (TLS) are both accepted; redis_streams://
is an explicit alias. If stream is omitted it defaults to the route name.
Examples
Load a CSV file into a stream, one-shot:
mqb copy --drain \
--from file:///data/events.csv?format=csv \
--to redis://localhost:6379?stream=events
Consume with a durable group into ClickHouse, continuous:
mqb copy \
--from 'redis://localhost:6379?stream=events&group=analytics&consumer_name=w1' \
--to 'clickhouse://localhost:8123?table=events&database=analytics'
Unclaimed entries pending longer than redelivery_timeout_ms (default 60s)
are re-delivered via XAUTOCLAIM.
Ephemeral tail of new messages (no group, no acks), continuous:
mqb copy \
--from 'redis://localhost:6379?stream=events&subscriber_mode=true' \
--to kafka://kafka.local:9092?topic=events
Key options
| Option | Purpose |
|---|---|
stream | Stream key to publish to / read from. Defaults to the route name. |
group | Consumer-only: group name. Defaults to {APP_NAME}-{stream}. |
consumer_name | Consumer-only: consumer within the group. |
subscriber_mode | Consumer-only: ephemeral XREAD from new messages (no group/acks). |
read_from_start | Consumer-only: on group creation, start from the beginning (0) not $. |
redelivery_timeout_ms | Consumer-only: re-claim entries pending ≥ this long; 0 disables. |
reader_connections | Consumer-only: parallel XREADGROUP readers across the group. |
maxlen + approx_trim | Publisher-only: cap stream length with XADD MAXLEN. |
username / password | Authentication (Redis ACL). |
Full field list: reference/redis.md.
HTTP
As a publisher, sends each message as an HTTP request to a target URL. As a consumer, runs an embedded HTTP server and turns incoming requests into messages.
URL format
http://host[:port][/path]?method=<verb>
http:///https:// pass through unchanged — they’re already the native
scheme the driver expects (the target path, if any, is just part of the URL,
not a separate query param).
Examples
Consume a RabbitMQ queue and POST each message to an API, continuous:
mqb copy \
--from rabbitmq://guest:guest@localhost:5672/%2f?queue=orders \
--to http://internal-api.local/ingest?method=POST
Run an HTTP listener as the source (webhook receiver), continuous:
mqb copy \
--from http://0.0.0.0:8080?method=POST \
--to kafka://kafka.local:9092?topic=webhooks
Non-blocking publisher (don’t wait for the downstream response):
mqb copy --drain \
--from file:///data/events.jsonl?format=json \
--to 'https://api.example.com/ingest?method=POST&request_timeout_ms=5000'
Key options
| Option | Purpose |
|---|---|
method | HTTP method. Publisher: request method (defaults to POST). Consumer: restrict to this method. |
request_timeout_ms | Per-request timeout. Defaults to 30000ms. |
workers | Consumer-only: worker thread count. Defaults to unlimited. |
fire_and_forget | Consumer-only: respond 202 immediately, don’t wait for downstream processing. |
message_id_header | Header to extract the message ID from. Defaults to message-id. |
Full field list: reference/http.md.
WebSocket
Acts as a WebSocket server (consumer — listens for connections) or client (publisher — connects to a target URL). As a consumer it receives frames; as a publisher it sends them.
URL format
# Consumer (listen): ws://<bind-address>
# Publisher (connect): ws://host[:port]/path
ws:// (plain) and wss:// (TLS) are both accepted. For a consumer the URL is
the listen address (e.g. ws://0.0.0.0:9000); for a publisher it is the target
server URL.
Examples
Listen for WebSocket frames and forward them to Kafka, continuous:
mqb copy \
--from ws://0.0.0.0:9000 \
--to kafka://kafka.local:9092?topic=ws-events
Only accept a specific path:
mqb copy \
--from ws://0.0.0.0:9000?path=/ingest \
--to file:///data/ws.jsonl?format=json
Push a stream to a remote WebSocket server, continuous:
mqb copy \
--from redis://localhost:6379?stream=events \
--to wss://feed.example.com/socket
Key options
| Option | Purpose |
|---|---|
path | Consumer-only: only upgrade requests whose URI path matches exactly are delivered. |
message_id_header | Consumer-only: handshake header to read the message ID from (default message-id). |
execution_mode | Consumer-only: auto (default), direct_only, or routed. |
backlog | Consumer-only: TCP listen backlog for the accept socket (default 4096). |
routed_queue_capacity | Consumer-only: queue capacity for the routed adapter (default 100). |
Full field list: reference/websocket.md.
gRPC
Sends or receives messages over gRPC. As a client (default) it connects to
a remote server and issues Publish / PublishBatch RPCs; as a server
(server_mode=true) it starts an embedded tonic server that accepts those RPCs.
A client consumer can also call any service described by a compiled protobuf
descriptor, without generated Rust code — see Calling an arbitrary
service.
URL format
grpc://host[:port]?topic=<topic>
grpc:// (plain) and grpcs:// (TLS) are both accepted. In client mode the URL
is the remote server (e.g. grpc://localhost:50051); in server mode it is the
bind address (e.g. grpc://0.0.0.0:50051).
Examples
Forward a Kafka topic to a remote gRPC service, continuous:
mqb copy \
--from kafka://kafka.local:9092?topic=orders \
--to grpc://orders-svc.local:50051?topic=orders
Run an embedded gRPC server that ingests into Postgres, continuous:
mqb copy \
--from 'grpc://0.0.0.0:50051?server_mode=true&topic=orders' \
--to 'postgres://user:pass@localhost/app?table=orders&auto_create_table=true'
Calling an arbitrary service
Point the endpoint at a compiled FileDescriptorSet and name the service, the method and the
request as JSON. Responses are decoded dynamically and emitted using protobuf’s canonical JSON
representation:
input:
grpc:
url: https://grpc.example.com:443
descriptor_set_path: proto/events.bin
service_name: events.EventService
method_name: Tail
server_streaming: true
request:
topic: audit
Generate the descriptor with imports included:
protoc --descriptor_set_out=proto/events.bin --include_imports -I proto proto/events.proto
Unary and server-streaming methods are supported; client-streaming is rejected. A descriptor
describes a wire format but not an acknowledgement protocol, so a dynamic source has no ACK
operation and its delivery semantics are the remote API’s own. Where route-level ACK/NACK and
at-least-once matter, use the built-in mqbridge.Bridge protocol (the default) — its ACK/NACK
are real RPCs, and unacknowledged messages are retained and redelivered to the same
consumer_id while the server process lives.
Key options
| Option | Purpose |
|---|---|
server_mode | Start an embedded gRPC server (receive) instead of connecting as a client. |
topic | Topic / subject used for both subscribe and publish paths. |
timeout_ms | Client: connection timeout and per-request deadline. Server: per-request deadline. |
consumer_id | For the built-in mqbridge.Bridge protocol, the subscription identity for ACK tracking and redelivery. Defaults to a fresh id per consumer; set it to have unacknowledged messages redelivered after a reconnect. Dynamic services use the remote API’s own semantics. |
descriptor_set_path / service_name / method_name / request / server_streaming | Dynamic client mode (above). |
max_decoding_message_size / max_encoding_message_size | Max decoded / encoded message size (decode default 4 MiB, encode unlimited). |
http2_keepalive_interval_ms / http2_keepalive_timeout_ms | HTTP/2 keepalive tuning, both modes. |
tls | TLS configuration (object; set with a JSON literal ?tls={...}). |
Full field list: reference/grpc.md.
MongoDB
Reads from or writes to a MongoDB collection. database and collection
are separate query params — MongoDB URIs don’t encode the database in the
path the way this connector reads them (unlike PostgreSQL).
URL format
mongodb://[user:pass@]host[:port]?database=<db>&collection=<name>
Examples
Load a CSV file into a collection, one-shot:
mqb copy --drain \
--from file:///data/customers.csv?format=csv \
--to 'mongodb://localhost?database=app&collection=customers'
Non-destructive read of an existing collection (default source behavior — replica set required):
mqb copy --drain \
--from 'mongodb://localhost?database=app&collection=customers' \
--to 'postgres://user:pass@localhost/app?table=customers&auto_create_table=true'
By default (no consume given), a MongoDB source reads existing documents
then watches for changes (capture_all) — pointing at a collection never
claims or deletes its documents. This mode reads the oplog, so it requires a
replica set (a single-node one is enough) and refuses to start without one.
Read a standalone mongod (no replica set), one-shot:
mqb copy --drain \
--from 'mongodb://localhost?database=app&collection=customers&consume=snapshot' \
--to file:///data/customers.jsonl
snapshot pages the collection by _id and ends the route on drain. It is
non-destructive and needs no replica set, but it is not a tail and not
resumable — it delivers what exists when the run starts, and rejects
cursor_id.
Watch for new documents only (change stream), continuous:
mqb copy \
--from 'mongodb://localhost?database=app&collection=orders&consume=capture_new' \
--to kafka://kafka.local:9092?topic=orders
Key options
| Option | Purpose |
|---|---|
database | Required. |
collection | Collection name. |
consume | capture_all (default — snapshot then change stream, replica set required), capture_new (changes only, replica set required), snapshot (one-shot read, no replica set), or consumer (durable work queue — destructive). |
checkpoint_store | Where to persist the resume cursor for capture_new/capture_all. |
username / password | Take precedence over credentials embedded in url. |
Full field list: reference/mongodb.md.
AWS SQS / SNS
Consumes from an SQS queue or publishes to an SQS queue / SNS topic. Credentials come from the standard AWS provider chain (environment, profile, instance role) unless supplied explicitly as query params.
URL format
aws://?queue_url=<sqs-url>®ion=<region>
aws:// and aws-sqs:// are both accepted. The host is unused — the queue is
selected by queue_url (required for consumers, and for publishers unless
topic_arn is set for SNS). Point endpoint_url at LocalStack for local
testing.
Examples
Drain an SQS queue into a file, one-shot:
mqb copy --drain \
--from 'aws://?queue_url=https://sqs.us-east-1.amazonaws.com/1234/orders®ion=us-east-1' \
--to file:///data/orders.jsonl?format=json
Publish a Postgres table to an SNS topic, one-shot:
mqb copy --drain \
--from postgres://user:pass@localhost/app?table=orders \
--to 'aws://?topic_arn=arn:aws:sns:us-east-1:1234:orders®ion=us-east-1'
LocalStack, continuous:
mqb copy \
--from 'aws://?queue_url=http://localhost:4566/000000000000/orders®ion=us-east-1&endpoint_url=http://localhost:4566&access_key=test&secret_key=test' \
--to null:
Warning
access_key/secret_keyin the URL are shown here only because LocalStack’stest/testare throwaway values. Never put real credentials in a connector URL — they leak into shell history and process listings. Against real AWS, omit both and let the standard provider chain (env vars,~/.aws/credentials, IAM role) supply them; see Secrets.
Key options
| Option | Purpose |
|---|---|
queue_url | SQS queue URL. Required for consumers; optional for publishers if topic_arn is set. |
topic_arn | Publisher-only: SNS topic ARN to publish to. |
region | AWS region (e.g. us-east-1). |
endpoint_url | Custom endpoint (e.g. LocalStack). |
access_key / secret_key / session_token | Explicit credentials (otherwise the AWS provider chain is used). |
max_messages | Consumer-only: batch size per receive (1–10). |
wait_time_seconds | Consumer-only: long-poll wait (0–20). |
Full field list: reference/aws.md.
ZeroMQ
Sends or receives messages over ZeroMQ sockets (PUSH/PULL, PUB/SUB, REQ/REP).
The socket_type selects the pattern and bind decides whether the endpoint
binds or connects.
URL format
zeromq://<transport>?socket_type=<type>[&bind=true]
zeromq:// and zmq:// are both accepted. The URL is a ZeroMQ transport
address such as tcp://127.0.0.1:5555. Choose bind=true on exactly one side
of a socket pair; the other connects.
Examples
Pull from a PUSH producer and write to a file, continuous:
mqb copy \
--from 'zeromq://tcp://127.0.0.1:5555?socket_type=pull&bind=true' \
--to file:///data/events.jsonl?format=json
Publish a Kafka topic to a PUB socket, continuous:
mqb copy \
--from kafka://kafka.local:9092?topic=events \
--to 'zeromq://tcp://0.0.0.0:5556?socket_type=pub&bind=true'
Subscribe to a topic on a remote PUB socket, continuous:
mqb copy \
--from 'zeromq://tcp://feed.local:5556?socket_type=sub&topic=orders' \
--to 'postgres://user:pass@localhost/app?table=orders&auto_create_table=true'
Key options
| Option | Purpose |
|---|---|
socket_type | push, pull, pub, sub, req, or rep. |
bind | Bind to the address instead of connecting (default false). |
topic | Consumer-only: topic filter for sub sockets. |
format | Wire format: raw_framed (default — payload bytes with a JSON metadata frame in front), raw (payload bytes only, no metadata), json (wraps the whole message). |
backend | try_omq (default — use omq when the zeromq-omq feature is compiled in, else zmq), or zmq / omq to require that backend. |
Wire format changed in 0.4.0.
formatused to default tojson; it is nowraw_framed, which is binary-safe and still carries headers. A 0.4 peer and a 0.3 peer no longer understand each other on the same socket unless one of them usesformat=jsonin the URL query. REQ/REP replies are the exception toformatentirely: a REP peer always answers with a JSON array of canonical messages, and a REQ publisher always decodes one.
Both backends cover the whole socket set, REQ/REP included. backend: omq is the faster path
when the zeromq-omq feature is compiled in; naming omq or zmq explicitly makes that
backend a hard requirement rather than a preference. On omq, backpressure is applied by the
socket’s high-water mark and internal_buffer_size is ignored.
Full field list: reference/zeromq.md.
IBM MQ
Puts to or gets from an IBM MQ queue (point-to-point) or topic (publish/subscribe). Requires the IBM MQ client libraries and the optional IBM MQ build — see IBM MQ setup for prerequisites.
URL format
ibmmq://host(port)?queue_manager=<QM>&channel=<CHANNEL>&queue=<QUEUE>
ibmmq:// and ibm-mq:// are both accepted. The host is given in IBM MQ’s
host(port) form, and a comma-separated list provides failover
(host1(1414),host2(1414)). queue_manager and channel (the SVRCONN
channel) are always required; supply queue or topic.
Examples
Drain a queue into a file, one-shot:
mqb copy --drain \
--from 'ibmmq://mq.local(1414)?queue_manager=QM1&channel=DEV.APP.SVRCONN&queue=DEV.QUEUE.1&username=app&password=<password>' \
--to file:///data/mq.jsonl?format=json
Credentials on the command line land in shell history and process listings. Keep them out of the URL by interpolating an environment variable instead — see Secrets.
Bridge a queue into Kafka, continuous:
mqb copy \
--from 'ibmmq://mq.local(1414)?queue_manager=QM1&channel=DEV.APP.SVRCONN&queue=DEV.QUEUE.1' \
--to kafka://kafka.local:9092?topic=mq-events
Subscribe to a topic (pub/sub), continuous:
mqb copy \
--from 'ibmmq://mq.local(1414)?queue_manager=QM1&channel=DEV.APP.SVRCONN&topic=orders/new' \
--to 'postgres://user:pass@localhost/app?table=orders&auto_create_table=true'
Key options
| Option | Purpose |
|---|---|
queue_manager | Required. Queue Manager name (e.g. QM1). |
channel | Required. SVRCONN channel name defined on the QM. |
queue | Queue for point-to-point (defaults to the route name if omitted). |
topic | Topic string for pub/sub; enables subscriber mode on a consumer. |
username / password | Authentication (required if the channel enforces it). |
max_message_size | Max message size in bytes (default 4 MiB). |
wait_timeout_ms | Consumer-only: polling timeout (default 1000 ms). |
tls | TLS via the MQ-native key repository (object; set with a JSON literal ?tls={...}). |
Full field list: reference/ibmmq.md. Setup and prerequisites: IBM MQ setup.
File (CSV / JSON / JSONL)
Reads from or writes to a local file. Useful as a one-shot source/sink for migrating data in or out of the other connectors.
URL format
file:///absolute/path/to/file?format=<normal|json|text|raw|csv>
The path comes from the URI path itself (file:///...), not a query param.
format defaults to normal (the full message serialized as JSON).
Examples
Load a CSV file into MongoDB, one-shot (first row = header):
mqb copy --drain \
--from file:///data/customers.csv?format=csv \
--to 'mongodb://localhost?database=app&collection=customers'
Export a table to JSONL, one-shot:
mqb copy --drain \
--from postgres://user:pass@localhost/app?table=orders \
--to file:///data/orders.jsonl?format=json
Tail a file as it grows (broadcast/subscribe mode), continuous:
mqb copy \
--from file:///var/log/app/events.log?mode=subscribe \
--to kafka://kafka.local:9092?topic=app-events
Key options
| Option | Purpose |
|---|---|
format | normal, json, text, raw, or csv. |
delimiter | Message delimiter. Defaults to newline. |
mode | Consumer only: consume (from start), subscribe (tail from end), or persistent offset-tracked modes. |
compression | Compress/decompress each batch: none (default), gzip, lz4, zstd (needs the compression build feature). A source must declare the same codec the file was written with. See Compression. |
Full field list: reference/file.md.
URL Parameter Reference
Auto-generated from the same JSON Schemas mqb copy uses to parse --from/--to query parameters (see crates/cli/src/main.rs::endpoint_from_uri). For workflow examples, see the connector pages.
- PostgreSQL / MySQL / MariaDB / SQLite — schemes:
postgres://,postgresql://,mysql://,mariadb://,sqlite:// - PostgreSQL CDC (logical replication) — schemes:
postgres-cdc://,pgcdc:// - ClickHouse — schemes:
clickhouse://,clickhouses:// - Kafka — schemes:
kafka:// - MQTT — schemes:
mqtt://,mqtts:// - RabbitMQ (AMQP) — schemes:
amqp://,amqps://,rabbitmq://,rabbitmqs:// - HTTP — schemes:
http://,https:// - WebSocket — schemes:
ws://,wss:// - MongoDB — schemes:
mongodb:// - File (CSV / JSON / JSONL) — schemes:
file:// - Cloud Object Storage — schemes:
s3://,s3a://,gs://,gcs://,az://,azure://,abfs://,abfss:// - NATS — schemes:
nats:// - Redis Streams — schemes:
redis://,rediss://,redis_streams:// - gRPC — schemes:
grpc://,grpcs:// - AWS SQS / SNS — schemes:
aws://,aws-sqs:// - ZeroMQ — schemes:
zeromq://,zmq:// - IBM MQ — schemes:
ibmmq://,ibm-mq://
PostgreSQL / MySQL / MariaDB / SQLite
Schemes: postgres://, postgresql://, mysql://, mariadb://, sqlite://
Query parameters recognised as config fields for this connector. The object-typed tls is set with a JSON literal, e.g. ?tls={...}. Any other ?key=value pair is passed through unchanged as a driver option on the connection URL.
| Name | Type | Required | Default | Description |
|---|---|---|---|---|
acquire_timeout_ms | integer | no | — | Timeout for acquiring a connection from the pool in milliseconds. Defaults to 30000ms. |
auto_create_table | boolean | no | false | (Publisher only) If true, automatically create the table and indexes if they don’t exist. Defaults to false. |
bulk_copy | boolean | no | false | (Publisher only) PostgreSQL only. Bulk-load batches via COPY FROM STDIN (much faster than multi-row INSERT). Requires a token-based insert_query; no ON CONFLICT/RETURNING. |
checkpoint_store | string | no | — | (Consumer only) Where to persist the resume cursor in cursor_column mode. A URL selects the backend; a bare name (or /name) reuses the source datastore with that table name: - absent → source datastore, table mqb_cursors_<source_table> (auto-unique) - /my_cursors → source datastore, table my_cursors - file:///var/lib/mqb/cursors.json → local JSON file (read-only / write-restricted sources) - postgres://user@host/db/table or mysql://host/db/table → external SQL table (table optional) - mongodb://host/db/collection → external MongoDB collection (collection optional) - s3://bucket/prefix (also gs://, az://, abfs://) → cloud object store; creds via env When no table/collection is named, it defaults to mqb_cursors_<source_table>. May embed connection credentials, so it is treated as a secret. |
create_publication | boolean | no | false | (Consumer only, CDC) When publication is set, create it if missing (default false). Needs table-owner privilege: it is auto-published FOR TABLE {table}. |
cursor_column | string | no | — | (Consumer only) Read an existing table non-destructively and resumably, paging by this monotonic column (SELECT * FROM {table} WHERE {cursor_column} > $last ORDER BY {cursor_column} ASC LIMIT n) and persisting the last read value under cursor_id. Does not delete/lock source rows. Mutually exclusive with delete_after_read. |
cursor_id | string | no | — | (Consumer only) Cursor id used to key the persisted resume position. Recommended when cursor_column is set: without it, progress is not persisted and every restart re-copies from the beginning. |
delete_after_read | boolean | no | false | (Consumer only) If true, delete messages after processing. |
idle_timeout_ms | integer | no | — | Maximum idle time for a connection in milliseconds. Defaults to 600000ms (10 minutes). |
insert_query | string | no | — | (Publisher only) Optional. A custom SQL INSERT query. Use ? as a placeholder for the payload. If not provided, a default INSERT INTO {table} (payload) VALUES (?) is used. For multi-column inserts, embed explicit source tokens directly in the query: ${metadata:<key>} binds message.metadata["<key>"], and ${payload:<field>} binds the top-level JSON field <field> of the payload (types preserved: numbers/bools stay numeric/bool). There is no fallback between the two: an absent metadata key, non-JSON payload, or missing/non-scalar field binds SQL NULL. Example: INSERT INTO orders (customer_id, sku, qty) VALUES (${metadata:customer_id}, ${payload:sku}, ${payload:qty}). A query with no ${...} tokens behaves exactly as before (whole payload bound once). auto_create_table is not supported together with a token-based query. Tokens bind as text/number/bool; Postgres won’t implicitly cast text into a numeric/timestamptz column (these arrive as JSON strings from a sql source). Add an explicit cast next to the token — it is preserved verbatim in the SQL: VALUES (${payload:amount}::numeric, ${payload:created_at}::timestamptz). |
max_connections | integer | no | — | Maximum number of connections in the pool. Defaults to 10. |
max_lifetime_ms | integer | no | — | Maximum lifetime of a connection in milliseconds. Defaults to 1800000ms (30 minutes). |
max_polling_interval_ms | integer | no | — | (Consumer only) If set, the poll interval backs off exponentially from polling_interval_ms up to this value while drained, resetting on new rows. Unset = constant interval. |
min_connections | integer | no | — | Minimum number of connections to keep in the pool. Defaults to 0. |
password | string | no | null | Optional password. Takes precedence over any credentials embedded in the url. |
polling_interval_ms | integer | no | — | (Consumer only) Polling interval in milliseconds. Defaults to 100ms. |
publication | string | no | — | (Consumer only, PostgreSQL) If set, consume via logical-replication CDC instead of cursor polling: streams inserts/updates/deletes from this publication. Requires the postgres-cdc feature and a Postgres URL. For full control use the dedicated postgres_cdc endpoint. |
select_query | string | no | — | (Consumer only) Optional. A custom SQL SELECT query to fetch messages. This is only supported for PostgreSQL and Microsoft SQL Server. The query must include a placeholder for the batch size ($1 for PostgreSQL, @p1 for SQL Server). The bridge will bind the route’s batch_size to this placeholder. |
shared | boolean | no | true | Share one connection pool per connection (default: true); false forces a dedicated pool. |
slot_name | string | no | — | (Consumer only, CDC) Replication slot name; created if missing. Defaults to mq_bridge_slot. |
source_metadata | boolean | no | false | (Consumer only) Include authoritative mqb.src.sqlx_* source positions; cursor_column must then be a unique integer. Defaults to false. |
table | string | yes | — | The table to interact with. |
test_before_acquire | boolean | no | — | Ping each pooled connection before handing it out (default false). Costs a round-trip per acquire. |
tls | object | no | see below | TLS configuration for the database connection. |
url | string | yes | — | Database connection URL. If it contains userinfo, it will be treated as a secret. |
username | string | no | null | Optional username. Takes precedence over any credentials embedded in the url. |
Struct-typed fields
tls
TLS configuration for secure connections.
Configures Transport Layer Security (TLS/SSL) for encrypted communication. Supports both client certificate (mutual TLS) and server certificate validation.
Examples
use mq_bridge::models::TlsConfig;
let tls = TlsConfig {
required: true,
ca_file: Some("/path/to/ca.pem".to_string()),
cert_file: Some("/path/to/cert.pem".to_string()),
key_file: Some("/path/to/key.pem".to_string()),
..Default::default()
};
| Name | Type | Required | Default | Description |
|---|---|---|---|---|
accept_invalid_certs | boolean | no | false | If true, disable server certificate verification (insecure). |
ca_file | string | no | — | Path to the CA certificate file. |
cert_file | string | no | — | Path to the client certificate file (PEM). |
cert_password | string | no | — | Password for the private key (if encrypted). |
key_file | string | no | — | Path to the client private key file (PEM). |
required | boolean | no | false | If true, enable TLS/SSL. |
PostgreSQL CDC (logical replication)
Schemes: postgres-cdc://, pgcdc://
Query parameters recognised as config fields for this connector. The object-typed tls is set with a JSON literal, e.g. ?tls={...}. Any other ?key=value pair is passed through unchanged as a driver option on the connection URL.
| Name | Type | Required | Default | Description |
|---|---|---|---|---|
checkpoint_store | string | no | — | Checkpoint store spec (e.g. file:///path, s3://bucket/prefix); defaults to the source database. |
create_publication | boolean | no | false | Create the publication if missing (default false; leave off if it pre-exists). Needs table ownership for publication_tables, or superuser when none are set (FOR ALL TABLES). |
create_slot | boolean | no | true | Create the replication slot if it does not exist. |
cursor_id | string | no | — | Checkpoint key for persisting the confirmed LSN across restarts (optional; the slot is authoritative). |
publication | string | yes | — | Publication name (must already exist; defines which tables are captured). |
publication_tables | array of string | no | see below | Tables to include when managing the publication (create_publication); may be schema.table. Missing ones are added to an existing publication (never removed). Empty = FOR ALL TABLES (needs superuser). |
slot_name | string | no | mq_bridge_slot | Replication slot name; created if missing when create_slot is true. |
source_metadata | boolean | no | false | Include authoritative mqb.src.postgres_* source positions. Defaults to false. |
status_interval_ms | integer | no | 10000 | Standby-status-update interval in ms; must be shorter than the server’s wal_sender_timeout. |
temporary_slot | boolean | no | false | Ephemeral run: drop the slot when the route stops. Not restart-safe; a hard crash leaks it. |
tls | object | no | see below | TLS configuration for the replication connection. |
url | string | yes | — | Connection URL, e.g. postgres://user:pass@host:5432/dbname. |
Struct-typed fields
publication_tables
[]
tls
TLS configuration for secure connections.
Configures Transport Layer Security (TLS/SSL) for encrypted communication. Supports both client certificate (mutual TLS) and server certificate validation.
Examples
use mq_bridge::models::TlsConfig;
let tls = TlsConfig {
required: true,
ca_file: Some("/path/to/ca.pem".to_string()),
cert_file: Some("/path/to/cert.pem".to_string()),
key_file: Some("/path/to/key.pem".to_string()),
..Default::default()
};
| Name | Type | Required | Default | Description |
|---|---|---|---|---|
accept_invalid_certs | boolean | no | false | If true, disable server certificate verification (insecure). |
ca_file | string | no | — | Path to the CA certificate file. |
cert_file | string | no | — | Path to the client certificate file (PEM). |
cert_password | string | no | — | Password for the private key (if encrypted). |
key_file | string | no | — | Path to the client private key file (PEM). |
required | boolean | no | false | If true, enable TLS/SSL. |
MongoDB
Schemes: mongodb://
Query parameters recognised as config fields for this connector. The object-typed tls is set with a JSON literal, e.g. ?tls={...}. Any other ?key=value pair is passed through unchanged as a driver option on the connection URL.
| Name | Type | Required | Default | Description |
|---|---|---|---|---|
capped_size_bytes | integer | no | — | (Publisher only) If set, creates a capped collection with this size in bytes. |
change_stream | boolean | no | false | (Consumer only) Deprecated — use consume: capture_new. Kept for compatibility. |
checkpoint_store | string | no | — | (Consumer only) Where to persist the resume cursor in capture_new/capture_all mode. A URL selects the backend; a bare name (or /name) reuses the source database with that name: - absent → source database, collection mqb_cursors_<source_collection> (auto-unique) - /my_cursors → source database, collection my_cursors - file:///var/lib/mqb/cursors.json → local JSON file (read-only / write-restricted sources) - mongodb://host/db/collection → external MongoDB collection (collection optional) - postgres://user@host/db/table or mysql://host/db/table → external SQL table (table optional) - s3://bucket/prefix (also gs://, az://, abfs://) → cloud object store; creds via env When no collection/table is named, it defaults to mqb_cursors_<source_collection>. May embed connection credentials, so it is treated as a secret. |
collection | string | no | — | The MongoDB collection name. |
consume | consumer | snapshot | capture_new | capture_all | no | — | (Consumer only) How to consume the collection: capture_all (default, read existing documents first, then watch for changes — non-destructive, for bulk reads and ETL), capture_new (watch an existing collection for changes only), snapshot (one-shot non-destructive read that ends on drain, the option without a replica set), or consumer (competing-consumers work queue — destructive and intended for jobs). The bridge selects the underlying mechanism automatically. If unset, the deprecated change_stream boolean is honored for backward compatibility. |
cursor_id | string | no | — | The ID used for the cursor in sequenced mode. If not provided, consumption starts from the current sequence (ephemeral). |
database | string | yes | — | The database name. |
format | normal | json | text | raw | no | normal | Format for storing messages. Defaults to Normal. |
id_field | string | no | — | (Publisher only) Top-level payload field whose value becomes the document _id, or a replay-stable ${...} template such as ${metadata:mqb.id}. Enables idempotent inserts through MongoDB’s unique _id index. Sink collections only. |
meta_collection | string | no | — | (Optional) Collection to store sequence counters and cursor positions. Defaults to the message collection if not set. |
password | string | no | — | Optional password. Takes precedence over any credentials embedded in the url. Use embedded URL credentials for simple one-off connections but prefer explicit username/password fields (or environment-sourced secrets) for clarity and secret management in production. |
polling_interval_ms | integer | no | — | (Consumer only) Polling interval in milliseconds for the consumer (when not using Change Streams). Defaults to 100ms. |
receive_query | string | no | — | (Consumer only) Optional custom MongoDB query to filter messages. Provided as a JSON string (e.g., ‘{“type”: “notification”}’). |
reply_polling_ms | integer | no | — | (Publisher only) Polling interval in milliseconds for the publisher when waiting for a reply. Defaults to 50ms. |
report_outcome | boolean | no | false | (Publisher only) Return the message with metadata mongodb.outcome = inserted/existed (dup-key) so a request+switch can branch. Sink collections only; pair with id_field. |
request_reply | boolean | no | false | (Publisher only) If true, the publisher will wait for a response in a dedicated collection. Defaults to false. |
request_timeout_ms | integer | no | — | (Publisher only) Timeout for request-reply operations in milliseconds. Defaults to 30000ms. |
shared | boolean | no | true | Share one MongoDB client per connection (default: true); false forces a dedicated client. |
source_metadata | boolean | no | false | (Consumer only, capture_new/capture_all) Include authoritative mqb.src.mongodb_* source positions. Defaults to false. |
tls | object | no | see below | TLS configuration. |
ttl_seconds | integer | no | — | (Publisher only) TTL in seconds for documents created by the publisher. If set, a TTL index is created. |
url | string | yes | — | MongoDB connection string URI. Can contain a comma-separated list of hosts for a replica set. If it contains userinfo, it will be treated as a secret. Credentials provided via the separate username and password fields take precedence over any credentials embedded in the URL. |
username | string | no | — | Optional username. Takes precedence over any credentials embedded in the url. Use embedded URL credentials for simple one-off connections but prefer explicit username/password fields (or environment-sourced secrets) for clarity and secret management in production. |
Struct-typed fields
tls
TLS configuration for secure connections.
Configures Transport Layer Security (TLS/SSL) for encrypted communication. Supports both client certificate (mutual TLS) and server certificate validation.
Examples
use mq_bridge::models::TlsConfig;
let tls = TlsConfig {
required: true,
ca_file: Some("/path/to/ca.pem".to_string()),
cert_file: Some("/path/to/cert.pem".to_string()),
key_file: Some("/path/to/key.pem".to_string()),
..Default::default()
};
| Name | Type | Required | Default | Description |
|---|---|---|---|---|
accept_invalid_certs | boolean | no | false | If true, disable server certificate verification (insecure). |
ca_file | string | no | — | Path to the CA certificate file. |
cert_file | string | no | — | Path to the client certificate file (PEM). |
cert_password | string | no | — | Password for the private key (if encrypted). |
key_file | string | no | — | Path to the client private key file (PEM). |
required | boolean | no | false | If true, enable TLS/SSL. |
Kafka
Schemes: kafka://
Query parameters recognised as config fields for this connector. The object-typed tls is set with a JSON literal, e.g. ?tls={...}. Unrecognised parameters are not forwarded as driver options, so any other ?key=value pair is rejected rather than silently ignored.
| Name | Type | Required | Default | Description |
|---|---|---|---|---|
consumer_options | array of array of any | no | null | (Consumer only) Additional librdkafka consumer configuration options (key-value pairs). |
delayed_ack | boolean | no | false | (Publisher only) If true, do not wait for an acknowledgement when sending to broker. Defaults to false. |
group_id | string | no | — | (Consumer only) Consumer group ID. If not provided, the consumer acts in Subscriber mode: it generates a unique, ephemeral group ID and starts consuming from the latest offset. |
partition_key | string | no | null | (Publisher only) Name of a metadata field whose value is used as the Kafka record key (drives partitioning/ordering). Unset, or absent on a given message, falls back to the message id. Default unset. |
partitions | integer | no | 6 | (Publisher only) Partition count used when auto-creating the topic (default: 6). Higher values raise write/consume parallelism; ordering is only guaranteed per partition key (message_id), not across the whole topic. Ignored if the topic exists. |
password | string | no | — | Optional password for SASL authentication. |
producer_options | array of array of any | no | null | (Publisher only) Additional librdkafka producer configuration options (key-value pairs). |
shared | boolean | no | true | (Publisher only) Share one producer per connection (default: true); false gives a dedicated producer. |
source_metadata | boolean | no | false | (Consumer only) Include authoritative mqb.src.kafka_* source positions. Defaults to false. |
tls | object | no | see below | TLS configuration. |
topic | string | no | — | The Kafka topic to produce to or consume from. |
url | string | yes | — | Comma-separated list of Kafka broker URLs. If it contains userinfo, it will be treated as a secret. |
username | string | no | — | Optional username for SASL authentication. |
Struct-typed fields
tls
TLS configuration for secure connections.
Configures Transport Layer Security (TLS/SSL) for encrypted communication. Supports both client certificate (mutual TLS) and server certificate validation.
Examples
use mq_bridge::models::TlsConfig;
let tls = TlsConfig {
required: true,
ca_file: Some("/path/to/ca.pem".to_string()),
cert_file: Some("/path/to/cert.pem".to_string()),
key_file: Some("/path/to/key.pem".to_string()),
..Default::default()
};
| Name | Type | Required | Default | Description |
|---|---|---|---|---|
accept_invalid_certs | boolean | no | false | If true, disable server certificate verification (insecure). |
ca_file | string | no | — | Path to the CA certificate file. |
cert_file | string | no | — | Path to the client certificate file (PEM). |
cert_password | string | no | — | Password for the private key (if encrypted). |
key_file | string | no | — | Path to the client private key file (PEM). |
required | boolean | no | false | If true, enable TLS/SSL. |
NATS
Schemes: nats://
Query parameters recognised as config fields for this connector. The object-typed tls is set with a JSON literal, e.g. ?tls={...}. Unrecognised parameters are not forwarded as driver options, so any other ?key=value pair is rejected rather than silently ignored.
| Name | Type | Required | Default | Description |
|---|---|---|---|---|
deduplicate | boolean | no | false | (Publisher only, JetStream) If true, publish a Nats-Msg-Id header (from the message id) so JetStream deduplicates redeliveries within the stream’s duplicate window. Defaults to false. |
delayed_ack | boolean | no | false | (Publisher only) If true, do not wait for an acknowledgement when sending to broker. Defaults to false. |
deliver_policy | all | last | new | last_per_subject | no | — | (Consumer only) The delivery policy for the consumer. Defaults to “all”. |
no_jetstream | boolean | no | false | If no_jetstream: true, use Core NATS (fire-and-forget) instead of JetStream. Defaults to false. |
password | string | no | — | Optional password for authentication. |
prefetch_count | integer | no | — | (Consumer only) Number of messages to prefetch from the consumer. Defaults to 10000. |
request_reply | boolean | no | false | (Publisher only) If true, the publisher uses the request-reply pattern. It sends a request and waits for a response (using core_client.request_with_headers()). Defaults to false. |
request_timeout_ms | integer | no | — | (Publisher only) Timeout for request-reply operations in milliseconds. Defaults to 30000ms. |
shared | boolean | no | true | Share one NATS client per connection (default: true); false forces a dedicated connection. |
source_metadata | boolean | no | false | (Consumer only) Include authoritative mqb.src.nats_* source positions. Defaults to false. |
stream | string | no | — | The JetStream stream name. Required for Consumers, even with no_jetstream: true (unused there, but still validated). |
stream_max_bytes | integer | no | — | (Publisher only) Maximum total bytes in the stream (if created by the bridge). Defaults to 1GB. |
stream_max_messages | integer | no | — | (Publisher only) Maximum number of messages in the stream (if created by the bridge). Defaults to 1,000,000. |
subject | string | no | — | The NATS subject to publish to or subscribe to. If a stream is auto-created, it’s scoped to {stream}.>, so prefix accordingly. |
subscriber_mode | boolean | no | false | (Consumer only) If true, use ephemeral Subscriber mode. Defaults to false (durable consumer). |
tls | object | no | see below | TLS configuration. |
token | string | no | — | Optional token for authentication. |
url | string | yes | — | Comma-separated list of NATS server URLs (e.g., “nats://localhost:4222,nats://localhost:4223”). If it contains userinfo, it will be treated as a secret. |
username | string | no | — | Optional username for authentication. |
Struct-typed fields
tls
TLS configuration for secure connections.
Configures Transport Layer Security (TLS/SSL) for encrypted communication. Supports both client certificate (mutual TLS) and server certificate validation.
Examples
use mq_bridge::models::TlsConfig;
let tls = TlsConfig {
required: true,
ca_file: Some("/path/to/ca.pem".to_string()),
cert_file: Some("/path/to/cert.pem".to_string()),
key_file: Some("/path/to/key.pem".to_string()),
..Default::default()
};
| Name | Type | Required | Default | Description |
|---|---|---|---|---|
accept_invalid_certs | boolean | no | false | If true, disable server certificate verification (insecure). |
ca_file | string | no | — | Path to the CA certificate file. |
cert_file | string | no | — | Path to the client certificate file (PEM). |
cert_password | string | no | — | Password for the private key (if encrypted). |
key_file | string | no | — | Path to the client private key file (PEM). |
required | boolean | no | false | If true, enable TLS/SSL. |
RabbitMQ (AMQP)
Schemes: amqp://, amqps://, rabbitmq://, rabbitmqs://
Query parameters recognised as config fields for this connector. The object-typed tls is set with a JSON literal, e.g. ?tls={...}. Any other ?key=value pair is passed through unchanged as a driver option on the connection URL.
| Name | Type | Required | Default | Description |
|---|---|---|---|---|
delayed_ack | boolean | no | false | (Publisher only) If true, do not wait for an acknowledgement when sending to broker. Defaults to false. |
exchange | string | no | — | The exchange to publish to or bind the queue to. |
no_declare_queue | boolean | no | false | (Publisher only) If true, do not attempt to declare the queue. Assumes the queue already exists. Defaults to false. |
no_persistence | boolean | no | false | If true, declare queues as non-durable (transient). Defaults to false. Affects both Consumer (queue durability) and Publisher (message persistence). |
password | string | no | — | Optional password for authentication. |
prefetch_count | integer | no | — | (Consumer only) Number of messages to prefetch. Defaults to 100. |
queue | string | no | — | The AMQP queue name. |
source_metadata | boolean | no | false | (Consumer only) Include authoritative mqb.src.amqp_* source positions. Defaults to false. |
subscribe_mode | boolean | no | false | (Consumer only) If true, act as a Subscriber (fan-out). Defaults to false. |
tls | object | no | see below | TLS configuration. |
url | string | yes | — | AMQP connection URI. The lapin client connects to a single host specified in the URI. If it contains userinfo, it will be treated as a secret. For high availability, provide the address of a load balancer or use DNS resolution that points to multiple brokers. Example: “amqp://localhost:5672/vhost”. |
username | string | no | — | Optional username for authentication. |
Struct-typed fields
tls
TLS configuration for secure connections.
Configures Transport Layer Security (TLS/SSL) for encrypted communication. Supports both client certificate (mutual TLS) and server certificate validation.
Examples
use mq_bridge::models::TlsConfig;
let tls = TlsConfig {
required: true,
ca_file: Some("/path/to/ca.pem".to_string()),
cert_file: Some("/path/to/cert.pem".to_string()),
key_file: Some("/path/to/key.pem".to_string()),
..Default::default()
};
| Name | Type | Required | Default | Description |
|---|---|---|---|---|
accept_invalid_certs | boolean | no | false | If true, disable server certificate verification (insecure). |
ca_file | string | no | — | Path to the CA certificate file. |
cert_file | string | no | — | Path to the client certificate file (PEM). |
cert_password | string | no | — | Password for the private key (if encrypted). |
key_file | string | no | — | Path to the client private key file (PEM). |
required | boolean | no | false | If true, enable TLS/SSL. |
MQTT
Schemes: mqtt://, mqtts://
Query parameters recognised as config fields for this connector. The object-typed tls is set with a JSON literal, e.g. ?tls={...}. Unrecognised parameters are not forwarded as driver options, so any other ?key=value pair is rejected rather than silently ignored.
| Name | Type | Required | Default | Description |
|---|---|---|---|---|
clean_session | boolean | no | false | (Consumer only) If true, start with a clean session. Defaults to false (persistent session). Setting this to true effectively enables Subscriber mode (ephemeral). |
client_id | string | no | — | Optional client ID. If not provided, one is generated or derived from route name. |
delayed_ack | boolean | no | false | (Consumer only) If true, messages are acknowledged immediately upon receipt (auto-ack). If false (default), messages are acknowledged after processing (manual-ack). Note: For QoS 1/2 the publisher always waits for end-to-end broker confirmation (PUBACK/PUBCOMP) before reporting success, independent of this setting; QoS 0 remains fire-and-forget. |
keep_alive_seconds | integer | no | — | Keep-alive interval in seconds. Defaults to 20. |
max_inflight | integer | no | — | Maximum number of inflight messages. |
password | string | no | — | Optional password for authentication. |
protocol | v5 | v3 | no | v5 | MQTT protocol version (V3 or V5). Defaults to V5. |
qos | integer | no | — | Quality of Service level (0, 1, or 2). Defaults to 1. |
queue_capacity | integer | no | — | Capacity of the internal channel for incoming messages. Defaults to 100. |
session_expiry_interval | integer | no | — | Session expiry interval in seconds (MQTT v5 only). |
tls | object | no | see below | TLS configuration. |
topic | string | no | — | The MQTT topic. |
url | string | yes | — | MQTT broker URL (e.g., “tcp://localhost:1883”). Does not support multiple hosts. If it contains userinfo, it will be treated as a secret. |
username | string | no | — | Optional username for authentication. |
Struct-typed fields
tls
TLS configuration for secure connections.
Configures Transport Layer Security (TLS/SSL) for encrypted communication. Supports both client certificate (mutual TLS) and server certificate validation.
Examples
use mq_bridge::models::TlsConfig;
let tls = TlsConfig {
required: true,
ca_file: Some("/path/to/ca.pem".to_string()),
cert_file: Some("/path/to/cert.pem".to_string()),
key_file: Some("/path/to/key.pem".to_string()),
..Default::default()
};
| Name | Type | Required | Default | Description |
|---|---|---|---|---|
accept_invalid_certs | boolean | no | false | If true, disable server certificate verification (insecure). |
ca_file | string | no | — | Path to the CA certificate file. |
cert_file | string | no | — | Path to the client certificate file (PEM). |
cert_password | string | no | — | Password for the private key (if encrypted). |
key_file | string | no | — | Path to the client private key file (PEM). |
required | boolean | no | false | If true, enable TLS/SSL. |
Redis Streams
Schemes: redis://, rediss://, redis_streams://
Query parameters recognised as config fields for this connector. Any other ?key=value pair is passed through unchanged as a driver option on the connection URL.
| Name | Type | Required | Default | Description |
|---|---|---|---|---|
approx_trim | boolean | no | — | (Publisher) Use approximate (~) trimming when maxlen is set. Defaults to true. |
block_ms | integer | no | — | (Consumer) Block timeout in milliseconds for each read. Defaults to 5000ms. |
consumer_name | string | no | — | (Consumer) Consumer name within the group. Defaults to a unique per-instance id. |
group | string | no | — | (Consumer) Group name. Defaults to {APP_NAME}-{stream}; ignored in subscriber_mode. |
internal_buffer_size | integer | no | — | Internal buffer size for the consumer channel. Defaults to 128. |
maxlen | integer | no | — | (Publisher) If set, cap the stream length with XADD MAXLEN. |
password | string | no | — | Optional password for authentication. |
read_from_start | boolean | no | false | (Consumer) On group creation, start from the stream beginning (“0”) not “$”. Default false. |
reader_connections | integer | no | — | (Consumer) Parallel XREADGROUP reader connections fanned out across the group. Default 1. Ignored in subscriber_mode. |
redelivery_timeout_ms | integer | no | — | (Consumer) Redeliver entries pending ≥ this long via XAUTOCLAIM; 0 disables. Default 60000ms. |
stream | string | no | — | The stream key to publish to or read from. Defaults to the route name. |
subscriber_mode | boolean | no | false | (Consumer) Read ephemerally via XREAD from new messages (no group/acks). Default false. |
url | string | yes | — | Redis URL, redis:// or rediss:// for TLS. Userinfo is treated as a secret. |
username | string | no | — | Optional username for authentication (Redis ACL). |
ClickHouse
Schemes: clickhouse://, clickhouses://
Query parameters recognised as config fields for this connector. The object-typed columns is set with a JSON literal, e.g. ?columns={...}. Any other ?key=value pair is passed through unchanged as a driver option on the connection URL.
| Name | Type | Required | Default | Description |
|---|---|---|---|---|
async_insert | boolean | no | false | (Publisher only) If true, set the ClickHouse async_insert=1 server setting so inserts are buffered server-side. Defaults to false. |
checkpoint_store | string | no | — | (Consumer only) Where to persist the resume cursor. Because ClickHouse is unsuited to per-row cursor upserts, a durable checkpoint requires an external store URL: - file:///var/lib/mqb/cursors.json → local JSON file - postgres://user@host/db/table / mysql://host/db/table → external SQL table (table optional) - mongodb://host/db/collection → external MongoDB collection (collection optional) - s3://bucket/prefix (also gs://, az://, abfs://) → cloud object store; creds via env May embed connection credentials, so it is treated as a secret. |
columns | object | no | — | (Publisher only) Optional per-column mapping. Each entry maps a target column name to a value token: ${payload:<field>} takes the top-level JSON field <field> of the payload (JSON type preserved), ${metadata:<key>} takes message.metadata["<key>"] (as a string), and any other value is inserted literally. When omitted, the whole payload JSON object is inserted as one row. |
compression | none | gzip | lz4 | zstd | no | gzip | HTTP body compression for inserts and cursor reads (none, gzip, lz4, zstd). Applied as Content-Encoding on the request body and negotiated on the response via Accept-Encoding. lz4/zstd are faster than gzip; all are understood natively by ClickHouse. Defaults to gzip. |
connect_timeout_ms | integer | no | — | Connection (TCP + TLS handshake) timeout in milliseconds. Defaults to 10000ms. |
cursor_column | string | no | — | (Consumer only) Read an existing table non-destructively and resumably, paging by this monotonic column (SELECT … WHERE {cursor_column} > {last} ORDER BY {cursor_column} ASC LIMIT n) and persisting the last read value under cursor_id. |
cursor_id | string | no | — | (Consumer only) Cursor id used to key the persisted resume position. Without it, progress is not persisted and every restart re-copies from the beginning. |
database | string | no | — | Database name. Defaults to default. |
max_polling_interval_ms | integer | no | — | (Consumer only) If set, the poll interval backs off exponentially from polling_interval_ms up to this value while drained, resetting on new rows. Unset = constant interval. |
password | string | no | null | Optional password. Takes precedence over any credentials embedded in the url. |
polling_interval_ms | integer | no | — | (Consumer only) Polling interval in milliseconds when the table is drained. Defaults to 100ms. |
request_timeout_ms | integer | no | — | Request timeout in milliseconds for ClickHouse HTTP calls (inserts, cursor reads, status). Unset = no timeout (wait indefinitely), which suits very large batch inserts. |
select_columns | string | no | — | (Consumer only) Columns to select in cursor_column mode. Defaults to *. |
table | string | yes | — | The table to read from / write to. May be schema-qualified (db.table). |
tls | object | no | see below | TLS configuration for https:// connections. |
url | string | yes | — | ClickHouse HTTP endpoint URL, e.g. http://localhost:8123 (or https://…). If it contains userinfo, it will be treated as a secret. |
username | string | no | null | Optional username. Takes precedence over any credentials embedded in the url. Defaults to default. |
wait_for_async_insert | boolean | no | null | (Publisher only) With async_insert, wait for the server to flush before acking. Defaults to true (durable). False = fire-and-forget: faster, but a crash before flush can drop the batch. |
Struct-typed fields
tls
TLS configuration for secure connections.
Configures Transport Layer Security (TLS/SSL) for encrypted communication. Supports both client certificate (mutual TLS) and server certificate validation.
Examples
use mq_bridge::models::TlsConfig;
let tls = TlsConfig {
required: true,
ca_file: Some("/path/to/ca.pem".to_string()),
cert_file: Some("/path/to/cert.pem".to_string()),
key_file: Some("/path/to/key.pem".to_string()),
..Default::default()
};
| Name | Type | Required | Default | Description |
|---|---|---|---|---|
accept_invalid_certs | boolean | no | false | If true, disable server certificate verification (insecure). |
ca_file | string | no | — | Path to the CA certificate file. |
cert_file | string | no | — | Path to the client certificate file (PEM). |
cert_password | string | no | — | Password for the private key (if encrypted). |
key_file | string | no | — | Path to the client private key file (PEM). |
required | boolean | no | false | If true, enable TLS/SSL. |
HTTP
Schemes: http://, https://
Query parameters recognised as config fields for this connector. The object-typed custom_headers is set with a JSON literal, e.g. ?custom_headers={...}. Any other ?key=value pair is passed through unchanged as a driver option on the connection URL.
| Name | Type | Required | Default | Description |
|---|---|---|---|---|
basic_auth | array of any | no | — | HTTP Basic Authentication credentials (username, password). For consumers: validates incoming requests. For publishers: adds Authorization header. |
batch_concurrency | integer | no | — | (Publisher only) The number of concurrent HTTP requests to send in a batch. Defaults to 20. |
compression | none | gzip | lz4 | zstd | no | none | (Publisher only) Codec for the request body (none, gzip, lz4, zstd); overrides compression_enabled. lz4 is non-standard (mq-bridge peers only). Ignored on a consumer — enable response compression with compression_enabled. Defaults to none. |
compression_enabled | boolean | no | — | Turns compression on. Publisher: compress the request body with gzip (unless compression sets another codec). Consumer: compress responses, negotiating the best codec the client’s Accept-Encoding accepts. Defaults to off. |
compression_threshold_bytes | integer | no | null | Minimum message size in bytes to compress. Messages smaller than this are sent uncompressed. Defaults to 1024 bytes. |
concurrency_limit | integer | no | — | (Consumer only) Maximum number of concurrent requests to handle. Defaults to 100. |
custom_headers | object | no | — | Custom headers as key-value pairs (e.g., {“X-API-Key”: “token123”}). Added to outgoing HTTP headers for both consumers and publishers. |
fire_and_forget | boolean | no | false | (Consumer only) If true, respond immediately with 202 Accepted without waiting for downstream processing. Defaults to false. |
inline_response_fast_path | boolean | no | true | (Consumer only) If true, compatible http -> response routes may bypass the normal route consumer/worker/disposition pipeline and reply inline for lower latency. Defaults to true. Set to false to force the normal route path. |
internal_buffer_size | integer | no | — | (Consumer only) Internal buffer size for the channel. Defaults to 100. |
message_id_header | string | no | — | (Consumer only) Header key to extract the message ID from. Defaults to “message-id”. |
method | string | no | — | (Optional) HTTP method. For publishers: the method to use (defaults to POST). For consumers: restrict to this method (others return 405). |
path | string | no | — | (Consumer only) Optional request path filter. If set, only requests whose URI path matches exactly are delivered to this consumer. |
pool_idle_timeout_ms | integer | no | — | (Publisher only) Timeout for idle connections in the connection pool in milliseconds. Defaults to 90000ms. |
receive_streamable | boolean | no | false | (Consumer only) If true, read request bodies as a stream and emit each received stream item as a separate message. |
request_timeout_ms | integer | no | — | Timeout for HTTP requests in milliseconds. For consumers, it’s the request-reply timeout. For publishers, it’s the timeout for each individual request. Defaults to 30000ms. |
server_protocol | auto | http1_only | http2_only | no | auto | (Consumer only) Restrict which HTTP protocol versions a server listener accepts. Defaults to auto (HTTP/1.1 + HTTP/2). On cleartext listeners, http2_only means HTTP/2 prior-knowledge (h2c) only. |
shared | boolean | no | true | (Publisher only) Share one HTTP client per connection (default: true); false forces a dedicated client. |
stream_response_to | object | no | — | (Publisher only) Optional endpoint that receives streamed HTTP response items as correlated messages. Use a stream_buffer endpoint here when callers need to read streamed response items later through a normal mq-bridge consumer. Each streamed item is published with correlation_id, http_stream_id, http_stream_index, http_stream_format, and http_stream_end metadata. If the request message has no correlation_id, the HTTP publisher uses format!("{:032x}", request.message_id) so callers can derive the consumer correlation id before calling send. |
tcp_keepalive_ms | integer | no | — | (Publisher only) TCP keepalive timeout for the underlying connection pool in milliseconds. Defaults to 60000ms. |
tls | object | no | see below | TLS configuration. |
url | string | yes | — | For consumers, the listen address (e.g., “0.0.0.0:8080”). For publishers, the target URL. |
workers | integer | no | — | (Consumer only) Number of worker threads to use. Defaults to 0 for unlimited. |
Struct-typed fields
tls
TLS configuration for secure connections.
Configures Transport Layer Security (TLS/SSL) for encrypted communication. Supports both client certificate (mutual TLS) and server certificate validation.
Examples
use mq_bridge::models::TlsConfig;
let tls = TlsConfig {
required: true,
ca_file: Some("/path/to/ca.pem".to_string()),
cert_file: Some("/path/to/cert.pem".to_string()),
key_file: Some("/path/to/key.pem".to_string()),
..Default::default()
};
| Name | Type | Required | Default | Description |
|---|---|---|---|---|
accept_invalid_certs | boolean | no | false | If true, disable server certificate verification (insecure). |
ca_file | string | no | — | Path to the CA certificate file. |
cert_file | string | no | — | Path to the client certificate file (PEM). |
cert_password | string | no | — | Password for the private key (if encrypted). |
key_file | string | no | — | Path to the client private key file (PEM). |
required | boolean | no | false | If true, enable TLS/SSL. |
WebSocket
Schemes: ws://, wss://
Query parameters recognised as config fields for this connector. Any other ?key=value pair is passed through unchanged as a driver option on the connection URL.
| Name | Type | Required | Default | Description |
|---|---|---|---|---|
backlog | integer | no | — | (Consumer only) TCP listen backlog (pending-connection queue depth) for the accept socket. Raise this if high-concurrency handshake bursts are being dropped/reset before accept() can keep up. Defaults to 4096, which is higher than the OS/tokio default of 1024. |
execution_mode | auto | direct_only | routed | no | auto | (Consumer only) Selects whether WebSocket routes run directly or through the routed pipeline. |
message_id_header | string | no | — | (Consumer only) Header key to extract the message ID from the WebSocket handshake. Defaults to “message-id”. |
path | string | no | — | (Consumer only) Optional request path filter. If set, only upgrade requests whose URI path matches exactly are delivered to this consumer. |
routed_queue_capacity | integer | no | — | (Consumer only) Queue capacity for the routed adapter. Direct response routes do not use this queue. Defaults to 100. |
url | string | yes | — | For consumers, the listen address (e.g. “0.0.0.0:9000”). For publishers, the target URL. |
gRPC
Schemes: grpc://, grpcs://
Query parameters recognised as config fields for this connector. The object-typed tls is set with a JSON literal, e.g. ?tls={...}. Unrecognised parameters are not forwarded as driver options, so any other ?key=value pair is rejected rather than silently ignored.
| Name | Type | Required | Default | Description |
|---|---|---|---|---|
concurrency_limit_per_connection | integer | no | null | Maximum number of concurrent requests handled per connection. Server-mode only. |
consumer_id | string | no | null | Stable subscription identity used for ACK tracking and redelivery. Defaults to a fresh id per consumer; set it to be redelivered unacknowledged messages on reconnect. |
descriptor_set_path | string | no | null | Compiled protobuf FileDescriptorSet for dynamic client mode. |
http2_keepalive_interval_ms | integer | no | null | HTTP/2 keepalive ping interval in milliseconds. Applies in both modes. Default disabled |
http2_keepalive_timeout_ms | integer | no | null | Timeout for a keepalive ping acknowledgement in milliseconds. Applies in both modes. |
initial_connection_window_size | integer | no | null | HTTP/2 connection-level initial window size in bytes. Applies in both modes. |
initial_stream_window_size | integer | no | null | HTTP/2 stream-level initial window size in bytes. Applies in both modes. |
max_decoding_message_size | integer | no | null | Maximum size of a decoded incoming message in bytes. Applies in both modes. Default 4 MiB. |
max_encoding_message_size | integer | no | null | Maximum size of an encoded outgoing message in bytes. Default unlimited. |
method_name | string | no | null | RPC method name for dynamic client mode. |
request | any | no | null | JSON request mapped to the dynamic protobuf input message. |
server_mode | boolean | no | false | If true, start an embedded tonic gRPC server that accepts incoming Publish / PublishBatch RPCs. If false (the default), connect to a remote server as a client. |
server_streaming | boolean | no | false | Use a server-streaming dynamic RPC. False selects unary. |
service_name | string | no | null | Fully-qualified protobuf service name for dynamic client mode. |
shared | boolean | no | true | (Publisher only) Share one gRPC channel per connection (default: true); false forces a dedicated channel. |
timeout_ms | integer | no | — | Timeout in milliseconds. - Client mode: used as the connection timeout and per-request deadline. - Server mode: applied as the per-request deadline on the embedded server. |
tls | object | no | see below | TLS configuration. |
topic | string | no | — | Topic / subject used for both subscribe and publish paths. |
url | string | yes | — | The gRPC server URL (e.g., “http://localhost:50051” for client or “0.0.0.0:50051” for server mode). |
Struct-typed fields
tls
TLS configuration for secure connections.
Configures Transport Layer Security (TLS/SSL) for encrypted communication. Supports both client certificate (mutual TLS) and server certificate validation.
Examples
use mq_bridge::models::TlsConfig;
let tls = TlsConfig {
required: true,
ca_file: Some("/path/to/ca.pem".to_string()),
cert_file: Some("/path/to/cert.pem".to_string()),
key_file: Some("/path/to/key.pem".to_string()),
..Default::default()
};
| Name | Type | Required | Default | Description |
|---|---|---|---|---|
accept_invalid_certs | boolean | no | false | If true, disable server certificate verification (insecure). |
ca_file | string | no | — | Path to the CA certificate file. |
cert_file | string | no | — | Path to the client certificate file (PEM). |
cert_password | string | no | — | Password for the private key (if encrypted). |
key_file | string | no | — | Path to the client private key file (PEM). |
required | boolean | no | false | If true, enable TLS/SSL. |
ZeroMQ
Schemes: zeromq://, zmq://
Query parameters recognised as config fields for this connector. Unrecognised parameters are not forwarded as driver options, so any other ?key=value pair is rejected rather than silently ignored.
| Name | Type | Required | Default | Description |
|---|---|---|---|---|
backend | zmq | omq | try_omq | no | try_omq | Backend: try_omq (default, prefer omq and fall back to zmq), zmq (the zeromq crate) or omq (the omq-tokio backend). omq needs the zeromq-omq build feature. |
bind | boolean | no | false | If true, bind to the address. If false, connect. |
format | json | raw | raw_framed | no | raw_framed | Wire format: json wraps the CanonicalMessage; raw sends payload bytes per frame; raw_framed adds a JSON metadata frame. Default raw_framed. REQ/REP replies are the exception: a REP peer always answers with a JSON array of canonical messages and a REQ publisher always decodes one, whatever format is set to. |
internal_buffer_size | integer | no | null | Internal buffer size for the channel. Defaults to 128. zmq backend only — omq applies HWM backpressure on the socket itself and ignores this. |
request_timeout_ms | integer | no | null | (REQ publisher only) Timeout in ms for one request/reply exchange before it is reported as failed. Defaults to 30000. |
socket_type | push | pull | pub | sub | req | rep | no | null | The socket type (PUSH, PULL, PUB, SUB, REQ, REP). |
topic | string | no | — | (Consumer only) The ZeroMQ topic (for SUB sockets). |
url | string | yes | — | The ZeroMQ URL (e.g., “tcp://127.0.0.1:5555”). |
IBM MQ
Schemes: ibmmq://, ibm-mq://
Query parameters recognised as config fields for this connector. The object-typed tls is set with a JSON literal, e.g. ?tls={...}. Unrecognised parameters are not forwarded as driver options, so any other ?key=value pair is rejected rather than silently ignored.
| Name | Type | Required | Default | Description |
|---|---|---|---|---|
channel | string | yes | — | Required. Server Connection (SVRCONN) Channel name defined on the QM. |
disable_status_inq | boolean | no | false | If false, attempt to open the queue with INQUIRE permissions to fetch queue depth for status checks. Defaults to false. |
internal_buffer_size | integer | no | null | Internal buffer size for the channel. Defaults to 100. |
max_message_size | integer | no | 4194304 | Maximum message size in bytes (default: 4MB). Optional. |
password | string | no | — | Password for authentication. Optional; required if the channel enforces authentication. |
queue | string | no | — | Target Queue name for point-to-point messaging. Optional if topic is set; defaults to route name if omitted. |
queue_manager | string | yes | — | Required. Name of the Queue Manager to connect to (e.g., QM1). |
tls | object | no | see below | TLS configuration settings (e.g., keystore paths). Optional. |
topic | string | no | — | Target Topic string for Publish/Subscribe. If set, enables Subscriber mode (Consumer) or publishes to a topic (Publisher). Optional if queue is set. |
url | string | yes | — | Required. Connection URL in host(port) format. Supports comma-separated list for failover (e.g., host1(1414),host2(1414)). If it contains userinfo, it will be treated as a secret. |
username | string | no | — | Username for authentication. Optional; required if the channel enforces authentication |
wait_timeout_ms | integer | no | 1000 | (Consumer only) Polling timeout in milliseconds (default: 1000ms). Optional. |
Struct-typed fields
tls
TLS configuration for the IBM MQ native client.
The IBM MQ client doesn’t consume PEM files, so this uses MQ-native field
names rather than the generic [TlsConfig] used by the other endpoints.
| Name | Type | Required | Default | Description |
|---|---|---|---|---|
accept_invalid_certs | boolean | no | false | If true, disable server certificate verification (insecure). |
cert_file | string | no | — | For IBM MQ this is the CMS key repository stem (e.g. /path/to/tls for tls.kdb/tls.sth), not a PEM file. Exposed as cert_file for config parity with the generic TlsConfig; the MQ-native name key_repository is still accepted. |
cert_password | string | no | — | Password unlocking the key repository. Requires an IBM MQ client/server at 9.3.0.0+. Exposed as cert_password for parity with TlsConfig; alias key_repository_password. |
cipher_spec | string | no | — | TLS CipherSpec (e.g., ANY_TLS12). Required for encrypted connections. IBM MQ-specific. |
key_repository | string | no | — | MQ-native alias for cert_file: the CMS key repository stem (e.g. /path/to/tls for tls.kdb/tls.sth). |
key_repository_password | string | no | — | MQ-native alias for cert_password: password unlocking the key repository. Requires an IBM MQ client/server at 9.3.0.0+. |
required | boolean | no | false | If true, enable TLS/SSL. |
AWS SQS / SNS
Schemes: aws://, aws-sqs://
Query parameters recognised as config fields for this connector. Unrecognised parameters are not forwarded as driver options, so any other ?key=value pair is rejected rather than silently ignored.
| Name | Type | Required | Default | Description |
|---|---|---|---|---|
access_key | string | no | — | AWS Access Key ID. |
binary_payload_mode | boolean | no | false | Use binary payloads in SQS/SNS messages. |
endpoint_url | string | no | — | Custom endpoint URL (e.g., for LocalStack). |
max_messages | integer | no | — | (Consumer only) Maximum number of messages to receive in a batch (1-10). |
queue_url | string | no | — | The SQS queue URL. Required for Consumer. Optional for Publisher if topic_arn is set. If it contains userinfo, it will be treated as a secret. |
region | string | no | — | AWS Region (e.g., “us-east-1”). |
secret_key | string | no | — | AWS Secret Access Key. |
session_token | string | no | — | AWS Session Token. |
topic_arn | string | no | — | (Publisher only) The SNS topic ARN. |
wait_time_seconds | integer | no | — | (Consumer only) Wait time for long polling in seconds (0-20). |
File (CSV / JSON / JSONL)
Schemes: file://
Query parameters recognised as config fields for this connector. The object-typed encryption is set with a JSON literal, e.g. ?encryption={...}. Unrecognised parameters are not forwarded as driver options, so any other ?key=value pair is rejected rather than silently ignored.
| Name | Type | Required | Default | Description |
|---|---|---|---|---|
compression | none | gzip | lz4 | zstd | no | none | Per-batch compression (none, gzip, lz4, zstd). Requires the compression feature. Publishers: always. Consumers: must match, and only the default consume mode reads it. |
delete | boolean | no | false | If true, processed lines are physically removed from the file once they are successfully acknowledged. |
delimiter | string | no | — | Optional delimiter for messages. Defaults to newline (“\n”). Can be a string or a hex sequence (e.g. “0x00”). Currently only single-byte delimiters are supported. |
encryption | object | no | null | At-rest AEAD encryption applied after compression. Requires the encryption feature. Publishers: always. Consumers: must match, and only the default consume mode reads it. |
format | normal | json | text | raw | csv | no | normal | The format for writing messages to the file (Publisher) or interpreting them (Consumer). Defaults to normal. |
group_id | string | no | — | The consumer group ID that is used for offset tracking. Should be unique. |
idempotency | boolean | no | — | Deprecated: use name_by. true = source_position, false = write_time; ignored when name_by is set. |
mode | consume | subscribe | group_subscribe | no | — | |
name_by | auto | source_position | write_time | no | auto | (Sink only) write_time (default here, appends to path) or source_position (replay-safe part files, path is their directory). |
path | string | yes | — | Path to the file, or to the directory holding the part files under source_position naming. |
read_from_tail | boolean | no | false | If true, starts reading from the end of the file if no offset is stored. If false, starts reading from the beginning. |
source_metadata | boolean | no | false | (Consumer only) Include authoritative mqb.src.file_* source positions; only consume mode reproduces them across restarts. Defaults to false. |
Middleware & Structural Endpoint Reference
Complete listing of every middleware and every structural endpoint mq-bridge ships.
Structural endpoints are the ones that do not talk to a broker or store: they compose other
endpoints, shape routing, or terminate a request. Data endpoints (kafka, nats, mqtt,
sqlx, …) are covered in README.md and
CONFIGURATION.md.
Middleware
Middleware attaches to an endpoint via a middlewares: list, on the input, the
output, or both:
my_route:
input:
middlewares:
- deduplication: { sled_path: "/var/lib/mqb/dedup", ttl_seconds: 3600 }
kafka: { topic: "orders", url: "localhost:9092" }
output:
middlewares:
- retry: { max_attempts: 5 }
- dlq: { endpoint: { file: { path: "failed.jsonl" } } }
nats: { subject: "orders.processed", url: "nats://localhost:4222" }
Ordering — read this before combining middleware
Output (publisher) middlewares wrap in list order, so the last entry is the outermost
layer and sees the failures of the ones before it. Put dlq last.
Input (consumer) middlewares are applied in reverse, so the first entry is outermost and runs first on an incoming message.
Consequence: a route that reads back what another route wrote needs the reversed list.
Writing with [compression, encryption] produces compress(encrypt(payload)); a reader
given that same list would try to decrypt first and fail. The reading route must say
[encryption, compression]. The lists mirror — they are not copied.
A route handler sits outside every output middleware, so it runs once per message
and the middlewares act on what it returned. In particular retry re-attempts only the
publish, never the handler — a handler with a side effect fires once however many times the
sink is retried. The trade: a dlq cannot capture a handler failure, only a send failure;
a handler error propagates to the route and is reported there.
This is asserted by
route::tests::test_retryable_handler_error_is_not_retried_by_output_middleware(the handler runs once andretrydoes not re-run it),route::tests::test_dlq_and_retry_batch_integration,middleware::transform::tests::test_rejected_message_reaches_the_dlq_through_the_config_wiring, andreference_docs_test::publisher_middleware_wraps_last_entry_outermost, and is documented onapply_middlewares_to_publisherinsrc/middleware/mod.rs.
# Correct: transform rejects -> retry gives up -> dlq captures.
middlewares:
- transform: { schema_file: "user.json" }
- retry: { max_attempts: 3 }
- dlq: { endpoint: { file: { path: "rejected.jsonl" } } }
What exists
| Name | Input | Output | Feature | Purpose |
|---|---|---|---|---|
retry | – | ✅ | – | Exponential-backoff retry of failed sends |
dlq | – | ✅ | – | Route permanently-failed messages to another endpoint |
transform | ✅ | ✅ | – | Declarative JSON mapping, coercion, validation |
id | ✅ | – | – | Derive a replay-stable business identity into mqb.id |
filter | ✅ | ✅ | filter | Keep only the messages matching an expression |
deduplication | ✅ | – | dedup | Drop repeated keys within a TTL |
weak_join | ✅ | – | – | Correlate and join related messages |
buffer | ✅ | ✅ | – | Coalesce single sends into batches |
limiter | ✅ | ✅ | – | Cap throughput to a message rate |
delay | ✅ | ✅ | – | Fixed delay per receive/send |
cookie_jar | ✅ | ✅ | – | Persist HTTP cookies / session values across messages |
encryption | ✅ | ✅ | encryption | AEAD-encrypt payloads on send, decrypt on receive |
compression | ✅ | ✅ | compression | Compress payloads on send, decompress on receive |
metrics | ✅ | ✅ | metrics | Emit throughput/latency/error metrics |
random_panic | ✅ | ✅ | – | Fault injection for testing |
custom | ✅ | ✅ | – | Your own middleware via a registered factory |
Two kinds of compression. The
compressionmiddleware compresses each message payload on any transport and decompresses it on the far side. Separately, the batchcompressionfield on thefileandobject_storeendpoints (none/gzip/lz4/zstd, samecompressionfeature) compresses whole write batches so the file stays decodable withzcat/lz4 -d. Use the field for CLI-readable data at rest, the middleware for over-the-wire payloads. Don’t stack either compression with theencryptionmiddleware on the same route — ciphertext does not compress; for compressed-and-encrypted data at rest use the endpoints’ owncompression/encryptionfields (compress-then-encrypt per batch).
Putting a middleware on the wrong side behaves in two different ways, so check the table above rather than assuming:
dlq/retryon an input log a warning and are skipped. The route still starts.deduplication,weak_joinandidon an output are hard startup errors. Deduplication cannot work on the publish side, and silently starting an un-deduplicated route is worse than refusing to start.
A middleware whose feature is not compiled in (deduplication without dedup, metrics
without metrics) is likewise a startup error, not a silent no-op.
retry
Retries failed sends with exponential backoff. Output only.
| Field | Type | Default |
|---|---|---|
max_attempts | integer | 3 |
initial_interval_ms | integer | 100 |
max_interval_ms | integer | 5000 |
multiplier | float | 2.0 |
- retry: { max_attempts: 5, initial_interval_ms: 200, max_interval_ms: 10000, multiplier: 2.0 }
Only Retryable and connection errors are retried; NonRetryable failures pass straight
through. Once attempts are exhausted the error is marked so a following dlq treats it as
permanent. Pair the two.
dlq
Sends permanently-failed messages to a separate endpoint instead of failing the batch. Output only.
| Field | Type | Required |
|---|---|---|
endpoint | Endpoint | yes |
- dlq:
endpoint:
file: { path: "dead-letters.jsonl" }
Captures NonRetryable failures and Retryable ones whose retries are exhausted. Connection
errors are not dead-lettered — they propagate so the route can reconnect. Nor are handler
failures: the handler runs outside the middlewares (see Ordering),
so a dlq only ever sees what failed on the way to the sink. The DLQ endpoint
is a full endpoint, so it can itself have middleware. If the DLQ send fails with a connection
error that error propagates rather than silently dropping the message.
Without a dlq middleware, a message that fails permanently — a data/type
error the sink rejects — is logged at error level and
dropped, and the route keeps processing the rest of the batch. dlq is the only retention
mechanism: retry alone does not retain a permanently-failed message nor prevent it from being
dropped — it only re-attempts Retryable errors (a connection error is passed straight through
for the route to reconnect on, not retried), then hands a still-failing message on to be dropped
(or to a following dlq). This is why a sink that fails with a connection error never reaches
its dlq, whether it is a route’s sole output or one leg of a fanout. This tolerate-and-continue
policy keeps one bad message from halting the whole stream, but it means a systematic failure
(e.g. every row hitting a column-type mismatch) drains the input while committing nothing and
still ends completed. Add a dlq to capture the failures for inspection/replay, or watch the
route’s logs — a burst of Dropping message … due to non-retryable error is the signal. Note
that transient errors are handled separately: several endpoints retry connection/timeout errors
internally, and the retry middleware adds backoff on top, so only genuinely permanent errors
reach this drop path.
transform
JSON reshaping with field mapping, Zen Expression, and schema processing, in that order.
| Field | Type | Default |
|---|---|---|
mapping | map of output field → rule | {} |
expression | Zen Expression returning the output document | – |
schema | inline JSON Schema subset | – |
schema_file | path to a schema file | – |
coerce | bool | true |
apply_defaults | bool | true |
coerce_empty_as_null | bool | false |
on_error | reject | pass_through | reject |
schema and schema_file are mutually exclusive. A mapping rule is a bare path string or
{ path, default, required }.
schema must be a JSON object, not a string containing one. A flat key=value middleware
syntax (such as the |transform?schema=… form in a connection URI) can only pass strings, so
it cannot express schema or any mapping rule beyond a bare path — use schema_file, or move
the route into a config file.
- transform:
mapping:
firstName: "$.first_name"
id: "$.user_id"
"address.city": { path: "$.city", default: "unknown" }
schema_file: "schemas/user.json"
For calculated output, use expression (available with the zen Cargo feature):
- transform:
mapping:
first: "$.first_name"
last: "$.last_name"
expression: >-
{ fullName: first + ' ' + last, source: meta.source }
Paths accept $.field, $.a.b, and $.items[0]; the $. prefix is optional. Dots in the
output key nest the result. An absent optional source field is omitted rather than emitted
as null.
Schema keywords honoured: type, properties, required, default, items, nullable
(also "type": ["string","null"]), enum, contentMediaType, contentSchema. Everything
else is ignored, so an existing fuller schema can be used as-is. Coercions are limited to the
lossless ones: string → integer, string → number, string → boolean (true/false/1/0),
number → string.
Empty strings
CSV and many SQL exports spell “no value” as an empty string. coerce_empty_as_null: true
reads every "" the schema visits as null, which is then handled like any other null —
a nullable field keeps it, a default replaces it:
- transform:
coerce_empty_as_null: true
schema:
type: object
properties:
note: { type: string, nullable: true }
tier: { type: string, default: standard }
note: "" arrives as null and tier: "" as "standard". A field that is neither
nullable nor defaulted is rejected, naming the coercion. Only fields the schema declares are
affected; " " is not empty.
Embedded JSON
A field carrying a JSON document as a string is decoded by contentMediaType, following
JSON Schema 2020-12:
- transform:
schema:
type: object
properties:
payload:
type: string
contentMediaType: application/json
contentSchema:
type: object
properties:
qty: { type: integer }
The string is replaced by the parsed document, and contentSchema — if given — is applied to
it with the same coercion, defaults and validation as anywhere else, so the inner qty: "7"
arrives as 7. Without contentSchema the value is parsed but not validated. A root-level
schema of this shape decodes a double-encoded message body.
This is not a coercion, and coerce: true never performs it: widening "42" to 42 is
lossless, whereas evaluating a string as a document is a parse that can succeed on input never
meant as JSON. It is opt-in per field, as the JSON Schema spec requires. Note that the spec
treats contentSchema as annotation-only; applying it is the opt-in behaviour it carves out.
Media types ending in +json (and text/json) are decoded too; parameters like
; charset=utf-8 are ignored. A media type we cannot decode, or one paired with a
contentEncoding, leaves the string untouched rather than failing. A string that does not
parse fails with kind content:
transform failed at $.payload [content]: contentMediaType is JSON but the string does not parse: ....
Failures are always non-retryable and name the field, e.g.
transform failed at $.items[1].qty [coercion]: cannot coerce string "oops" to integer.
On an output endpoint the message is failed so a following dlq captures it; on an
input endpoint it is dropped from the batch and acknowledged, keeping invalid data out of
the route. on_error: pass_through instead forwards the original payload with the reason in
the mqb.transform_error metadata key, which a switch can route on.
Schemas and paths compile once at startup; schema_file is read a single time. A transform
with neither stage configured leaves the payload untouched without parsing it.
id
Renders a template into the mqb.id metadata key, giving a message a business identity
that survives a re-read. Input only.
The value is a bare interpolation template string (see ${namespace:selector}).
- id: "${payload:order_id}"
Most sources mint a fresh message_id on every read, so it identifies the delivery, not the
record — see DELIVERY.md for which ones do carry a
stable id. mqb.id fills that gap: derived from the message itself, it is the same on every
re-read. Unlike message_id (a u128) it keeps the key as a string, so a sink can use it
verbatim, and unlike mqb.src.* it is not stripped on publish — an identity describes the
record, not the hop, so it propagates downstream.
Order matters, and not the way it reads. Consumer middlewares wrap in reverse, so the entry
closest to the end of the list touches an incoming message first. Anything that consumes
mqb.id must therefore be listed before the id that produces it:
- deduplication: { store: "sled:///var/lib/mq-bridge/dedup", ttl_seconds: 3600, key: "${metadata:mqb.id}" }
- id: "${payload:order_id}"
Reversing those two leaves mqb.id unset when deduplication reads it, which falls back to
message_id with only a warning. Pinned by
middleware::id::tests::the_last_listed_consumer_middleware_runs_first.
A partial identity is no identity. The key is set only when every selector in the template
resolves; if any one is missing the message passes through with mqb.id unset (warned once per
route, then at debug). This matters for multi-part templates: "${payload:tenant}-${payload:order_id}"
would otherwise render "acme-" for every message missing order_id and hand them all the same
identity — which, used as a deduplication key, drops all but the first.
Malformed templates fail at startup, and so does a template with no ${...} token at all, since a
constant would give every message one identity.
filter
Keeps only the messages for which an expression is true; the rest are dropped. Input and
output. Requires the filter feature (pulls the zen-expression engine), which is part of
middleware/full but not of portable.
The value is a bare expression string:
- filter: "amount > 100"
Put it on the input whenever you can. A filter on the input drops the message before the rest of the pipeline touches it, and acknowledges it at the source; on the output the message has already paid for the whole route.
When filtering splits a full input batch, the consumer reads additional full source batches until it refills the requested batch size. A naturally short source batch remains a flush boundary, so live routes do not wait indefinitely merely to fill a batch. This lets sinks such as MongoDB continue using bulk writes after filtering without adding input buffer middleware.
What an expression can read:
- Payload fields by bare name, including nested paths —
amount,order.status. The payload must be a JSON object; anything else produces a per-message error and fails the batch. Indexed paths such asitems[0].qtyare unsupported: an expression that uses one is rejected at startup rather than silently dropping every message. - Metadata under the reserved
meta.prefix —meta.http_status_code,meta.kind. Metadata is always text, so a numeric comparison needs an explicit cast:number(meta.http_status_code) >= 400.
If an expression names no payload field at all, the payload is never parsed — a metadata-only filter costs no JSON decode.
- filter: "order.status == \"open\" and number(meta.retry_count) < 3"
&& and || are rewritten to and / or for you, so both spellings work.
A field that is absent is supplied to the expression as null, so an or branch or negation
can still match. A null or non-scalar field (an array or object where the expression expects
a scalar) logs a warning. A payload that is not a JSON object, or an
expression that does not evaluate to a boolean, is an error and fails the batch; those
are configuration mistakes, and dropping every message would hide them.
To send the non-matching messages somewhere instead of discarding them, use
switch’s when mode rather than a filter.
With an object_store sink on name_by: auto, the route switches to write_time names.
A source-range name covers one contiguous run of source positions, so a batch with holes
punched in it would be written as one object per surviving run — a filter keeping 80% of rows
turns one upload into roughly a hundred. The route logs one line at startup saying it made the
switch. The same applies to every other middleware that removes messages from a batch
(deduplication, weak_join, transform with on_error: reject) and to a switch in when
mode with no default. Set name_by: source_position explicitly to keep replay-safe names and
accept the fragmentation.
deduplication
Drops messages whose key was already seen within the TTL. Input only. Requires the dedup
feature (pulls sled).
| Field | Type | Required |
|---|---|---|
store | string | one of store/sled_path |
sled_path | string | one of store/sled_path |
ttl_seconds | integer | yes |
key | string | no (defaults to message_id) |
key is an interpolation template (see ${namespace:selector}), typically
"${payload:order_id}". Without it the key is the message_id, which most sources
regenerate on every read — so re-reading the same source deduplicates nothing and only
in-flight redeliveries are suppressed. Set key to a business key whenever you need
dedup to survive a re-read.
store selects the backend by URL scheme:
sled:///path(or a bare path) — a local sled database; per-process, not cluster-wide.mongodb://host/db[/collection]— a shared collection, so multiple instances of a route deduplicate against one another. Requires themongodbfeature. Expiry is judged on read, so attl_secondsboundary is honoured exactly; the TTL index only reclaims space afterwards (MongoDB’s sweep can lag by up to a minute). The collection defaults tomqb_dedup_<route>. Point it at the same deployment your sink already uses to avoid running extra infrastructure.postgres|mysql|mariadb|sqlite://…[/table]— a shared SQL table (dedup_keyPK,expire_at), so multiple instances deduplicate against one another. Requires thesqlxfeature. SQL has no native TTL, so expired rows are swept periodically; the table defaults tomqb_dedup_<route>.
sled_path is the legacy spelling of a local sled store and is equivalent to store: "sled://<path>".
- deduplication: { store: "sled:///var/lib/mq-bridge/dedup", ttl_seconds: 3600 }
- deduplication: { store: "mongodb://localhost:27017/etl", ttl_seconds: 3600 }
- deduplication: { store: "postgres://user:pass@localhost/etl", ttl_seconds: 3600 }
- deduplication: { store: "sled:///var/lib/mq-bridge/dedup", ttl_seconds: 3600, key: "${payload:order_id}" }
When MongoDB is your sink and messages carry a business key, prefer the sink’s own unique
index (id_field, which also accepts templates, on the mongodb output) over this middleware — the target collection then
is the deduplication authority, with no second write. See the idempotency notes in README.
weak_join
Correlates messages by a metadata key and emits them as one joined message. Input only.
| Field | Type | Default |
|---|---|---|
group_by | string (metadata key) | required |
expected_count | integer | required |
timeout_ms | integer | required |
branch_by | string (metadata key) | – |
required | list of branch names | [] |
on_timeout | fire | discard | fire |
# Count mode: wait for any 3 messages sharing a correlation_id, emit a JSON array.
- weak_join: { group_by: "correlation_id", expected_count: 3, timeout_ms: 5000 }
# Branch mode: wait for named branches, emit a branch-keyed JSON object.
- weak_join:
group_by: "correlation_id"
expected_count: 2
timeout_ms: 5000
branch_by: "source"
required: ["inventory", "pricing"]
on_timeout: discard
group_by reads message metadata only — never the payload. A message that lacks the key
falls into a shared "default" group, so a mistyped key or a source that never sets it joins
unrelated messages instead of failing. If the value lives in the payload, lift it into metadata
first (a transform mapping, or the source’s own metadata options).
Setting branch_by switches to branch mode, where required overrides expected_count.
On timeout an incomplete group is either emitted partially (fire) or dropped (discard).
Messages are acknowledged on receipt, so a crash before the group completes loses the
buffered members.
buffer
Accumulates single sends and forwards them as one batch. Input and output.
| Field | Type | Required |
|---|---|---|
max_messages | integer | yes |
max_delay_ms | integer | yes |
- buffer: { max_messages: 500, max_delay_ms: 20 }
Flushes when either bound is hit. Useful in front of an endpoint whose per-call overhead
dominates. Adds up to max_delay_ms of latency.
With route concurrency greater than 1, buffering preserves order inside each batch but
does not guarantee source order across concurrent destination writes. Use concurrency: 1
when destination order matters; route validation emits a warning for this combination.
limiter
Paces throughput to a target rate. Input and output.
| Field | Type | Required |
|---|---|---|
messages_per_second | float (> 0) | yes |
- limiter: { messages_per_second: 250 }
Best-effort pacing that accounts for batch size, not just call count.
delay
Sleeps a fixed duration before each receive or send. Input and output.
| Field | Type | Required |
|---|---|---|
delay_ms | integer | yes |
- delay: { delay_ms: 100 }
Mainly for testing and for crude pacing of a downstream system; prefer
limiter for real rate control.
cookie_jar
Persists HTTP cookies and arbitrary session values across messages. Input and output.
| Field | Type | Default |
|---|---|---|
shared_scope | string | – (per-instance store) |
cookie_metadata_key | string | cookie |
set_cookie_metadata_key | string | set-cookie |
capture_metadata_keys | list of strings | [] |
export_metadata_prefix | string | – |
inject_metadata | map string→string | {} |
- cookie_jar:
shared_scope: "login-session"
capture_metadata_keys: ["x-csrf-token"]
export_metadata_prefix: "session."
Reads set-cookie from responses and injects cookie into later requests. With
shared_scope, instances using the same name share one store across endpoints and routes in
the process — that is how a login route and a data route reuse one session.
encryption
Encrypts each message payload into a self-describing AEAD envelope on the output side
and decrypts it on the input side. Metadata and routing keys stay in the clear. Input and
output. Requires the encryption feature.
| Field | Type | Default |
|---|---|---|
cipher | xchacha20poly1305 | aes256gcm | xchacha20poly1305 |
key_id | string | default |
key | string — base64-encoded 32-byte key; ${env:VAR} reads it from the environment | required |
decrypt_keys | map key_id → key | {} |
- encryption: { key: "${env:MQB_ENC_KEY}" }
The envelope records the cipher and key_id, so key rotation works by sealing with a new
key_id/key while listing the old key under decrypt_keys on the consuming side. Each
payload is authenticated independently: any bit-level tampering, a torn frame, or a
missing/wrong key is a hard consumer error, not a silent drop. The AEAD binds only the
payload (empty associated data): metadata and routing keys are not authenticated against
the ciphertext, since they are not guaranteed to survive transport round-trips (many
endpoints regenerate the message_id or drop kind). A sealed payload can therefore be
replayed under different metadata; use the deduplication middleware or a sink uniqueness
constraint if that matters. Note that this authenticates
each payload, not the file as a whole — like any append-structured file, an at-rest file
that loses whole trailing frames (truncation at a frame boundary) reads back as a shorter
stream with no error, so rely on the consumer’s checkpoint/cursor for completeness rather
than on the encryption layer.
Do not combine this middleware with a sink’s batch compression on the same route:
ciphertext does not compress. For compressed and encrypted data at rest, use the file /
object_store endpoints’ own fields instead, which apply compress-then-encrypt per batch:
output:
file:
path: "data.enc"
format: raw
compression: lz4 # none | gzip | lz4 | zstd (`compression` feature)
encryption: { key: "${env:MQB_ENC_KEY}" }
Both endpoints accept the same compression and encryption fields (object_store
derives its default object extension from them, e.g. .jsonl.gz / .jsonl.lz4, and adds a
trailing .enc when encryption is on since the object is ciphertext, not a directly
decompressible .gz). An
encrypted file is written as length-prefixed sealed frames (one per batch) and is only
readable through a matching consumer; a compressed-only file stays a standard .gz/.lz4
stream. File compression/encryption supports only the default consume mode. csv works
too: the header row is written into the first member, so the decoded stream is a normal CSV
file.
A file source must declare the same compression/encryption the data was written with.
A mismatch (wrong key, wrong codec, or a missing field) is a permanent decode failure: the
route ends failed with the error in its status, rather than completing as if the file were
empty. Reading a compressed file with no compression set is likewise rejected up front by
sniffing the leading magic bytes, so raw compressed bytes are never emitted as messages.
f64 precision. Numbers move through payloads as JSON. serde_json’s default parser shifts ~1 ULP on ~19% of 17-significant-digit doubles, so a
postgres → file → postgreshop of adouble precisioncolumn can change the last bit. Build with thefloat-roundtripfeature for bit-exact float parsing across every endpoint (it trades a little parse speed for it).
compression
Compresses each message payload on the output side and decompresses it on the input
side. Metadata and routing keys are untouched. Input and output. Requires the compression
feature.
| Field | Type | Default |
|---|---|---|
algorithm | none | gzip | lz4 | zstd | zstd |
max_decompressed_bytes | integer — reject a payload that decompresses larger than this (bomb guard); consumer side only | unset (no limit) |
- compression: { algorithm: zstd }
Each payload is compressed independently into a single self-contained member, so this works
over any transport, not just files. algorithm: none is a passthrough. A truncated or
corrupt frame is a permanent consumer error (the poison message is not re-read
indefinitely), as is a payload that exceeds max_decompressed_bytes. Put the same
algorithm on both the input and output side of a route.
Unlike the file / object_store batch compression field — which keeps whole write
batches decodable with zcat / lz4 -d — this middleware frames per message and is only
readable through a matching consumer. Do not combine it with the encryption
middleware (ciphertext does not compress); for compressed-and-encrypted data at rest, use the
endpoints’ own compression/encryption fields instead.
metrics
Emits throughput, latency and error metrics for the endpoint. Input and output. Requires the
metrics feature. Takes no options; its presence enables collection.
- metrics: {}
Input and output are labelled separately, so attaching it to both sides is meaningful.
random_panic
Deliberate fault injection for testing recovery paths. Input and output.
| Field | Type | Default |
|---|---|---|
mode | panic | disconnect | timeout | json_format_error | nack | panic |
trigger_on_message | integer (1-indexed) | – (every message) |
enabled | bool | true |
- random_panic: { mode: disconnect, trigger_on_message: 500 }
disconnect and timeout produce retryable errors; json_format_error produces a
non-retryable one — useful for exercising a dlq. Keep enabled: false in committed configs
rather than deleting the block.
On the input side, json_format_error/nack never call the real consumer at all — they
substitute a synthetic message (or error) on every triggered receive. Leaving
trigger_on_message unset means every poll is faulted, so the real source is never read and
exit_on_empty/--drain never sees the empty batch it waits for — the route runs forever,
manufacturing synthetic messages. Always set trigger_on_message to a specific count when
testing a drain-mode route with input-side fault injection. And since dlq/retry on an input
are no-ops (see above), pair an input-side fault with a real assertion on the consumer’s
recovery, not a dlq.
The middleware block alone is not enough: fault injection is gated per route by
allow_fault_injection, which defaults to false. Copying only the snippet above leaves the
middleware inert (the route logs that it is disabled). A complete, working configuration:
flaky_test_route:
allow_fault_injection: true
input:
memory: { topic: "in" }
middlewares:
- random_panic: { mode: disconnect, trigger_on_message: 500 }
output:
memory: { topic: "out" }
allow_fault_injection: true is intended for test configurations only. Do not enable it — or
the random_panic middleware — in production configs.
custom (middleware)
Delegates to a factory you registered programmatically.
| Field | Type | Required |
|---|---|---|
name | string | yes |
config | any JSON | yes |
- custom:
name: "my_enricher"
config: { lookup_url: "http://enrich.internal" }
Implement CustomMiddlewareFactory (apply_consumer and/or apply_publisher, each
defaulting to pass-through) and register it before starting routes. It can also be written
in Python (register_middleware, hooks on_receive / on_send) or JavaScript
(registerMiddleware, hooks onReceive / onSend). See EXTENDING.md
for the full guide.
Structural endpoints
These appear wherever an endpoint is expected — as a route input/output, or nested inside
another structural endpoint.
| Name | Input | Output | Purpose |
|---|---|---|---|
ref | ✅ | ✅ | Reuse an endpoint defined elsewhere by name |
fanout | – | ✅ | Send every message to all listed endpoints; one may reply |
switch | – | ✅ | Content-based routing on a metadata value or an expression |
request | – | ✅ | Call a request/reply endpoint, forward the response onward |
response | – | ✅ | Reply to the origin of the current request |
reader | – | ✅ | Use an incoming message as a trigger to pull from a consumer |
static | ✅ | ✅ | Fixed, pre-rendered message |
stream_buffer | ✅ | ✅ | Correlation-partitioned in-memory stream |
null | – | ✅ | Discard everything |
custom | ✅ | ✅ | Your own endpoint via a registered factory |
They live under src/endpoints/structural/, and each of the variants above carries
"format": "structural_endpoint" in the generated JSON schema (mq-bridge.schema.json),
so external tooling can tell them apart from the transport endpoints.
ref
Reuses an endpoint registered under a name, instead of repeating its configuration.
The name is a registry key, not a topic name. Register it from Rust before starting the routes:
use mq_bridge::models::Endpoint;
use mq_bridge::route::register_endpoint;
register_endpoint("common_queue", Endpoint::new_memory("shared_memory_topic", 100));
enrich:
input: { ref: "common_queue" }
output: { nats: { subject: "enriched", url: "nats://localhost:4222" } }
A route can also publish its own output under a name with
Route::register_output_endpoint(Some("name")), which is how one route’s output becomes
another’s input.
The value is a bare string. Resolution looks in the endpoint registry first, then in
registered publishers. Middleware on the ref itself is applied outside the referenced
endpoint’s own middleware. Circular references are detected and rejected at startup, and
nesting depth is bounded.
fanout
Publishes each message to every listed endpoint. Output only.
output:
fanout:
- kafka: { topic: "audit", url: "localhost:9092" }
- file: { path: "audit.jsonl" }
- nats: { subject: "audit", url: "nats://localhost:4222" }
The value is a plain list of endpoints, each of which may have its own middleware and may itself be structural. All branches receive the same message.
A fan-out can also reply. If one of the branches produces a response — a
response or static leg, or a request whose
forward_to replies — that response is returned to the caller, so a request/reply input can
fan its message out and still answer. Branches that must not answer need a forward_to that
does not reply ({} is the null endpoint, which discards).
# Mirror every call to staging, but answer the caller from production only.
proxy:
input: { http: { url: "0.0.0.0:8443", path: "test" } }
output:
fanout:
- request:
to: { http: { url: "http://127.0.0.1:1444/" } }
forward_to: {} # discard staging's response
- request:
to: { http: { url: "http://127.0.0.1:1445/" } }
forward_to: { response: {} } # only this one replies to the caller
A caller has one reply channel, so only one branch may answer a given message: if several do, the first in list order wins and the others are dropped. That is a configuration mistake which would otherwise repeat on every message, so the route warns once and logs later drops at debug level.
A branch that fails nacks the whole fan-out: every branch is delivered at-least-once, so
the answering branch’s response is discarded and the caller gets a 500 rather than an answer
that hides a lost message. That is stricter than nginx’s mirror, which ignores failed mirror
subrequests entirely.
The mirror pattern above is unaffected, because a request branch with a non-replying
forward_to absorbs its own failure — that is where nginx’s “ignore the mirror” semantics
live, opted into per branch. For a plain branch (- kafka: { … } directly in the list) the
equivalent is a dlq on that branch: the failure is parked, the branch acks, and the
answering branch still replies.
proxy_with_parked_mirror:
input: { http: { url: "0.0.0.0:8443", path: "test" } }
output:
fanout:
- kafka: { topic: "audit", url: "localhost:9092" }
middlewares:
- dlq: { endpoint: { file: { path: "audit-failures.jsonl" } } }
- request:
to: { http: { url: "http://127.0.0.1:1445/" } }
forward_to: { response: {} }
switch
Content-based routing: picks one destination per message. Two modes, and a switch uses
exactly one of them — naming both, or neither, is a startup error.
| Field | Type | Required |
|---|---|---|
metadata_key | string | value-lookup mode |
cases | map value → Endpoint | value-lookup mode |
when | list of { if, to } | predicate mode |
default | Endpoint | no |
Value lookup matches a metadata value exactly. It is a HashMap get and never reads the payload, so prefer it when the routing key already is metadata.
output:
switch:
metadata_key: "http_status_code"
cases:
"200": { nats: { subject: "ok", url: "nats://localhost:4222" } }
"404": { file: { path: "not-found.jsonl" } }
default: { file: { path: "other.jsonl" } }
Predicate mode routes on an expression, so it can branch on payload content directly. Cases are evaluated in order and the first match wins, which is what makes overlapping thresholds safe to write:
output:
switch:
when:
- if: "amount > 100"
to: { kafka: { topic: "large-orders", url: "localhost:9092" } }
- if: "amount <= 100"
to: { nats: { subject: "small-orders", url: "nats://localhost:4222" } }
default: { file: { path: "unrouted.jsonl" } }
if takes the same expression language as the filter middleware — payload
fields by bare name (amount, order.status), metadata under meta. and always as text
(number(meta.http_status_code) >= 400), and/or or &&/||. Predicate mode therefore
needs the filter feature; a when list in a build without it is a startup error, not a
silent fallback. A payload the expression cannot read fails the send rather than dropping the
message silently. As with filter, indexed payload paths such as items[0].qty are unsupported
and are rejected at startup.
In either mode, a message that matches nothing goes to default; without a default it is
dropped with a warning. Value lookup is the cheaper mode and stays the right choice when the
key is already in metadata — for payload-derived keys you can either promote the value into
metadata first (for example with transform’s on_error: pass_through, which
sets mqb.transform_error) or just use when.
request
Sends each message to a request-capable endpoint and forwards the response somewhere else, turning a request/reply exchange into a one-way flow.
| Field | Type | Required |
|---|---|---|
to | Endpoint (request-capable) | yes |
forward_to | Endpoint | yes |
output:
request:
to: { http: { url: "https://api.internal/score" } }
forward_to: { ibmmq: { queue: "RESULTS", url: "mq(1414)", queue_manager: "QM1", channel: "APP.SVRCONN" } }
to must support request/reply: http, or a nats/mongodb/memory endpoint with
request_reply: true. On error or timeout the original message is forwarded instead of a
response, so nothing is lost — distinguish the two downstream with a switch on a
status key such as http_status_code.
For batch input, requests still run individually because each needs its own reply. They run
concurrently unless to requires ordered publishing, in which case they are issued one at a
time in source order. Their responses and error fallbacks are restored to input order and
passed to forward_to in one send_batch call. Batch-capable sinks such as MongoDB can
therefore use their native bulk write for the forwarding leg.
Whatever forward_to returns is passed back up. A plain sink acks, forward_to: {} (the
null endpoint, also spelled null) discards, and forward_to: { response: {} } replies to
the origin of the current request — which is how a fanout branch answers the
caller.
The error fallback never becomes that reply: when forward_to would answer the caller, a
failed request surfaces the error instead of echoing the original back as a success. The route
then nacks (HTTP 500), and a retry or dlq middleware on the endpoint
sees the failure as usual. Forwarding-to-a-sink still acks, so the switch pattern above is
unchanged.
response
Replies to the origin of the current request. Output only, and the recommended way to build request/reply routes.
http_echo:
input: { http: { url: "0.0.0.0:8080" } }
output: { response: {} }
Takes no options. Requires an input that carries a reply channel (http, websocket, grpc,
or a request/reply nats/mongodb/memory). With an http or websocket input and no
middleware, response (and static) enables an inline fast path that skips the normal route
pipeline. See README.md.
reader
An output endpoint that ignores the incoming payload and instead reads one message from the wrapped consumer, returning it as the response. The inbound message is purely a trigger.
# HTTP GET pulls the next message off a Kafka topic.
poll_api:
input: { http: { url: "0.0.0.0:8080", method: "GET" } }
output:
reader:
kafka: { topic: "queue", url: "localhost:9092" }
The value is a single nested endpoint, which must be valid as a consumer. The message read is acknowledged immediately, before the caller has necessarily received it — so a crash in between loses it. Use it for polling APIs, not for guaranteed delivery.
static
A fixed, pre-rendered message. Usable as an output (a constant reply) or an input (a constant source).
| Field | Type | Default |
|---|---|---|
body | string | required |
raw | bool | false |
metadata | map string→string | {} |
Accepts either a bare string or the full map form:
output: { static: "OK" } # shorthand, body JSON-encoded
output:
static:
body: '{"status":"ok"}'
raw: true # send verbatim, do not JSON-encode
metadata: { content-type: "application/json" }
raw: true sends body byte-for-byte; the default JSON-encodes it as a string. Like
response, a static output enables the HTTP inline fast path.
Placeholders
body is a template compiled once at startup; rendering a message never re-parses it.
Tokens use the ${namespace:selector} form:
| Token | Resolves to |
|---|---|
${payload:a.b.c} | a field of the incoming JSON payload (dotted path; array indices allowed) |
${metadata:key} | a metadata value |
${message:id} | the message id (UUID string) |
${gen:uuid} | a fresh UUID v7 |
${gen:now} / ${gen:timestamp} | current time (RFC3339 UTC / Unix epoch ms) |
${gen:counter} | a per-endpoint counter, starting at 0 |
${gen:random(1,100)} | a random integer in [min, max] |
${env:VAR} | an environment variable, resolved once at startup |
payload/metadata/message read the request, so they are the useful ones on an output
(e.g. an error reply that echoes the request); on an input (load-test source) only
gen/env produce values. When the body’s content-type metadata is a JSON type,
interpolated request values are JSON-escaped by default so external data cannot break the
structure — append | raw to a token to splice it verbatim. To emit a literal, un-interpolated
${…}, write $${…} (a bare $$ is left as-is); any ${…} with an unknown namespace is also
left untouched.
output:
static:
body: '{"error":"not found","id":"${message:id}","at":"${gen:now}"}'
raw: true
metadata: { content-type: "application/json" }
stream_buffer
An in-memory stream partitioned by correlation ID, used to carry streaming request/response bodies between routes.
| Field | Type | Notes |
|---|---|---|
topic | string | required; shared by publisher and consumers |
correlation_id | string | required on consumers, must be unset on publishers |
capacity | integer | default 100, per partition |
output:
stream_buffer: { topic: "responses" } # publisher: no correlation_id
input:
stream_buffer: { topic: "responses", correlation_id: "req-123" } # consumer
A consumer without correlation_id is a startup error; a publisher with one logs a warning
and ignores it. Primarily wired up via HttpConfig::stream_response_to.
null
Discards every message. Output only. This is the default output when a route omits one.
drain:
input: { kafka: { topic: "noisy", url: "localhost:9092" } }
output: null # a bare YAML null
Spelling trap: it is a bare YAML
null(or~, or the explicitnull: null).null: {}does not parse. Omittingoutput:entirely gives the same result.
Useful for consume-and-handle routes where a handler does the work and there is nothing to forward, and for benchmarking an input in isolation.
custom (endpoint)
Delegates to a factory you registered programmatically.
| Field | Type | Required |
|---|---|---|
name | string | yes |
config | any JSON | yes |
output:
custom:
name: "my_sink"
config: { target: "internal://thing" }
Implement CustomEndpointFactory and register it before starting routes. Once registered,
the name also works as a bare endpoint key — input: { my_sink: {...} } — since any
unrecognised key is looked up in the custom-endpoint registry. Use the explicit custom:
form above if you validate configs against mq-bridge.schema.json, which cannot know your
key. Endpoints can also be written in Python (register_endpoint) or JavaScript
(registerEndpoint). See EXTENDING.md for the full guide.
See also
- README.md — overview, data endpoints, request/response and CQRS patterns
- CONFIGURATION.md — full YAML examples, env vars, TLS, IDE schema validation
- DELIVERY.md — delivery guarantees, per-source identity, per-sink idempotency
- ARCHITECTURE.md — internals, batching/concurrency, extension traits
- EXTENDING.md — writing your own endpoint or middleware, in Rust, Python or Node
Configuration Guide
mq-bridge uses a flexible configuration system supporting YAML, JSON, and environment variables.
Configuration Reference
The best way to understand the configuration structure is through a comprehensive example. mq-bridge uses a YAML map where keys are route names.
# mq-bridge.yaml
# Route 1: Kafka to NATS
kafka_to_nats:
concurrency: 4
input:
kafka:
url: "localhost:9092"
topic: "orders"
group_id: "bridge_group"
# TLS Configuration (Optional)
tls:
required: true
ca_file: "./certs/ca.pem"
output:
nats:
url: "nats://localhost:4222"
subject: "orders_stream.processed"
stream: "orders_stream"
# Route 2: HTTP Webhook to MongoDB with Middleware
webhook_to_mongo:
input:
http:
url: "0.0.0.0:8080"
# Force the normal route pipeline instead of the inline HTTP response fast path.
inline_response_fast_path: false
middlewares:
- retry:
max_attempts: 3
initial_interval_ms: 500
output:
mongodb:
url: "mongodb://localhost:27017"
database: "app_db"
collection: "webhooks"
format: "json" # a bit slower, but better readability
# Route 3: File to AMQP (RabbitMQ)
file_ingest:
input:
file:
path: "./data/input.jsonl"
output:
amqp:
url: "amqp://localhost:5672"
exchange: "logs"
queue: "file_logs"
# Route 4: AWS SQS to SNS
aws_sqs_to_sns:
input:
aws:
# To consume from SNS, subscribe this SQS queue to the SNS topic in AWS Console/Terraform.
queue_url: "https://sqs.us-east-1.amazonaws.com/000000000000/my-queue"
region: "us-east-1"
# Credentials (optional if using env vars or IAM roles)
access_key: "test"
secret_key: "test"
output:
aws:
topic_arn: "arn:aws:sns:us-east-1:000000000000:my-topic"
region: "us-east-1"
# Route 5: IBM MQ Example
ibm_mq_route:
input:
ibmmq:
queue_manager: "QM1"
url: "localhost(1414)"
channel: "DEV.APP.SVRCONN"
queue: "DEV.QUEUE.1"
username: "app"
password: "admin"
output:
memory:
topic: "received_from_mq"
# Route 6: MQTT to Switch (Content-based Routing)
iot_router:
input:
mqtt:
url: "mqtt://localhost:1883"
topic: "sensors/+"
qos: 1
output:
switch:
metadata_key: "sensor_type"
cases:
temp:
kafka:
url: "localhost:9092"
topic: "temperature"
default:
memory:
topic: "dropped_sensors"
# Route 7: ZeroMQ PUSH/PULL
zeromq_pipeline:
input:
zeromq:
url: "tcp://0.0.0.0:5555"
socket_type: "pull"
bind: true
output:
zeromq:
url: "tcp://localhost:5556"
socket_type: "push"
bind: false
# format: "raw_framed" # default: raw payload bytes with a JSON metadata frame in front, so headers still travel
# format: "raw" # raw payload bytes per frame, no metadata (e.g. JPEG, Protobuf)
# format: "json" # JSON-wrapped CanonicalMessage, whole batch in one frame
# backend: "try_omq" # default: use the omq backend when built in, else zmq. Or pin "omq" / "zmq".
# NOTE: REQ/REP replies ignore `format`. A "rep" consumer always answers with a JSON
# array of canonical messages and a "req" publisher always decodes one, even under
# "raw"/"raw_framed" — there `format` still frames the request only. An external REP
# service answering a mq-bridge "req" endpoint has to reply in that JSON shape.
# Route 8: PostgreSQL via SQLx
sqlx_postgres_route:
input:
sqlx:
url: "postgres://user:pass@localhost:5432/mydb"
table: "job_queue"
delete_after_read: true
output:
memory:
topic: "processed_jobs"
# Route 9: Cross-process IPC via the memory endpoint
# The `topic` field (alias `url`) is a transport URL:
# "name" -> memory://name (in-process, same process only)
# "memory://name" -> in-process channel
# "ipc://name" -> Unix: /run/mq-bridge/name.sock (falls back to
# $XDG_RUNTIME_DIR/mq-bridge, then /tmp/mq-bridge)
# Windows: \\.\pipe\mq-bridge-name
# "ipc:///abs/path.sock" -> that exact socket path (Unix)
# "unix:///abs/path.sock" -> Unix only, path must be absolute
# "pipe://name" -> Windows only, \\.\pipe\name
# The consumer side binds/listens and must be started before the publisher connects.
# IPC does not support `subscribe_mode` or `request_reply`.
# `enable_nack` defaults to true, but redelivery is consumer-local: a nacked message
# is retried inside the consumer and is lost if the consumer process dies.
ipc_ingest:
input:
memory:
url: "ipc:///run/mq-bridge/orders.sock"
capacity: 256
output:
kafka:
topic: "orders"
url: "localhost:9092"
Configuration Details
Environment Variables
All YAML configuration can be overridden with environment variables. The mapping follows this pattern:
MQB__{ROUTE_NAME}__{PATH_TO_SETTING}
For example, to set the Kafka topic for the kafka_to_nats route:
export MQB__KAFKA_TO_NATS__INPUT__KAFKA__TOPIC="my-other-topic"
Postgres CDC example
orders_cdc:
input:
postgres_cdc:
url: "postgres://user:pass@localhost:5432/app"
publication: "orders_pub" # CREATE PUBLICATION orders_pub FOR TABLE orders;
slot_name: "mqb_orders" # created if missing (permanent slot, resumable)
output:
nats:
subject: "orders.changes"
url: "nats://localhost:4222"
Each change arrives as a CanonicalMessage whose payload is the flat row and whose postgres.operation metadata marks the operation — the same convention as MongoDB CDC, so typed handlers work identically across both. The replication transport uses the published pgwire-replication crate.
NATS JetStream Notes
Two gotchas worth knowing before wiring up a nats endpoint:
- Subject must be prefixed with the stream name. When mq-bridge auto-creates
a JetStream stream (no existing stream already covers the subject), it scopes
the stream to
{stream}.>. Sostream: "orders_stream"requires a subject likeorders_stream.foo— a subject such asorders.foowill fail to publish with “no stream found for given subject”. This only applies to auto-creation; publishing to a stream that already exists with a wider subject filter works regardless of naming. streamis required even in Core NATS mode. Consumer validation requires astreamvalue even whenno_jetstream: true. It’s unused for the actual Core NATS subscribe, but validation still rejects a missing value — pass any placeholder string.
Middleware Configuration
Every available middleware, with its fields, defaults, supported side (input/output) and a working example, is listed in REFERENCE.md. Note in particular the ordering rule: on an output, the last middleware in the list is the outermost layer, so
dlqgoes last.
Middleware is defined as a list under an endpoint.
input:
middlewares:
- retry:
max_attempts: 5
initial_interval_ms: 200
- dlq:
endpoint:
nats:
subject: "my-dlq-subject"
url: "nats://localhost:4222"
- deduplication:
sled_path: "/var/data/mq-bridge/dedup_db"
ttl_seconds: 3600 # 1 hour
kafka:
# ... kafka config
TLS & Security Hardening
Most endpoints accept a tls block. The available fields are:
tls:
required: true # enable TLS
ca_file: "./certs/ca.pem" # CA to verify the server
cert_file: "./certs/client.pem" # client cert (mTLS)
key_file: "./certs/client.key" # client private key (mTLS)
cert_password: "secret" # password for an encrypted key (where supported)
accept_invalid_certs: false # NEVER set true in production
Hardening checklist (e.g. for PCI-DSS Req 4.2.1):
- Enable TLS on every endpoint carrying sensitive data (
required: true) and supply aca_file. Use mTLS (cert_file+key_file) for mutual authentication where the broker supports it. - Never disable certificate validation.
accept_invalid_certsdefaults tofalse; leaving it that way is required — setting ittrueon a sensitive path defeats TLS. - Choose the crypto provider feature. Build with
rustls-aws-lc(FIPS-capable, also enables post-quantum key exchange) orrustls-ring. The rustls-based endpoints — NATS, MQTT, HTTP, gRPC, WebSocket, AMQP — only ever negotiate rustls’s safe TLS 1.2/1.3 AEAD cipher suites; weak/legacy suites (RC4, 3DES, CBC-SHA1, export) cannot be offered, so “strong ciphers only” holds without any explicit cipher list. - Kafka (librdkafka/OpenSSL): certificate verification is on by default
(
enable.ssl.certificate.verificationfollowsaccept_invalid_certs). If an auditor requires an explicit allowlist, pin it viaproducer_options/consumer_options:kafka: producer_options: [["ssl.cipher.suites", "ECDHE-RSA-AES256-GCM-SHA384"]] consumer_options: [["ssl.cipher.suites", "ECDHE-RSA-AES256-GCM-SHA384"]] - IBM MQ (native stack): set a strong
tls.cipher_spec(a TLS 1.2/1.3 CipherSpec) — it is required for encrypted connections. Notecipher_speclives undertls, not at the top level of theibmmqconfig (a breaking rename from earlier releases, where it wasibmmq.cipher_spec). - Keep sensitive payloads out of logs. Message payloads are emitted at
tracelevel; run production abovetraceand confirm no cardholder data (PAN) reaches logs or traces. - Do not commit secrets. Source passwords and tokens from a secrets manager or env vars
(
MQB__...) rather than checked-in config.
Notes and boundaries:
- TLS 1.3 alone is sufficient in 2026 (Mozilla “Modern” profile). The rustls endpoints currently negotiate TLS 1.2 and 1.3 (both are PCI-acceptable). A central “TLS 1.3-only” toggle is not yet configurable in the library; enforce a minimum protocol version on the broker/server side, which is the side that accepts the connection.
- Kafka and IBM MQ use native TLS stacks, so a library-wide version policy cannot be applied to them — configure their minimum TLS version on the broker.
HTTP Consumer Fast Path
Compatible http -> response routes may use an inline response fast path for lower latency. This bypasses the normal route consumer/worker/disposition pipeline, but it still keeps the output publisher chain active, including output handlers and allowed output middlewares.
The fast path is only considered when:
- the input has no middlewares
receive_streamableisfalsefire_and_forgetisfalse- output middlewares are limited to
buffer,delay,limiter, and/ormetrics
To force the normal route pipeline, set this on the HTTP consumer:
input:
http:
url: "0.0.0.0:8080"
inline_response_fast_path: false
This is useful when you want stable, explicit semantics regardless of future optimizations, or when you want to avoid the inline path’s response behavior differences. In particular, the inline path does not automatically echo unchanged request metadata back as HTTP response headers.
For HTTP publishers, pass_through_status: true treats non-2xx response statuses as response
data instead of publisher errors. On a non-streaming HTTP request/reply route, it also keeps the
listener running after a transient sink failure and returns HTTP 502 to the request. For composite
outputs such as fanout, every leaf sink must opt in; mixed outputs retain the normal
stop-and-reconnect policy. Streamable HTTP inputs retain their protocol-specific error frames;
neither they nor fire_and_forget consumers use this 502 behavior.
Connection Sharing
Publishers that target the same server reuse one underlying transport client by default, instead of each opening its own. This consolidates TCP connections, background threads, and batching, and follows each driver’s own guidance (one shared producer / client / pool per application). Sharing applies to Kafka, NATS, MongoDB, SQLx, HTTP, and gRPC; the client is keyed by its connection-level settings (URL, auth, TLS, and client-level options), never by topic/subject/collection. A shared client is released once the last publisher using it is dropped.
Set shared: false on a publisher to give it a dedicated connection:
orders_out:
output:
kafka:
topic: "orders"
url: "localhost:9092"
shared: false # dedicated producer — keeps this latency-sensitive topic off a busy producer's queue
- Kafka: a single producer serves every topic and is the recommended setup. Use
shared: falseto isolate a latency-sensitive topic from a high-throughput one so they don’t share one internal send queue (head-of-line blocking). - SQLx: a shared pool means its
max_connectionsis a budget shared across every route using that database. Useshared: falseif a route needs its own pool. - gRPC: a shared channel multiplexes over one HTTP/2 connection; at very high
concurrency its max-concurrent-streams cap can bottleneck —
shared: falsegives a dedicated channel.
Dynamic gRPC sources
The stable generated mqbridge.Bridge protocol remains the default. To call an
arbitrary unary or server-streaming gRPC method, provide a compiled protobuf descriptor
set plus the service, method, and JSON request:
input:
grpc:
url: https://grpc.example.com:443
descriptor_set_path: proto/events.bin
service_name: events.EventService
method_name: Tail
request:
topic: audit
The deprecated timeout_ms and server_streaming configuration keys are still accepted:
timeout_ms is a fallback for connection and request setup, while a dynamic stream’s idle and
overall deadlines require their dedicated keys.
Generate the descriptor with imports included:
protoc --descriptor_set_out=proto/events.bin --include_imports -I proto proto/events.proto
Responses use protobuf’s canonical JSON representation as the canonical message payload.
Dynamic mode derives unary versus server-streaming behavior from the descriptor; client-streaming
and bidirectional-streaming methods are rejected with explicit capability errors. A descriptor
describes the wire format but does not define broker acknowledgement
semantics, so dynamic sources have no generic ACK operation. Use the built-in
mqbridge.Bridge mode when route-level ACK/NACK and at-least-once delivery are required.
The same descriptor keys on a route’s output call a method instead of reading one: unary
methods send one call per message, client-streaming methods stream a whole batch into one call,
and request is rejected because the published messages are the requests.
See the complete gRPC integration guide for reflection, descriptor bytes, metadata and authentication, separate deadlines, canonical protobuf JSON, TLS/mTLS, external client generation, delivery guarantees, and the intentional generic-server boundary.
Specialized Endpoints
This section covers
switchin depth. The other structural endpoints —ref,fanout,request,response,reader,static,stream_buffer,nullandcustom— are documented in REFERENCE.md.
Switch
The switch endpoint is a conditional publisher that routes messages to different outputs based on a metadata key.
It checks the specified metadata_key in each message. If the key’s value matches one of the cases, the message is forwarded to that endpoint. If no case matches, it’s sent to the default endpoint. If there is no default, the message is dropped.
This is useful for content-based routing.
Example: Route orders to different systems based on country_code metadata.
output:
switch:
metadata_key: "country_code"
cases:
US:
kafka:
topic: "us_orders"
url: "kafka-us:9092"
EU:
nats:
subject: "eu_orders"
url: "nats-eu:4222"
default:
file:
path: "/var/data/unroutable_orders.log"
IDE Support (Schema Validation)
mq-bridge includes a JSON schema for configuration validation and auto-completion.
- Ensure you have a YAML plugin installed (e.g., YAML for VS Code).
- Configure your editor to reference the schema. For VS Code, add this to .vscode/settings.json:
{
"yaml.schemas": {
"https://raw.githubusercontent.com/marcomq/mq-bridge/main/mq-bridge.schema.json": ["mq-bridge.yaml", "config.yaml"]
}
}
To regenerate the schema from this repo, run: cargo test --features schema
CLI commands
mqb is a single headless binary with three modes: config mode (the default —
run a long-lived bridge, optionally serving the browser UI), the copy subcommand (an
ad-hoc one-route job), and the mcp subcommand (expose the bridge as MCP tools).
mqb [OPTIONS] # config mode
mqb copy SOURCE TARGET [COPY OPTIONS] # one-route ad-hoc job
mqb mcp [MCP OPTIONS] # MCP server
Config mode (default)
Run with no subcommand to load a config and run a bridge; with no config at all it starts empty and offers to serve the UI so you can build one interactively.
mqb --config config.yml
mqb --config config.yml --init-config dev/config/file-to-http.yml
mqb --ui # start empty, define config.yml in the UI
| Option | Meaning |
|---|---|
-c, --config <path> | Config file to load and save (the UI writes back here). |
-i, --init-config <path> | Initialize from a template file only if the main config doesn’t exist yet. |
--init-config-str <str> | Initialize from an inline config string if the main config doesn’t exist yet. |
--config-str <str> | Inline config that overrides the config file. |
--ui | Serve the browser UI on the default port without asking — see Starting the web UI. |
--no-ui | Never serve the browser UI, and don’t ask. |
--metrics-addr <addr> | Serve the Prometheus endpoint on addr (default 127.0.0.1:9090), overriding metrics_addr from the config. |
--no-metrics | Don’t serve the Prometheus endpoint on its own port. |
--schema <path> | Write the JSON Schema for AppConfig (use - for stdout) and exit. |
--plugin <path> | Load a native endpoint/middleware library before starting. Repeatable, valid on every subcommand, and combines with plugins: in the config — see Native plugins. |
Config is hierarchical (files + environment variables) — see Configuration grammar.
Starting the web UI
The UI is a control surface, so its port is never opened implicitly. What happens in config mode depends on where the address comes from:
| Situation | Result |
|---|---|
ui_addr set in the config | Served on that address — configuring it is the consent |
No ui_addr, --ui passed | Served on 0.0.0.0:9091 |
No ui_addr, --no-ui passed | Not served, no prompt |
No ui_addr, interactive terminal | Asks Start the web UI on 0.0.0.0:9091? [y/N] — anything but y/yes declines |
No ui_addr, no terminal (script, service, CI) | Not served. Pass --ui to opt in |
The last row is the important one: a run started by a script or a service unit never puts the UI on the network by accident. Nothing about the bridge itself is gated — configured routes run either way.
In a container the calculation is reversed, because nothing is reachable until
you publish it. The Docker image’s CMD therefore asks for the UI on your
behalf — see Ports in containers.
The metrics endpoint
Metrics are always collected, and always available at /metrics on the web UI
when it runs. Separately, config mode serves a standalone Prometheus endpoint on
127.0.0.1:9090 by default.
It defaults to loopback rather than 0.0.0.0 because, while the endpoint is
read-only, it still describes the routes and endpoint types in use — a bare run
on a workstation shouldn’t publish that to the local network. Scraping from
another host is an explicit choice:
mqb --config config.yml --metrics-addr 0.0.0.0:9090 # scrapeable
mqb --config config.yml --no-metrics # no separate port at all
metrics_addr in the config does the same thing; the flag overrides it.
copy — ad-hoc one-route job
Builds a single route from two endpoint URIs and runs it headlessly (no web UI). The scheme selects the endpoint and query parameters set its config.
# DB → DB, drain the source table then exit (exit code 0 on success)
mqb copy \
'postgres://user:pass@localhost/db?table=src' \
'postgres://user:pass@localhost/db?table=dst' \
--drain
# Queue → DB as a continuous bridge (runs until Ctrl-C; omit --drain)
mqb copy \
--from 'nats://localhost:4222?subject=orders' \
--to 'postgres://user:pass@localhost/db?table=orders'
| Flag | Default | Meaning |
|---|---|---|
SOURCE TARGET | required | Positional source and destination endpoint URIs. |
--from <uri> --to <uri> | — | Backward-compatible alternative to the positional form. |
--filter <expr> | off | Retain messages for which the expression is true. Top-level JSON scalar fields are variables. |
--resume | off | Configure the source’s safe native resume mechanism, or fail before route startup. |
--drain | off | Exit once the source yields an empty batch. Without it, copy runs as a continuous bridge until Ctrl-C. |
--concurrency <N> | 4 | Route concurrency. |
--batch-size <N> | 1024 | Batch size. |
Note:
copy’s defaults (--concurrency 4,--batch-size 1024) are higher than the library’s route defaults (concurrency: 1,batch_size: 512), becausecopyis built for bulk throughput. See Performance tuning.
Resumable copies
By default a bounded copy re-reads the whole source every run. --resume derives a stable
state identity from the credential-redacted source, destination, and filter, then maps it to
the source’s existing mechanism. Changing any of those pipeline semantics starts new state;
rotating a password does not.
mqb copy \
'postgres://user:pass@localhost/app?table=orders&cursor_column=id' \
'file:///data/orders.jsonl' \
--resume \
--drain
Currently supported mappings are Kafka consumer groups, MongoDB capture_all/capture_new
cursors, persistent Postgres CDC slots, SQL cursor readers with an explicit monotonic
cursor_column, and ClickHouse/object-store cursor readers with their required explicit
external checkpoint_store. Explicit group_id, cursor_id, slot_name, and
checkpoint_store URI settings remain advanced overrides.
File offsets are deliberately not accepted yet because partial batch failure can advance the
current file offset past a failed record. NATS is also rejected because its generated durable
consumer name cannot currently include the destination and filter. Other non-replayable sources,
including MQTT, fail early instead of silently ignoring --resume. Full checkpoint details:
Checkpoints & resumable copies.
Filtering
--filter is evaluated by mq-bridge after the source read and before URI-configured transform
middleware. It is not translated to SQL or MongoDB, so connector-native predicates remain a
separate optimization and keep their existing behavior.
mqb copy \
'kafka://localhost:9092?topic=orders' \
'postgres://localhost/app?table=orders' \
--filter 'country == "DE" && amount >= 50' \
--resume
An expression reads payload fields by bare name, including nested paths (order.status), and
message metadata under the reserved meta. prefix (meta.kind). Metadata is always text, so a
numeric comparison there needs a cast: number(meta.retry_count) < 3.
A true result continues to the destination. A false result is an intentional successful drop
and advances the source acknowledgement/checkpoint. Invalid expressions and payloads that are
not a JSON object are errors. A referenced field that is absent, null, or holds an array or
object counts as no match instead, the way a SQL WHERE treats NULL, so one heterogeneous
record does not end a copy that is otherwise running fine. The first such field is logged once
as a warning, so a typo in the expression does not simply look like an empty source.
Object naming under a filter
An object_store sink names each object after the contiguous source range it covers whenever
name_by resolves to source_position — which is what auto picks for any source that stamps
a replay position. A filter leaves holes in every batch, and each hole starts another object:
measured at 159,474 objects instead of 977 on a 1M-row copy, a ~220x drop in throughput.
So when a row-dropping middleware is present — --filter, deduplication, weak_join, or a
transform with on_error: reject on either endpoint — and the sink is still on name_by: auto,
the route resolves it to write_time instead and warns that it did. mq-bridge applies this to
every route, so a switch in when mode with no default and a config-defined route get the
same treatment. That has two consequences
worth knowing:
- Objects are named by uuidv7 at write time, so at the default
--concurrency 4their order is the order batches finish encoding, not source order. Order within an object is unchanged. Use--concurrency 1if order across objects matters. - The copy is no longer effectively-once at the sink: a crash mid-batch rewrites those rows under fresh names rather than colliding harmlessly with identical ones.
Pass name_by=source_position explicitly to keep replay-safe names and accept the
fragmentation. An explicit setting — including the deprecated idempotency alias — is never
overridden.
URI grammar
scheme://…?param=a&next=b: the scheme selects the endpoint and query parameters set
its config. Any query key that matches a field of that endpoint’s config becomes endpoint
config; every other query param stays on the connection URL, so driver params pass through
unchanged (e.g. postgres://…/db?table=src&sslmode=disable).
- Schemes:
postgres/postgresql/mysql/mariadb/sqlite→ sqlx,nats→ NATS,mongodb→ MongoDB,redis→ Redis streams,file→ file,s3/gs/az/abfs→ cloud object storage (credentials from the environment), and the rest by name. - Common config params:
table,insert_query(URL-encoded; supports${metadata:<key>}/${payload:<field>}token mapping),delete_after_read,subject,stream,collection,database,format, … — anything on the endpoint’s config struct. - For
nats, the dominant target field can be given as the URL path (nats://localhost:4222/orders≡?subject=orders); the query form wins if both are given. Aredispath is the connection’s database number, so a redis stream target must use?stream=…. - MongoDB sources are non-destructive by default:
consumedefaults tocapture_all, which needs a replica set (a single-node one is enough). On a standalonemongodpass?consume=snapshotfor a one-shot read.?consume=consumeropts into the destructive queue-drain mode.
Middlewares in the URI
Append |-separated middlewares to either URI to wrap that endpoint. They apply in the order
written, and each takes its own config struct’s fields as query params:
mqb copy \
--from 'postgres://user:pass@localhost/db?table=src|retry?max_attempts=5&initial_interval_ms=200' \
--to 'kafka://broker:9092?topic=orders|buffer?max_messages=500&max_delay_ms=50|metrics' \
--drain
- Names:
retry,metrics,dlq,deduplication,transform,delay,limiter,buffer,weak_join,cookie_jar,random_panic,compression,encryption,custom(-is accepted for_). encryption’skeyis a shell-visible argument; prefer${env:VAR}to keep it out of the process list and shell history:|encryption?key=$%7Benv:MQB_KEY%7D.compressionandencryptionproduce binary payloads, so afilesink holding them must useformat=normal.format=json/textrender the payload as a JSON value and it does not survive the round trip (it comes back as a JSON array, and the reader reports a bogus “unsupported encryption envelope version 91”).- Middlewares apply in list order on both ends, so a route that reads back what another wrote
must list them in the reverse order. Writing with
|compression?algorithm=zstd|encryption?key=…reads back with|encryption?key=…|compression?algorithm=zstd. - A middleware with no params needs no
?—|metrics. dlq’sendpointis itself a URL-encoded endpoint URI:|dlq?endpoint=file%3A%2F%2F%2Ftmp%2Ffailed.jsonl.- Object/array fields take a JSON literal:
|weak-join?group_by=cid&expected_count=2&timeout_ms=1000&required=["a","b"]. - A literal
|inside the URI (e.g. in a password) must be written percent-encoded as%7C.
Structural endpoints in the URI
Structural endpoints have no connection of their own — they arrange other endpoints. Their nested endpoints are query params that are themselves endpoint URIs:
| URI | Meaning |
|---|---|
null: | Discards everything. |
static:?body=…&raw=true | A fixed message: a constant source or a constant reply. |
response: | Replies to the caller; needs a source that carries a reply channel (http, websocket). |
fanout:?to=<uri>&mirror=<uri> | Sends every message to each branch, in the order written. |
request:?to=<uri>&forward_to=<uri> | Sends to a request-capable endpoint and forwards the response elsewhere; without forward_to the response is discarded. |
switch:?metadata_key=<key>&case.<value>=<uri>&default=<uri> | Picks one destination by a metadata value. |
switch:?when=<expression>&to=<uri>&default=<uri> | Picks the first destination whose predicate matches. |
A nested URI only needs percent-encoding when it carries &, # or | of its own —
fanout:?to=http://prod.internal/?method=PUT is fine as written, while an inner & must be encoded,
as in fanout:?to=http%3A%2F%2Fprod.internal%2F%3Fmethod%3DPUT%26timeout_ms%3D1000.
switch has the two modes the engine has, and takes one or the other, never both. Value lookup
branches on a metadata value; predicate mode takes when=<expression> / to=<uri> pairs in the
order written, first match wins, using the same expression language as --filter:
mqb copy \
'kafka://localhost:9092?topic=orders' \
'switch:?when=amount > 1000&to=kafka%3A%2F%2Flocalhost%3A9092%3Ftopic%3Dlarge&when=true&to=file%3A%2F%2F%2Fdata%2Frest.jsonl'
An expression travels as a query value, so its == needs no escaping. A literal & would
split the query, so write and / or rather than && / || — the engine accepts both
spellings. A message matching no predicate goes to default, and without a default it is
dropped.
fanout’s two branch kinds differ in what may come back: a to branch is used as written, while
a mirror branch has its response and its failures discarded, so it can neither answer the
caller nor fail the message for the other branches. That makes the mirroring proxy one command —
serve requests, copy each to staging, answer from production:
mqb copy \
--from 'http://0.0.0.0:8080' \
--to 'fanout:?mirror=http://staging.internal/&to=http://prod.internal/'
The engine does not forward a branch’s response through a fan-out yet, so the caller currently gets
202 Acceptedrather than production’s body. The mirroring half works today; a single--to http://prod.internal/(no fan-out) does reply with the real response.
An HTTP source is a listener, so --from http://0.0.0.0:8080 binds that address, and https://
makes it a TLS listener. Its certificate is the tls field, which takes a JSON literal:
mqb copy \
--from 'https://0.0.0.0:8443?tls={"required":true,"cert_file":"cert.pem","key_file":"key.pem"}' \
--to 'http://prod.internal/'
Escape hatch: full connection strings
Any query parameter that isn’t a recognised config field (e.g. sslmode, replicaSet) stays
on the connection URL, so driver options just work. That includes a name shared with an
object-typed config field: ?tls=true reaches the driver, while only an actual JSON literal —
?tls={"required":true,"ca_file":"ca.pem"} — is read as endpoint config.
If you already have a complete connection string, pass it verbatim with ?url=<url-encoded>:
mqb copy \
--from 'mongodb://_/?url=mongodb%3A%2F%2Fuser%3Apass%40host%2Fdb%3Ftls%3Dtrue&collection=orders' \
--to null:
See the Quick start for complete, working copy
commands.
mcp — MCP server
mqb mcp # stdio (local clients)
mqb mcp --transport http --bind 127.0.0.1:9092 # streamable HTTP
| Flag | Default | Meaning |
|---|---|---|
--transport <stdio|http> | stdio | Transport. stdio for local clients (Claude Desktop/Code), http for streamable HTTP over hyper. |
--bind <addr> | 127.0.0.1:9092 | Bind address; --transport http only. |
--report-to-ui | off | Report running routes / publish targets to a local mq-bridge-app UI over a local IPC socket. Only names, connector types, health and counts are sent — never URLs or credentials. |
mcp install / uninstall / status
Register the running binary with local MCP clients so you don’t write the config by hand:
mqb mcp install # every detected client
mqb mcp install --client cursor --local # one client, project-scoped
mqb mcp install --report-to-ui # bake --report-to-ui into the entry
mqb mcp status
mqb mcp uninstall
| Subcommand | Flags | Purpose |
|---|---|---|
install | --client, --local, --report-to-ui, --print-config | Register this binary (its absolute path). |
uninstall | --client, --local | Remove the registration. |
status | --local | Show where it is registered and whether the path is still current. |
--print-config prints the JSON snippet for a client not written directly. Full tool and
message reference is in MCP server.
Language bindings API
The core engine is a Rust library, but the same engine ships as native bindings for Python and Node.js. The Tokio runtime, broker I/O, routing, and batching all stay in Rust; the binding is a thin layer for handlers and configuration.
| Language | Package | Install |
|---|---|---|
| Rust | mq-bridge | cargo add mq-bridge |
| Python | mq-bridge-py | pip install mq-bridge-py |
| Node.js | mq-bridge | npm install mq-bridge |
The core of the library are the MessageConsumer and MessagePublisher traits, found in
mq_bridge::traits.
Config loaders
Constructor names are kept aligned across languages, so a config loader reads the same in
either binding (Python uses snake_case, Node uses camelCase):
| Purpose | Python | Node.js |
|---|---|---|
| Load a route from a YAML/JSON file | Route.from_file | Route.fromFile |
| Load from an in-memory YAML/JSON string | Route.from_str | Route.fromStr |
| Load from a parsed dict / JS object | Route.from_config | Route.fromConfig |
| Build a publisher endpoint | the matching Publisher.* | the matching Publisher.* |
The name argument is optional in both: pass it to select one entry from a
routes:/publishers: document, or omit it to treat the config as a single bare
route/endpoint body.
This is the natural companion to the configuration-first workflow: design and test a route in the UI, export the JSON/YAML, then load that exact config from your code. See the Embed the library tutorial for full examples in each language.
Extending from a binding
Custom endpoints and middleware are not Rust-only. Both bindings expose
register_endpoint / register_middleware (registerEndpoint / registerMiddleware in
Node), with the same batch, ack and request-reply semantics as a Rust CustomEndpointFactory,
and both can load a compiled plugin — mq_bridge.load_endpoint_plugin(path) in Python,
loadEndpointPlugin(path) in Node — so one Rust implementation serves every language.
Registration is process-global, keyed by name, and must happen before a route that names it
starts; a duplicate name is an error rather than a silent replacement.
Full examples in Custom endpoints and Native plugins.
Rust API surface
The Rust crate exposes the full engine. The main types:
Route::new(input, output)— a pipeline from one endpoint to one endpoint, with.with_batch_size(n),.with_handler(h),.add_handler(kind, f),.deploy(name)/.run().Endpoint— protocol adapters (Endpoint::new_memory,Endpoint::null, …) plus the serde-configured variants.Publisher::new(endpoint)— publish into a route’s input or any endpoint.- Handlers:
CommandHandler(1-to-1/1-to-0),EventHandler(terminal 1-to-N), andTypeHandler(dispatches on thekindmetadata field, deserializing payloads). CanonicalMessage— the unified message type all handlers work with;msg!(&value, "kind")builds one with akind.
See Core concepts and Learn the architecture for the handler model, and the Rust docs on docs.rs for the full API.
Notes
- The Python binding holds up under load on the third-party http-arena.com requests-per-second HTTP benchmark (a live leaderboard — rankings shift over time).
- A binding is a thin layer: routing, batching, and broker I/O stay in Rust regardless of which language calls in, so behaviour and reliability match the Rust engine.
IBM MQ Setup
How to build and install mq-bridge-app (CLI/server and the Tauri desktop app)
with IBM MQ support.
IBM MQ is included in the default full feature set. It loads IBM’s native MQ
client library at runtime, only when an IBM MQ endpoint is first used, so the IBM
MQ SDK is not required to build or install mq-bridge-app. The UI auto-detects
whether the running backend was built with IBM MQ and shows or hides the IBM MQ
endpoint type accordingly (via the /features endpoint).
1. Install the IBM MQ client library
You need IBM’s native MQ C client on each machine where an IBM MQ endpoint will run. It is not needed on build-only machines.
-
Download a supported IBM MQ C client for your platform. The
mqisetup instructions link to the x86-64 redistributable clients and the additional Linux and macOS packages available for other architectures. -
Extract or install it, set
MQ_HOMEto the installation directory, and add its native library directory to the platform’s library search path:Linux / macOS
mkdir -p ~/ibm-mq && tar -xzf IBM-MQC-Redist-*.tar.gz -C ~/ibm-mq export MQ_HOME=~/ibm-mq # Linux export LD_LIBRARY_PATH="$MQ_HOME/lib64${LD_LIBRARY_PATH:+:$LD_LIBRARY_PATH}" # macOS (use the library directory present in your MQ package) export DYLD_LIBRARY_PATH="$MQ_HOME/lib64${DYLD_LIBRARY_PATH:+:$DYLD_LIBRARY_PATH}"Windows (PowerShell) — extract the zip, then:
$env:MQ_HOME = "C:\IBM\MQ" $env:Path = "$env:MQ_HOME\bin64;$env:Path"
If the client is installed in the platform’s standard location and is already on the library search path, the loader can find it without these variables.
2. Install mq-bridge-app
The normal/default build already includes IBM MQ support. No additional Cargo feature or IBM MQ build environment is required.
CLI / server (web UI)
cargo install mq-bridge-app
mqb --ui # then open http://localhost:9091
The web UI is embedded directly in the binary, so a plain cargo install serves
the full UI — no static/ folder or extra files to ship.
Desktop app (Tauri)
The desktop crate is not published to crates.io, so install it straight from git.
The committed UI bundle is reused, so no npm build is required:
cargo install --git https://github.com/marcomq/mq-bridge \
mq-bridge-app-desktop
mq-bridge-app-desktop
The desktop build also needs the usual Tauri prerequisites (WebKitGTK + build tools on Linux; Xcode command-line tools on macOS; WebView2 on Windows).
From a local checkout
git clone https://github.com/marcomq/mq-bridge
cd mq-bridge
# CLI / server
cargo install --path apps/mq-bridge-app/crates/cli
# or desktop
cargo install --path apps/mq-bridge-app/crates/desktop
Docker (CLI / server, amd64 only)
A prebuilt IBM MQ image is published as the ibm-mq / latest-ibm-mq tags:
docker run --rm -p 9091:9091 ghcr.io/marcomq/mq-bridge-app:latest-ibm-mq
Or build it yourself (the Dockerfile downloads the MQ client automatically):
docker build -f apps/mq-bridge-app/Dockerfile \
--build-arg ENABLE_IBM_MQ=true -t mq-bridge-app:ibm-mq .
3. Verify IBM MQ is enabled
curl http://localhost:9091/features
# => {"ibm_mq":true, "kafka":true, ...}
When ibm_mq is true, the IBM MQ endpoint type appears in the publisher and
consumer dropdowns in the UI.
4. Configure an IBM MQ endpoint
publishers:
- name: "IBM MQ Publisher"
endpoint:
ibmmq:
connection_manager: "QM1"
queue: "DEV.QUEUE.1"
# ...or a topic instead of a queue:
# topic: "topic://events"
url: "mq-host(1414)"
channel: "DEV.APP.SVRCONN"
username: "app"
password: "${MQ_PASSWORD}"
consumers:
- name: "IBM MQ Consumer"
endpoint:
ibmmq:
connection_manager: "QM1"
queue: "DEV.QUEUE.1"
url: "mq-host(1414)"
channel: "DEV.APP.SVRCONN"
username: "app"
password: "${MQ_PASSWORD}"
Troubleshooting
An IBM MQ route says the client library is unavailable — confirm MQ_HOME
points at the C client installation and its library directory is present in
LD_LIBRARY_PATH (Linux), DYLD_LIBRARY_PATH (macOS), or PATH (Windows).
Make sure you installed the C client, not only the Java client, and that it matches
the application’s architecture.
As app-specific alternatives, set MQ_INSTALLATION_PATH to the client installation
directory or set MQB_IBM_MQ_LIB to the exact native library path. The explicit
path is useful for non-standard layouts.
Runtime: cannot open shared object file: libmqic_r.so (Linux) /
libmqic_r.dylib (macOS) — the loader needs the library at runtime:
# Linux
export LD_LIBRARY_PATH="$MQ_HOME/lib64${LD_LIBRARY_PATH:+:$LD_LIBRARY_PATH}"
# macOS
export DYLD_LIBRARY_PATH="$MQ_HOME/lib64${DYLD_LIBRARY_PATH:+:$DYLD_LIBRARY_PATH}"
On Linux you can make it permanent:
echo "$MQ_HOME/lib64" | sudo tee /etc/ld.so.conf.d/ibm-mq.conf
sudo ldconfig
Connection errors — verify the queue manager is running, the channel/queue
exist, the port (default 1414) is reachable, and credentials are correct. Check the
MQ error logs for detail.
Platform notes
| Platform | Runtime search variable | Typical library directory |
|---|---|---|
| Linux | LD_LIBRARY_PATH | $MQ_HOME/lib64 |
| macOS | DYLD_LIBRARY_PATH | $MQ_HOME/lib64 |
| Windows | PATH | %MQ_HOME%\bin64 |
License
The IBM MQ client library is redistributable under IBM’s own license terms. If you
distribute binaries built with IBM MQ support, include IBM’s license files (from
$MQ_HOME/licenses) and comply with IBM’s redistribution terms.
Further reading
Deploying
The CLI / server form is what you deploy — a single headless binary that runs a long-lived bridge in config mode. This page covers running it as a container and as a service, and the config/secrets patterns that suit each.
Docker
The CLI is published as a multi-arch image (amd64 + arm64):
docker run --rm --name mq-bridge -p 9091:9091 ghcr.io/marcomq/mq-bridge-app:latest
Mount the working directory at /app and seed the config on first run from one of the
templates baked into the image at /config:
touch input.log
docker run --rm --name mq-bridge -p 9091:9091 -v "$(pwd)":/app \
ghcr.io/marcomq/mq-bridge-app:latest --ui --init-config=/config/file-to-http.yml
- The default
latestimage is a plain multi-arch image foramd64andarm64. - IBM MQ support is published separately as the
latest-ibm-mq/ibm-mqtags,amd64only (no redistributable arm64 client yet). Start it with--platform=linux/amd64, or build yourself withcargo build --release --features=ibm-mq.
Ports in containers
On a host, the UI is never opened implicitly and
metrics bind loopback. In a container those defaults would be wrong for the opposite reason —
nothing is reachable until you publish it — so the image’s CMD is --ui, and metrics bind
0.0.0.0:9090 through ENV MQB__METRICS_ADDR rather than the command line: a Kubernetes pod
that sets args: replaces CMD wholesale, and the environment survives that. The container
boundary is the gate: without -p (or a Kubernetes Service), neither port leaves the container.
Docker replaces CMD wholesale as soon as you pass any argument of your own. That is what
keeps the headless modes headless:
The two settings are carried differently, and that difference is the whole design:
| Carried by | Survives an args: / command override? | |
|---|---|---|
Metrics on 0.0.0.0:9090 | ENV MQB__METRICS_ADDR | Yes |
| Web UI | CMD ["--ui"] | No |
Metrics live in ENV because a Kubernetes pod almost always sets args:, which replaces
CMD wholesale. Had the bind address ridden along in CMD, every such pod would silently
fall back to the host default of 127.0.0.1:9090 and go unscrapeable. As an environment
variable it survives any command-line override, and a pod that wants something else just
sets MQB__METRICS_ADDR (or metrics_addr in its ConfigMap).
The UI stays in CMD precisely because it is dropped on override — that is what keeps the
headless modes headless:
| Invocation | Result |
|---|---|
docker run image | Config mode, UI + metrics served |
docker run image copy … / mcp … | CMD dropped — headless, as those modes always are |
docker run image --config /app/x.yml | CMD dropped — add --ui if you want the UI |
Kubernetes with args: […] | No UI; metrics still served |
Kubernetes with no args | UI + metrics served, reachable only via a Service |
The second docker run example above passes --init-config, which is why it also passes
--ui.
To build the image from source, see
BUILD.md.
Configuration in containers / Kubernetes
Configuration is hierarchical — files plus environment variables — which is exactly what container and Kubernetes deployments want:
- Bake a base
config.ymlinto the image or mount it as a ConfigMap. - Override any field per environment with
MQB__{ROUTE}__{PATH}env vars (double underscores between segments), e.g.MQB__KAFKA_TO_NATS__INPUT__KAFKA__TOPIC=my-topic. - Reference secrets inline with
${ENV_VARIABLE_NAME:-default}; a.envfile in the working directory is auto-loaded for local development.
See Configuration grammar for the full env-var mapping and Secrets & interpolation for keeping credentials out of committed config.
Choosing the run shape
| You want… | Run it as… |
|---|---|
| A one-shot batch move (finite source), exit 0 on success | copy … --drain (see Quick start) |
| A long-lived bridge with one or more routes | config mode: mqb --config config.yml |
| The bridge driven by an LLM agent | mcp mode |
In config mode the CLI can also serve the browser UI on the configured port, but never
implicitly: it needs ui_addr in the config or an explicit --ui. An unattended start — a
service unit, a container, a script — is headless unless you asked for the UI, so production
deployments opt in rather than opt out. Where you do serve it, front it appropriately.
See Starting the web UI.
Security checklist for production
- TLS on every sensitive endpoint (
tls.required: true+ca_file, mTLS where supported). Never setaccept_invalid_certs: true. Pick the crypto provider feature (rustls-aws-lcfor FIPS-capable / post-quantum, orrustls-ring). - Keep payloads out of logs: run above
tracelevel (payloads log attrace). - Do not commit secrets: source them from a secrets manager or env vars.
- Consider the config security modes (plain, extracted secrets, encrypted config, encrypted history) based on the runtime target and available key storage.
Full hardening notes (including the PCI-DSS-oriented checklist) are in the TLS & security hardening section.
Observability
Wire up the metrics middleware and scrape the Prometheus endpoint; ship
the JSON logs to your aggregator. See Observability & metrics.
Continuous deployment of this book
The book is published to GitHub Pages by
.github/workflows/docs.yml
on every push to main that touches the root engine docs or
apps/mq-bridge-app/dev/docs/**. It runs the local
apps/mq-bridge-app/dev/docs/sync-engine-docs.sh before building the book. To
build it locally, run the same commands from the repository root. See
the book’s README.
Observability & metrics
mq-bridge-app is production-ready with structured JSON logging and a Prometheus metrics
endpoint, plus per-route status you can query at runtime.
Metrics
Attach the metrics middleware to an endpoint to emit
throughput, latency, and error metrics. It requires the metrics feature and takes no options —
its presence enables collection. Input and output are labelled separately, so attaching it to
both sides is meaningful:
orders_bridge:
input:
middlewares: [ { metrics: {} } ]
kafka: { topic: "orders", url: "localhost:9092" }
output:
middlewares: [ { metrics: {} } ]
nats: { subject: "orders.processed", stream: "orders_stream", url: "nats://localhost:4222" }
Metrics are exposed for a Prometheus scrape. Point your Prometheus (or Grafana Agent) at the running server and build dashboards on the emitted throughput/latency/error series.
⚠️ Metrics are not free. The
metricsmiddleware records a measurement per message on whichever side it is attached, so it adds per-message overhead and can measurably reduce throughput — most noticeably on high-throughput endpoints where per-message cost dominates. Enable it where you need visibility, not blanket-on every side of every route: attach it to the one endpoint you actually want to watch, and leave it off the hot path when chasing peak throughput. Benchmark numbers should be taken without it attached (see Reading throughput honestly).
Logging
Logs are structured JSON, suited to shipping into a log aggregator. Two things to keep in mind:
- Message payloads are emitted only at
tracelevel. Run production abovetraceso no sensitive data (e.g. cardholder data) reaches logs or traces. See the TLS & security hardening notes. - The MCP server’s
stdiotransport owns stdout for the protocol, so its logs go to stderr. In HTTP transport this is not a concern. - At startup each route reports its inferred delivery guarantee —
effectively-onceorat-least-once. It is read off the endpoint configuration, not enforced: the line tells you whether the source’s identity and the sink’s write add up to an idempotent pipeline. See Delivery guarantees.
Runtime route status
For a running bridge, query route health rather than reading logs:
- In the UI, the runtime status view shows live connection health and message counts per publisher/consumer/route.
- Through the MCP server,
list_routesandroute_statusreportmessages,messages_per_second(instantaneous),elapsed_s, andaverage_messages_per_second. For a running route read the instantaneous rate; for a finished job read the average. See MCP route status.
Reading throughput honestly
Whatever the source, a rate figure is only meaningful with its methodology:
- Measure on a release build — a debug build reports dramatically slower rates. Through MCP,
call
server_infofirst to confirm the buildprofile. - The instantaneous rate of a completed job decays to ~0 within a second — use the average for finished work.
- Record CPU/cores/RAM and the exact
batch_size/concurrencynext to every number.
See Performance tuning → Measuring and the like-for-like ETL/CDC
methodology in
benches/etl/README.md.
Performance tuning
mq-bridge is fast by default and stays out of your way until you need more. This page is the
map of the knobs that trade throughput against latency, memory, and ordering — what each one
does, when raising it helps, and when it hurts.
Philosophy: fast by default, safe knobs
The engine is optimized around batch-shaped APIs on every endpoint, even when the backend only has a single-message primitive. Batching costs nothing when there is nothing to batch: a route waits for one message and then takes whatever else is already queued, so a busy pipeline fills large batches while an idle one still ships each message as it arrives. Parallelism is the knob that stays opt-in.
Two different starting points are worth knowing:
- Library / config-mode routes default to
batch_size: 512,concurrency: 1— the engine primitive default. - The
copyCLI and the MCP server default tobatch_size: 1024,concurrency: 4(--batch-size/--concurrencyoncopy), because they exist for bulk moves. The benchmark numbers below usecopy.
When throughput matters, batch_size is almost always the first knob to try.
batch_size — the single most important knob
batch_size is the maximum number of messages a route gathers per iteration before handing the
batch to the sink. Larger batches amortize per-call overhead (network round-trips, SQL
statement prep, file syscalls) across many rows.
| Field | Default (library) | Default (copy) |
|---|---|---|
batch_size | 512 | 1024 |
When bigger helps: row-oriented and high-latency sinks — SQL INSERT, HTTP POST, object
storage, ClickHouse. These pay a fixed cost per call, so moving from 1 to 128–1024 rows per
call is often a 10–100x throughput change. Databases especially: one multi-row insert beats a
thousand single-row inserts.
When bigger hurts:
- Memory. Nothing caps a batch by bytes — only by message count. The whole batch is held in
memory (and, over IPC, written as one frame — frames over 100 MB are rejected), so on a route
carrying MB-scale payloads the limit is set by payload size and
batch_sizehas to come down. - Redelivery granularity. A nack redelivers at batch granularity on some transports, so a big batch means more replayed work after a failure — and a batch that fails as a whole takes more healthy messages down with it.
- Latency under load. A route never waits to fill a batch, so an idle bridge is unaffected.
Once the source is faster than the sink, though, a message queued behind a large in-flight
batch waits for that batch to ship. Where that matters, keep
batch_sizesmall — or use abuffermiddleware with amax_delay_msbound.
Choosing batch_size
| Workload | Recommended batch_size |
|---|---|
| Bulk DB → file / file → DB ETL | 512–1024 |
| Broker → DB sink (row inserts) | 128–512 |
| Low-latency request/response or event bridge | 1–16 |
| Large payloads (MB-scale) | keep small; watch peak RSS and the 100 MB IPC frame cap |
concurrency — sequential vs. worker pool
concurrency is the number of route worker tasks that process batches in parallel.
| Field | Default (library) | Default (copy) |
|---|---|---|
concurrency | 1 | 4 |
When it helps: high-latency handlers or sinks where a worker spends most of its time awaiting I/O — HTTP calls, remote databases. More workers keep the pipeline full while others wait.
Ordering implications. With concurrency > 1, batches are processed in parallel, so
strict per-key ordering across the route is not guaranteed. Commits are still sequenced for
cumulative-ack brokers (a later batch cannot ack over an earlier unresolved one), but the order
of side effects at the sink can interleave. If you need ordering, keep concurrency: 1 or
partition upstream so each key lands on one route.
When it does not help. Concurrency does not speed up sources that fetch serially. A
MongoDB source fetches batches serially — concurrency only widens the downstream side, not
the read. A single-connection cursor read is likewise source-bound. Raising concurrency
against a serial source just adds idle workers.
Choosing concurrency
| Workload | Recommended concurrency |
|---|---|
| CPU-light copy, fast local sink | 1–4 |
| Per-message HTTP call / remote sink (I/O-bound) | 4–16 |
| Order-sensitive stream | 1 |
| MongoDB / serial-read source | 1 (widen downstream instead) |
commit_concurrency_limit (default 4096) caps in-flight commit operations, whether queued
through ordered sequencing or run concurrently for independent-ack transports. Rarely needs
changing.
Connection pooling / reuse
Publishers targeting the same server share one underlying transport client by default — consolidating TCP connections, background threads, and batching, following each driver’s own guidance (one shared producer / client / pool per application). Sharing applies to Kafka, NATS, MongoDB, SQLx, HTTP, and gRPC, keyed by connection-level settings (URL, auth, TLS), never by topic/subject/collection.
Set shared: false on a publisher for a dedicated connection when sharing works against you:
- Kafka — isolate a latency-sensitive topic from a high-throughput one so they don’t share one internal send queue (head-of-line blocking).
- SQLx — a shared pool’s
max_connectionsis a budget spread across every route on that database; give a hot route its own pool. - gRPC — a shared channel multiplexes over one HTTP/2 connection; at very high concurrency its max-concurrent-streams cap can bottleneck.
See Connection sharing.
Retry & backoff
The retry middleware retries only Retryable and
connection errors, with exponential backoff (initial_interval_ms, multiplier,
max_interval_ms). Tuning notes:
- Backoff caps at
max_interval_ms, so retries don’t stall a route indefinitely; pick a cap that matches your latency budget. - Retry interacts with
dlq: once attempts are exhausted the error is marked permanent so a followingdlqcaptures it. Putdlqafterretryon the output (last = outermost). Without adlq, an exhausted message is dropped. - Several endpoints already retry connection/timeout errors internally, so
retryadds backoff on top — you don’t need hugemax_attempts.
Compression & encryption cost
- Compression trades CPU for smaller output, whether it’s the
file/object_storebatch field (compression: none|gzip|lz4|zstd) or thecompressionmiddleware compressing payloads over the wire.lz4is cheapest;zstdcompresses best;gzipis roughly 33% slower than zstd at similar ratios in practice. The middleware frames per message, so it pays its codec cost far more often than the batch field — prefer the endpoint field when the sink is a file or object. See the Compression recipe. - Encryption costs an AEAD seal/open per batch. Do not stack the
encryptionmiddleware on top of a sink’s batchcompression— ciphertext does not compress; use the file endpoints’ own compress-then-encrypt fields instead. See Encryption at rest. - A
buffermiddleware in front of a compressing sink increases batch size, which improves compression ratio and amortizes the codec cost.
Measuring
Never trust a single number without its methodology. Measure with a release build (a debug
build reports dramatically slower rates — if you’re driving the MCP server,
call server_info first to confirm the build profile), record CPU/cores/RAM, and report the
exact batch_size / concurrency next to every figure.
- Attach the
metricsmiddleware and scrape the Prometheus endpoint for live throughput/latency/error rates. - For the MCP server, read
average_messages_per_secondfor a finished job andmessages_per_secondfor a running one (see MCP route status). - The like-for-like ETL/CDC methodology and fixed parameters are in
benches/etl/README.md.
Reference numbers
Measured through mq-bridge-app’s copy CLI (the zero-code path) on an Apple M1, 8 cores,
8 GB RAM. Numbers are hardware-dependent — treat them as shape, not guarantees.
| Scenario | Batch | Conc. | Throughput | Peak RSS |
|---|---|---|---|---|
IPC forward (static → memory) | 1024 | 1 | 1,769,700 rows/s | — |
| CSV → JSONL (strings passthrough, 1M rows ~116 MiB) | 1024 | 1 | 1,133,786 rows/s | ~22 MiB |
CSV → JSONL with typing transform (id→int, embedded JSON) | 1024 | 1 | 742,390 rows/s | ~94 MiB |
| Postgres → JSONL (1M rows, 7 mixed-type cols) | 1024 | 1 | 338,066 rows/s | ~40 MiB |
| Postgres → JSONL (same) | 1024 | 4 | 384,615 rows/s | ~41 MiB |
All rows measured with the mimalloc allocator used by the shipped binaries. It is
the default-on mimalloc cargo feature (also implied by bench); build with
--no-default-features and without it in the feature list to fall back to the
system allocator on platforms where mimalloc is unsupported.
Two things the table shows:
- Typing has a real but modest cost. Adding a
transformthat coercesidto an integer and decodes an embedded JSON document costs ~0.47 µs/row — CSV→JSONL drops from 1.13M to 742k rows/s but every output record is fully typed. - Peak RSS does not scale with dataset size, because rows stream in batches rather than
being buffered whole — at the fixed batch size and concurrency above, ~22 MiB for a
passthrough copy however large the input. It is not a constant: batch size, connector-side
buffering, allocator retention and transforms all move it. The
typing
transformis the exception: its per-row JSON decode and buffering push peak RSS to ~94 MiB, still far leaner than tools that materialize the dataset.
Keyset cursor needs an index. The Postgres bulk-copy reader uses keyset pagination (
WHERE id > $cursor ORDER BY id LIMIT batch). Without an index on the cursor column it does a full scan per batch (near-quadratic).CREATE INDEX ON <table>(id)first.
A tuning checklist
- Start from the defaults. Confirm correctness before tuning.
- Raise
batch_size(128 → 512 → 1024) and re-measure. This is usually the biggest win. - If the sink or handler is I/O-bound and order doesn’t matter, raise
concurrency. - Add
bufferif the source emits singles but the sink prefers batches. - For hot Kafka/SQLx/gRPC publishers colliding with others, set
shared: false. - Add
retry+dlqfor resilience; keepmax_attemptsmodest. - Measure on a release build, with metrics, and record the parameters.
Troubleshooting
Common failure modes and how to read them. When something “silently does nothing”, the cause is almost always one of the drop/drain behaviours below.
Messages disappear and the route still ends completed
By design, a message that fails permanently (a type/data error the sink rejects, a poison
payload a handler rejects) is logged at error level and dropped — the route keeps
processing the rest of the batch. So a systematic failure (e.g. every row hitting a
column-type mismatch) drains the input while delivering nothing to the sink and still ends completed.
The terminal route status is not clean, however: its error reports the number of dropped
messages and the last rejection cause.
- Watch for a burst of
Dropping message … due to non-retryable errorin the logs — that’s the signal. - Check the route status/error in the UI, CLI, or MCP response for the retained drop count and
cause; do not treat
outcome: completedalone as proof that every message was delivered. - Add a
dlqto capture the failures for inspection/replay. retryalone does not retain a failed message — pair it withdlq(dlq last). See Dead-letter queues.
--drain / exit_on_empty behaviour
--drain(CLI) /exit_on_empty(MCP/route) exits once the source yields an empty batch — right for finite sources (a file, a full-table read). For brokers and CDC sources that never “end”, omit it and run continuously.- A drained Redis Streams source reports an error at the end:
"healthy": falsewithRedis XREAD failed: timed out, even though every message moved. Cosmetic — verify the sink, not the final status. NATS does not behave this way. - Finished MCP routes are not reaped: a route started with
exit_on_emptystays inlist_routesafter draining. Check"finished"/"outcome"on the entry, or callstop_routeto clear it. See MCP known limitations.
IPC: publisher fails to connect or blocks
The memory endpoint’s IPC transport is unidirectional, publisher → consumer, and the
consumer is the server:
- The consumer must be running before the publisher connects — otherwise the publisher fails with a connection error. Start the consumer-side route first.
send_batchblocking is normal backpressure, not a fault: socket buffers are small (8 KiB on macOS), so a batch larger than the buffer only completes once the consumer drains it. After 5 s blocked the publisher logs a warning naming the socket and keeps waiting.- A consumer that has accepted but stopped draining stalls the publisher indefinitely — make sure the consumer side is actually reading.
- Named
ipc://nameresolves to different paths for different users/services (the/run→$XDG_RUNTIME_DIR→/tmpfallback). When both sides must agree, use an explicit path (ipc:///run/myapp/queue.sock). - IPC redelivery is consumer-local and does not survive a consumer crash; use a real broker for durable redelivery. See Cross-process IPC bridge.
Request/reply timeouts and dropped responses
- The
responseoutput requires an input that carries a reply channel (http,websocket,grpc, or a request/replynats/mongodb/memory). If the input does not support responses (File, SQLx, …), the message sent toresponseis dropped. - Configure a generous timeout on the requester side — bridge processing adds latency.
- Middleware that drops metadata (like
correlation_id) breaks the response chain. Keep the correlation metadata intact through the route. - MongoDB’s reply pattern uses emulation that waits for messages; misconfigured timeouts can cause severe stalls. Test it before relying on it.
Ordering looks wrong
With concurrency > 1, batches process in parallel, so strict per-key ordering across the
route is not guaranteed — side effects at the sink can interleave. Commits are still sequenced
for cumulative-ack brokers. If you need ordering, set concurrency: 1 or partition upstream so
each key lands on one route. See Performance tuning.
NATS JetStream: “no stream found for given subject”
When mq-bridge auto-creates a JetStream stream it scopes it to {stream}.>, so the subject
must be prefixed with the stream name: stream: "orders_stream" needs a subject like
orders_stream.foo. Also, stream is required even in Core NATS mode (no_jetstream: true) — pass any placeholder string. See
NATS JetStream notes.
MongoDB source: “capture_all needs a replica set”
Both capture_* modes read the oplog, so they need a replica set — a single-node one is
enough — and refuse to start without one rather than falling back. The removed fallback paged
by _id, which only ever returns documents above its high-water mark, so anything a concurrent
writer committed below that mark was dropped silently.
- One-shot, non-destructive read of a standalone
mongod:consume: snapshot. - Work queue (destructive, competing readers):
consume: consumer. - Turning the standalone into a single-node replica set (
replSet+rs.initiate()) restorescapture_all, and with it resumable reads.
ZeroMQ peers stopped understanding each other after upgrading
0.4.0 changed the zeromq default format from json to raw_framed. A 0.4 peer and a 0.3
peer no longer interoperate on the same socket until they are pinned to the same format, and the
fix belongs on the 0.4 peer: set format: json there. Setting it on the 0.3 peer changes
nothing — json is already its default, and the 0.4 peer stays on raw_framed. (Pinning both to
raw_framed works too, since 0.3 also understands it.) The symptom is a peer that receives frames
but reads them as garbage, or a payload that arrives as JSON text instead of the decoded message.
format does not apply to REQ/REP replies: a REP peer always answers with a JSON array of
canonical messages and a REQ publisher always decodes one, whatever format is set to.
SQLx source: “reconnecting forever” or rejects the table
An SQLx source with no cursor_column is a competing-consumers work queue and requires
the queue schema (id, payload, locked_until). A plain table without locked_until fails
fast with a permanent error. To read an arbitrary table non-destructively, set cursor_column
to switch to cursor polling. See Endpoints.
Encrypted / compressed file source ends immediately or failed
A file source must declare the same compression/encryption the data was written with. A
mismatch (wrong key, wrong codec, missing field) is a permanent decode failure — the route ends
failed with the error in its status. Reading a compressed file with no compression set is
rejected up front by magic-byte sniffing. See Encryption at rest.
Postgres bulk copy is slow / near-quadratic
The Postgres keyset-cursor reader does WHERE id > $cursor ORDER BY id LIMIT batch. Without an
index on the cursor column it does a full scan per batch. Create one:
CREATE INDEX ON <table>(id). See Performance tuning.
Throughput numbers look wrong
Measure on a release build — a debug build reports dramatically slower rates. Through the
MCP server, call server_info first to confirm the build profile. See
Measuring.
Float values change in the last bit
Numbers move through payloads as JSON, and serde_json’s default parser shifts ~1 ULP on ~19% of
17-significant-digit doubles. For bit-exact float round-tripping across every endpoint, build
with the float-roundtrip feature. See
middleware encryption notes.
Custom endpoints
When a protocol isn’t built in, you can plug your own endpoint into the engine. A custom endpoint is selected from config by name and delegates to a factory you register programmatically before starting routes.
Config
output:
custom:
name: "my_sink"
config: { target: "internal://thing" }
| Field | Type | Required |
|---|---|---|
name | string | yes — matches the registered factory |
config | any JSON | yes — passed through to your factory |
The custom endpoint works as a route input or output.
Implementing it
Implement the CustomEndpointFactory trait and register your type before starting
routes. The factory receives the config JSON and builds an endpoint that speaks the
engine’s MessageConsumer / MessagePublisher traits (in mq_bridge::traits), so
your endpoint participates in batching, middleware, and ack/nack exactly like a
built-in one.
Registration is process-global and keyed by name: it must happen before any route that names it starts, and registering a name twice is an error rather than a silent replacement, so each factory needs its own name. Python and Node.js can register a factory directly, with the same semantics — no Rust required.
See also
custom(endpoint) reference — the authoritative field list.- Writing endpoints & middleware — the trait, registration, and the Python/Node equivalents.
- Native plugins — the same endpoint shipped as a loadable library instead.
- Custom middleware — the middleware equivalent.
Custom middleware
Middleware wraps the message flow between an input and an output — retries, dedup, transforms, and so on. When the built-in set doesn’t cover your need, register your own and select it from config by name.
Config
Middleware attaches to a route side, so the list goes under input: or output::
output:
mongodb:
url: "mongodb://localhost:27017"
middlewares:
- custom:
name: "my_enricher"
config: { lookup_url: "http://enrich.internal" }
| Field | Type | Required |
|---|---|---|
name | string | yes — matches the registered factory |
config | any JSON | yes — passed through to your factory |
Implementing it
Implement the CustomMiddlewareFactory trait and register it before starting routes.
It exposes apply_consumer and/or apply_publisher — each defaults to pass-through, so
you only implement the side you need. The consumer and publisher sides are separate
because middleware wraps a MessageConsumer on the way in and a MessagePublisher on
the way out; wrap order matters (see the architecture doc).
Registration is process-global and keyed by name: it must happen before any route that names it starts, and registering a name twice is an error rather than a silent replacement. Python and Node.js can register a middleware directly, with the same semantics — no Rust required.
See also
custom(middleware) reference — the authoritative field list.- Writing endpoints & middleware — traits, registration, wrap order, and the Python/Node equivalents.
- Custom endpoints — the endpoint equivalent.
Extending mq-bridge
How to add an endpoint or a middleware that lives outside this repository — in your own Rust crate, or written directly in Python or JavaScript.
Everything here plugs into the same two extension points:
| You want | Implement | Register with |
|---|---|---|
| A new source/sink (Pulsar, an internal broker, a SaaS API) | CustomEndpointFactory | register_endpoint_factory |
| A step that inspects/rewrites/drops messages in flight | CustomMiddlewareFactory | register_middleware_factory |
Registration is process-global and keyed by name. Register before starting any route that names it; registering the same name twice is an error, so each factory needs its own name.
Looking for the built-in endpoints and middleware instead? See REFERENCE.md. To ship a Rust endpoint as a loadable library that Rust, Python and Node.js hosts can all load — an endpoint, a middleware or both — see PLUGINS.md.
How a custom name reaches your code
Once pulsar is registered, both of these configs route to your factory:
# Shorthand: any endpoint key mq-bridge does not recognise is looked up
# in the custom-endpoint registry.
input:
pulsar:
url: "pulsar://localhost:6650"
topic: "orders"
# Explicit form. Prefer this if you validate configs against
# mq-bridge.schema.json, which cannot know your custom key.
input:
custom:
name: "pulsar"
config:
url: "pulsar://localhost:6650"
topic: "orders"
Middleware is always the explicit form, in any endpoint’s middlewares list:
output:
file: { path: "out.jsonl" }
middlewares:
- custom:
name: "redact"
config: { fields: ["ssn"] }
Whatever sits under the endpoint key (or under config:) is handed to your
factory verbatim as JSON. mq-bridge does not interpret it.
Rust: an endpoint in your own crate
This is the path for a real transport — it has no FFI overhead and full access to the async ecosystem. Keeping it in a separate crate means its dependency tree never lands in mq-bridge’s build or CI.
# Cargo.toml
[dependencies]
mq-bridge = { version = "0.4", default-features = false }
pulsar = "6"
async-trait = "0.1"
anyhow = "1"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
tokio = { version = "1", features = ["rt", "macros"] }
use std::sync::Arc;
use async_trait::async_trait;
use mq_bridge::errors::ConsumerError;
use mq_bridge::traits::{
BatchCommitFunc, CustomEndpointFactory, MessageConsumer, MessageDisposition, MessagePublisher,
};
use mq_bridge::{CanonicalMessage, ReceivedBatch, SentBatch};
use serde::Deserialize;
#[derive(Debug, Deserialize)]
struct PulsarConfig {
url: String,
topic: Option<String>,
#[serde(default)]
subscription: Option<String>,
}
#[derive(Debug)]
struct PulsarFactory;
#[async_trait]
impl CustomEndpointFactory for PulsarFactory {
async fn create_consumer(
&self,
route_name: &str,
config: &serde_json::Value,
) -> anyhow::Result<Box<dyn MessageConsumer>> {
let mut config: PulsarConfig = serde_json::from_value(config.clone())?;
// Convention: default the topic to the route name, like kafka/nats do.
let topic = config.topic.take().unwrap_or_else(|| route_name.to_string());
Ok(Box::new(
PulsarConsumer::connect(&config.url, &topic, config.subscription.as_deref()).await?,
))
}
async fn create_publisher(
&self,
route_name: &str,
config: &serde_json::Value,
) -> anyhow::Result<Box<dyn MessagePublisher>> {
let mut config: PulsarConfig = serde_json::from_value(config.clone())?;
let topic = config.topic.take().unwrap_or_else(|| route_name.to_string());
Ok(Box::new(PulsarPublisher::connect(&config.url, &topic).await?))
}
}
/// Call once, before starting any route that uses `pulsar`.
pub fn register() -> anyhow::Result<()> {
mq_bridge::extensions::register_endpoint_factory("pulsar", Arc::new(PulsarFactory))
}
Both methods default to “unsupported”, so a source-only endpoint just omits
create_publisher and gets a clear error if someone configures it as an output.
The consumer contract
#[async_trait]
impl MessageConsumer for PulsarConsumer {
async fn receive_batch(
&mut self,
max_messages: usize,
) -> Result<ReceivedBatch, ConsumerError> {
let messages = self.pull(max_messages).await?; // your client
if messages.is_empty() {
// "Nothing right now" — NOT end of stream. The route backs off and
// retries, and treats this as the drain signal under exit_on_empty.
return Ok(ReceivedBatch::empty());
}
let acker = self.acker.clone();
let commit: BatchCommitFunc = Box::new(move |dispositions| {
Box::pin(async move {
for disposition in dispositions {
match disposition {
MessageDisposition::Nack => acker.nack().await?,
_ => acker.ack().await?,
}
}
Ok(())
})
});
Ok(ReceivedBatch { messages, commit })
}
fn as_any(&self) -> &dyn std::any::Any {
self
}
}
Three rules that decide whether your endpoint behaves well:
- Never block forever on an empty source. Return
ReceivedBatch::empty()instead. A consumer that parks indefinitely makesexit_on_empty/--drainhang — the single most common bug in a new endpoint. commitgets one disposition per message, in order. It is what advances the broker offset. Do not ack at read time unless the transport gives you no choice (and say so in your docs — it downgrades the route to at-most-once).- Classify your errors.
ConsumerError::Connectionmakes the route reconnect;ConsumerError::Permanentshuts it down (use it for poison data, not for a dropped socket);ConsumerError::EndOfStreamends it cleanly.
The publisher contract
#[async_trait]
impl MessagePublisher for PulsarPublisher {
async fn send_batch(
&self,
messages: Vec<CanonicalMessage>,
) -> Result<SentBatch, mq_bridge::errors::PublisherError> {
// Hand the client the whole batch, then flush once. Do NOT `await` a
// single-message send per message — that is the difference between
// ~100k and ~1M messages/s.
let payloads = messages.iter().map(|m| m.payload.clone());
self.producer.send_all(payloads).await?;
self.producer.flush().await?;
Ok(SentBatch::Ack)
}
fn as_any(&self) -> &dyn std::any::Any {
self
}
}
If your client has no batch API, start every send before awaiting any of them
(collect the futures, then join_all) rather than awaiting each in turn — the
point is to keep one in-flight request from gating the next.
PublisherError::Retryable is retried by a retry middleware;
PublisherError::NonRetryable goes straight to a dlq if one is configured.
Optional lifecycle hooks
on_connect_hook runs once after the endpoint is created and before the route
reports itself ready — use it to warm a pool or create tables.
on_disconnect_hook runs during shutdown. Both are optional.
Using it
#[tokio::main]
async fn main() -> anyhow::Result<()> {
mq_bridge_pulsar::register()?;
mq_bridge::Route::from_file("routes.yaml", Some("pulsar_to_file"))?
.run("pulsar_to_file")
.await
}
Python
import mq_bridge
class PulsarSource:
def __init__(self, config):
import pulsar
self.pulsar = pulsar
client = pulsar.Client(config["url"])
self.consumer = client.subscribe(config["topic"], config.get("subscription", "mq-bridge"))
self.pending = []
def receive_batch(self, max_messages):
# Return [] / None for "nothing right now"; raise StopIteration to end.
batch = []
self.pending = []
for _ in range(max_messages):
try:
message = self.consumer.receive(timeout_millis=100)
# pulsar-client 3.4.x exposes an expired receive as pulsar.Timeout.
except self.pulsar.Timeout:
break
self.pending.append(message)
batch.append(message.data())
return batch
def commit(self, dispositions):
for message, disposition in zip(self.pending, dispositions):
if disposition == "nack":
self.consumer.negative_acknowledge(message)
else:
self.consumer.acknowledge(message)
def close(self):
self.consumer.close()
mq_bridge.register_endpoint("pulsar", lambda route_name, config: PulsarSource(config))
route = mq_bridge.Route.from_config({
"exit_on_empty": True,
"input": {"pulsar": {"url": "pulsar://localhost:6650", "topic": "orders"}},
"output": {"file": {"path": "orders.jsonl"}},
}, "pulsar_to_file")
route.run()
The object. factory(route_name, config) returns it; which methods it has
decides what it can be:
| Method | Makes it usable as | Notes |
|---|---|---|
receive_batch(max_messages) | an input | Returns Message/bytes/str/JSON values, or None/[] for idle. Raise StopIteration for end of stream — only here; from any other method it is an ordinary error. |
commit(dispositions) | — | Optional. One "ack"/"nack" string per message in the batch. |
send_batch(messages) | an output | Receives Message objects. |
close() | — | Optional. Called when the route releases the endpoint. |
Configuring a sink-only object as an input fails at route startup with a
message naming the missing method.
Errors. Raise mq_bridge.RetryableError to have a failed send_batch
retried; anything else is non-retryable and reaches a dlq. On the read side any
exception triggers a reconnect, except mq_bridge.NonRetryableError, which shuts
the route down instead of re-reading data that cannot heal.
Threading. Each endpoint instance gets its own thread, and every call into it
— construction included — happens there. Your object never sees concurrent calls,
even with concurrency > 1, so it does not need to be thread-safe. It also means
one endpoint is one Python thread’s worth of throughput.
Releasing a name. The registry is process-global and rejects a duplicate
name. mq_bridge.unregister_endpoint(name) (and unregister_middleware(name))
drops the factory once the routes using it have stopped, freeing the name and the
reference held on your factory object. Both return True when a registration was
removed.
Python middleware
class Redact:
def __init__(self, config):
self.fields = config.get("fields", [])
def on_send(self, messages):
out = []
for message in messages:
data = message.json()
if data.get("internal"):
out.append(None) # drop this one
continue
for field in self.fields:
data.pop(field, None)
out.append(mq_bridge.Message.from_json(data, message.metadata))
return out # one slot per input message
mq_bridge.register_middleware("redact", lambda route_name, config: Redact(config))
on_receive(messages)applies when the middleware sits on an input endpoint;on_send(messages)when it sits on an output. Implement either or both — a side you leave out passes through untouched.- Both must return exactly one item per input message: a
Messageto keep it (rewritten or not), orNoneto drop it. That fixed length is what keeps acknowledgements aligned with the source batch — a dropped message is acked at the source, so it is not redelivered forever.
Node
const mqb = require("mq-bridge");
mqb.registerEndpoint("pulsar", (routeName, config) => {
const client = new Pulsar.Client({ serviceUrl: config.url });
let consumer;
let pending = [];
return {
async receiveBatch(maxMessages) {
consumer ??= await client.subscribe({ topic: config.topic, subscription: "mq-bridge" });
const batch = [];
pending = [];
for (let i = 0; i < maxMessages; i += 1) {
let message;
try {
message = await consumer.receive(100);
} catch (error) {
// pulsar-client 1.18.x reports an expired receive as `TimeOut`.
if (error instanceof Error && error.message.endsWith(": TimeOut")) break;
throw error;
}
pending.push(message);
batch.push(message.getData());
}
return batch; // [] means "nothing right now"
},
async commit(dispositions) {
for (let i = 0; i < dispositions.length; i += 1) {
if (dispositions[i] === "nack") consumer.negativeAcknowledge(pending[i]);
else await consumer.acknowledge(pending[i]);
}
pending = [];
},
async close() {
await client.close();
},
};
});
const route = mqb.Route.fromConfig({
input: { pulsar: { url: "pulsar://localhost:6650", topic: "orders" } },
output: { file: { path: "orders.jsonl" } },
}, "pulsar_to_file");
route.start();
The shape matches Python, with JS names and promises: receiveBatch,
commit, sendBatch, close; registerMiddleware with onReceive / onSend.
Throw mqb.EndOfStream (instead of StopIteration) to end a source, and set
err.retryable = true on a thrown error to have it retried.
Keep the event loop free
Your endpoint runs in JavaScript, so mq-bridge has to hand work back to the Node event loop to call it. Anything that blocks the JS thread starves those calls:
route.start();
// ...your app runs, the event loop turns, the endpoint gets called...
route.stop();
await new Promise((r) => setTimeout(r, 50)); // let the loop drain the teardown
route.join();
Calling route.join() immediately after stop() blocks the loop while the route
is still finishing, which costs a 5s shutdown timeout — and if the route still
needs the endpoint, it deadlocks outright. This does not affect normal
event-loop-driven apps; it only bites when you block the thread on purpose.
For the same reason the host object is built lazily, on first use rather than at
start(): a factory dispatched from inside start() could never be serviced.
A registered endpoint also keeps the Node process alive (it is a live resource, like an open server). Once the routes using it have stopped, release it so the process can exit on its own:
mqb.unregisterEndpoint(name); // mqb.unregisterMiddleware(name) for middleware
Both return true when a registration was removed, false when the name was
not registered. process.exit() also works, but it skips pending flushes and
close() hooks.
Choosing a language
| Rust | Python / Node | |
|---|---|---|
| Throughput | Full — no FFI hop | One host thread per endpoint; fine for I/O-bound sources |
| Reuse | Rust crates | The host ecosystem (an official SDK that has no Rust equivalent) |
| Distribution | A crate users add and register() | A few lines in the app that already exists |
Reach for a host-language endpoint when the vendor ships a good Python/Node SDK and no Rust one, or when the endpoint is glue specific to your deployment. Write it in Rust when it is a real transport other people will want, when it must keep up with a high-throughput route, or when you want to publish it — as a crate, or as a native plugin every language can load (PLUGINS.md).
See also
- PLUGINS.md — ship a Rust endpoint or middleware as a loadable native plugin
- REFERENCE.md — every built-in endpoint and middleware
- ARCHITECTURE.md — how routes, batching and commits fit together
- CONFIGURATION.md — config loading, env vars, schema validation
Native plugins
A plugin is a shared library holding an endpoint — and optionally a middleware — that this binary never compiled: a proprietary broker, an in-house transport. Loading one registers it under its own name, after which routes address it like any built-in connector.
Pulsar is compiled in, so it is not a plugin here. Address it directly, as
pulsar: { url: "pulsar://localhost:6650", topic: ..., subscription: ... }in a route, orpulsar://localhost:6650?topic=...fromcopy. Passinglibmq_bridge_pulsar.{so,dylib}to--pluginorplugins:fails at startup with`pulsar` ... is already registered by another factory— that rejection is deliberate, since a second factory under a live name would silently reroute traffic. Other hosts that did not compile it in, such asmq-bridge-py, do load it as a plugin.
This is the runtime counterpart to custom endpoints: a custom endpoint is registered programmatically by code you compile in, a plugin is loaded from a file named in the config.
Loading
List the libraries under plugins:. Paths go through the usual placeholder expansion,
so ${VAR} works:
plugins:
- "${MQB_PLUGIN_DIR}/libmq_bridge_acme.so"
routes:
orders:
input:
custom:
name: acme
config: { url: "acme://localhost:9000" }
output:
file:
path: "orders.jsonl"
The name is the one the plugin exports, not the file name. A plugin providing a
middleware registers that under the same name, usable in any middlewares: chain.
Plugins are loaded only from trusted startup configuration. The UI and POST /config may
reorder or repeat the same canonical paths, but cannot add, remove, or retarget a plugin.
Edit the startup config and restart the process for every plugin change. Loaded native
libraries remain mapped and registered for the process lifetime; they are never unloaded.
Every build can load plugins; there is no cargo feature to enable.
From the CLI
--plugin <path> loads a library without touching the config, repeatable, and valid on
every subcommand:
mqb --plugin ./libmq_bridge_acme.so --config config.yml
It combines with plugins: rather than replacing it. Listing the same library both ways
is harmless — a library already loaded is not loaded twice.
copy takes plugin endpoints too: once a factory is registered, its name works as a URI
scheme, with the query params becoming its config fields.
mqb copy --plugin ./libmq_bridge_acme.so \
--from "acme://localhost:9000?stream=orders" --to "file:///tmp/orders.jsonl"
The URI carries strings only — url is the part before the ? (override it with an
explicit ?url=), and every other param is passed to the factory as a string field. Use
config mode for a factory whose config needs numbers, booleans, or nested objects.
Failure modes
Loading is strict, because the alternative is a confusing “unknown endpoint” much later, once a route asks for something nobody registered:
| Condition | Result |
|---|---|
| File missing or unreadable | startup fails naming the path |
| Built against an incompatible ABI major version | rejected |
| Declares neither an endpoint nor a middleware | rejected |
| Name already taken by a different factory | rejected — traffic would silently reroute |
At startup a bad path aborts the process. A runtime config whose canonical plugin set differs from the startup set is rejected before route validation, storage changes, or saving.
Security
A plugin is native code loaded into this process, with the same privileges — it is not sandboxed. Treat the startup configuration and CLI flags as trusted inputs, and treat the libraries they name exactly like any other native dependency. Runtime config updates cannot load a new library.
Writing one is covered in Writing a plugin.
Plugins
An endpoint or middleware written in Rust can be compiled to a shared library and loaded into any mq-bridge process at runtime — Rust, Python or Node.js — without being compiled into mq-bridge itself.
That solves a specific problem: an endpoint like Pulsar or a proprietary broker
drags in a dependency tree (and a protoc, or a vendor C client) that nobody
who does not use it should have to build. As a plugin it lives in its own
repository, on its own release cycle, and every language runs the same
implementation with the same delivery semantics.
Writing the endpoint in Python or JavaScript instead — no compilation, no packaging — is often the better trade. See EXTENDING.md.
Using a plugin
Loading is always explicit; installing a package never registers anything.
// Rust
mq_bridge::plugin::load_endpoint_plugin("./libmq_bridge_pulsar.so")?;
# Python
import mq_bridge
mq_bridge.load_endpoint_plugin("./libmq_bridge_pulsar.so")
// Node.js
import { loadEndpointPlugin } from "mq-bridge";
loadEndpointPlugin("./libmq_bridge_pulsar.so");
Published endpoint packages wrap that call so you never touch a path:
import mq_bridge_pulsar
mq_bridge_pulsar.register()
After loading, the endpoint is usable by name, exactly like a factory registered in-process:
input:
custom:
name: pulsar
config:
url: "pulsar://localhost:6650"
topic: "persistent://public/default/orders"
Load once, before starting routes that use it. Loading the same file twice is a
no-op, and the plugin loader rejects a second library claiming a name that is
already registered, rather than silently replacing it. That check belongs to the
loader alone: registering the same name twice in-process (via
register_endpoint_factory / register_middleware_factory, see
EXTENDING.md) returns an error and preserves the first factory. Rust users who link
the endpoint crate directly can skip loading entirely and call its register().
The plugin feature (in full and portable) provides the loader.
A plugin is native code in your process. It can crash it or do anything the process may do. Treat plugin packages like other native dependencies, not like sandboxed scripts. Nothing is ever unloaded: a library stays mapped for the life of the process, because unloading while endpoint handles or in-flight batches exist cannot be made safe.
Writing a plugin
Implement the ordinary mq-bridge contracts — CustomEndpointFactory,
MessageConsumer, MessagePublisher (see EXTENDING.md) — then
export the factory:
[dependencies]
mq-bridge = { version = "0.4", default-features = false, features = ["plugin-sdk"] }
[lib]
crate-type = ["rlib", "cdylib"]
#[derive(Debug, Default)]
pub struct PulsarFactory;
#[async_trait]
impl CustomEndpointFactory for PulsarFactory { /* ... */ }
mq_bridge::export_endpoint_plugin! {
name: "pulsar",
factory: PulsarFactory,
}
That is the whole FFI surface. The rlib keeps the endpoint usable as plain
Rust — link it, test it, register() it — while the cdylib is what other
processes load. Your factory type must implement Default (the ABI constructs
it with no arguments); configure endpoints through the route’s config, not
through factory state.
The SDK handles what the boundary requires: panic containment, buffer and handle
lifetimes, error translation, and the plugin’s own async runtime. Acknowledgement
timing is passed through untouched — the host’s batch commit arrives at your
ReceivedBatch commit function, so nothing is acked before the route says so,
and a batch dropped mid-shutdown acks nothing at all.
Declare an output-only (or input-only) endpoint when it is one:
mq_bridge::export_endpoint_plugin! {
name: "metrics-sink",
factory: SinkFactory,
capabilities: mq_bridge::plugin::sdk::CAPABILITIES_OUTPUT_ONLY,
}
Middleware
A plugin can also provide a middleware. It never touches the endpoint it wraps — the host keeps that wrapper — so all that crosses the ABI is the batch:
#[derive(Debug, Default)]
struct RedactFactory;
#[async_trait]
impl mq_bridge::plugin::sdk::MiddlewareFactory for RedactFactory {
async fn create(
&self,
_route: &str,
config: &serde_json::Value,
) -> anyhow::Result<Box<dyn mq_bridge::plugin::sdk::BatchFilter>> {
Ok(Box::new(Redact::new(config)?))
}
}
#[async_trait]
impl mq_bridge::plugin::sdk::BatchFilter for Redact {
async fn on_receive(
&self,
messages: Vec<CanonicalMessage>,
) -> anyhow::Result<Vec<Option<CanonicalMessage>>> {
// Exactly one entry per input message, in order: `None` drops it.
Ok(messages.into_iter().map(|m| Some(self.redact(m))).collect())
}
}
mq_bridge::export_middleware_plugin! {
name: "redact",
middleware: RedactFactory,
}
Routes name it like any custom middleware; loading the library registers it:
input:
kafka: { topic: orders }
middlewares:
- custom:
name: redact
config: { fields: ["ssn"] }
Return None to drop a message. The host acknowledges dropped messages on the
source for you, so they are not redelivered — and a batch that is filtered away
entirely never reaches the route, which keeps exit_on_empty from mistaking it
for a drained source.
A plugin that provides both uses one name for both, which is usually what a transport-specific middleware wants:
mq_bridge::export_endpoint_plugin! {
name: "pulsar",
factory: PulsarFactory,
middleware: PulsarMiddleware,
}
Limits of ABI v1
- A batch is published all-or-nothing: no per-message publish responses, so no request/reply through a plugin.
MessageDisposition::Replyacknowledges the source message.- One plugin per shared library (the export macro defines the discovery symbol), so two plugin crates cannot be statically linked into one binary. Gate the macro behind a feature if that matters for your crate.
Testing it
Run the same semantic suite twice — linked directly, and loaded as a plugin. If both agree, the ABI round trip changed nothing:
use mq_bridge::plugin::conformance::{self, ConformanceOptions};
use mq_bridge::plugin::{load_endpoint_plugin, test_support::build_plugin_cdylib};
let config = serde_json::json!({ "url": "pulsar://localhost:6650" });
let direct = conformance::run(&PulsarFactory, ConformanceOptions::new("direct", config.clone())).await?;
let library = build_plugin_cdylib(".", "mq-bridge-pulsar")?;
let info = load_endpoint_plugin(&library)?;
let factory = mq_bridge::extensions::get_endpoint_factory(&info.name).unwrap();
let loaded = conformance::run(factory.as_ref(), ConformanceOptions::new("plugin", config)).await?;
assert_eq!(direct, loaded);
The suite checks round-tripping, metadata preservation, nack redelivery, and
that an uncommitted batch is redelivered. Turn the redelivery checks off
(expect_redelivery = false) for endpoints that legitimately have none, or whose
broker delays redelivery beyond a test’s patience, and the metadata check off
(expect_metadata = false) for transports that carry payloads only.
build_plugin_cdylib builds the package and reads the artifact path back out of
cargo, so tests do not hard-code target-directory layout or file extensions.
Shipping it to Python and Node.js
Both bindings understand the same package manifest:
{ "name": "pulsar", "library": "mq_bridge_pulsar" }
Store it as mq-bridge-plugin.json. A platform wheel may put its native library
beside that manifest. A cross-platform npm package puts each library under
prebuilds/<platform>-<arch>/ (with -gnu or -msvc where applicable).
Python packages call plugin_library_path() and load_plugin_package();
Node.js packages can export definePluginPackage(__dirname) directly. The
bindings own platform detection, filenames, errors, and loading, so endpoint
packages contain no custom loader logic.
mq-bridge-pulsar is the worked example. It publishes platform wheels under one
Python distribution name and one npm package containing all supported prebuilds.
The builders live in mq-bridge itself, so plugins do not copy packaging scripts:
pip install "mq-bridge-py[plugin-packaging]"
python -m mq_bridge.plugin_packaging --package python/my_plugin --out dist
mq-bridge-package-plugin --package node --pack --out npm
Loading without writing code
mq-bridge-app loads plugins for you, so a YAML-only deployment can use an endpoint the binary never compiled:
plugins:
- "${MQB_PLUGIN_DIR}/libmq_bridge_pulsar.so"
routes:
orders:
input:
custom:
name: pulsar
config: { url: "pulsar://localhost:6650" }
or per run:
mq-bridge-app --plugin ./libmq_bridge_pulsar.so --config mq-bridge.yaml
Paths go through the app’s usual ${VAR} expansion, which is what keeps a
config portable across machines that install libraries in different places.
Plugins load before any route is built; a path that fails to load stops startup
rather than leaving a route to fail later with “unknown endpoint”.
Versioning and compatibility
The ABI has its own major/minor version
(mq_bridge::support::plugin_abi::MQB_PLUGIN_ABI_MAJOR / _MINOR), independent
of the mq-bridge release it ships in:
- A different major is rejected at load with an actionable error.
- Within a major, fields are only ever appended to the function table, and both sides use its recorded size to decide what exists — so an older plugin keeps working with a newer host.
Publish the supported ABI range in your package metadata, and test each packaged plugin against the oldest and newest mq-bridge you claim to support.
See also
- EXTENDING.md — custom endpoints and middleware, including Python and JavaScript ones
- REFERENCE.md — every built-in endpoint and middleware
- ARCHITECTURE.md — how routes, batching and commits fit together
Contributing
Contributions to the engine (mq-bridge) — bug reports, feature requests, docs, and
code — are welcome. The engine repo holds the authoritative
CONTRIBUTING.md; this
page is a short orientation.
Getting started
- Fork and clone the engine repo.
- Install Rust (stable, via rustup).
- The
tests/folder ships Docker-Compose files for each broker, so you don’t need to install Kafka/NATS/AMQP/etc. natively. - Verify your environment:
cargo test -p mq-bridge-app --features full.
Code style
cargo fmt --allbefore submitting a PR.cargo clippy -p mq-bridge-app --all-features -- -D warningsmust pass.- Follow idiomatic Rust and existing conventions.
Adding an endpoint or middleware
- Add files under
src/endpoints/orsrc/middleware/. - Update the factory functions in the relevant
mod.rs. - Add configuration models to
src/models.rs. - Add/adjust unit tests in the module, and integration tests under
tests/integration/where applicable. - Keep the
REFERENCE.mdsnippets valid — they are parsed bytests/reference_docs_test.rs.
Building this book
The documentation lives here in mq-bridge-app under dev/docs/. To build it locally,
see dev/docs/README.md.
See also
- Engine
CONTRIBUTING.md— the full, authoritative guide. - Custom endpoints and Custom middleware — extend without forking.