Introduction
crossing streams
mq-bridge moves data reliably between message brokers, databases, files, and HTTP — with batching, retries, dead-letter queues, deduplication, and change data capture built in. One Rust engine, one config format, two ways to use it:
| mq-bridge — the library | mq-bridge-app — the application | |
|---|---|---|
| What | The engine, embedded in your own service | The same engine as a desktop app, CLI / server, and MCP server |
| For | Code that moves data and adds business logic in handlers | Moving data by config alone — no code, no build step |
| Languages | Rust, Python, Node.js | YAML, environment variables, or one-line mqb copy |
| Start | Quick start: library | Quick start: copy |
Almost everything in this book applies to both: connectors, middleware, delivery guarantees, and tuning share the same settings. A CLI URL’s query parameters are the same fields you write in a library’s YAML config.
Supported integrations include Kafka, RabbitMQ (AMQP), NATS, AWS SQS / SNS, MQTT, IBM MQ, HTTP, WebSocket, gRPC, ZeroMQ, MongoDB, Redis Streams, ClickHouse, Postgres CDC, SQLx (MySQL, MariaDB, PostgreSQL, SQLite), cloud object storage, and filesystem endpoints — plus Pulsar, Meilisearch, and Redpanda Connect through plugins.
A quick taste
In code — the same route runs in Rust, Python, or Node.js:
from mq_bridge import Route
Route.from_file("routes.yaml", "kafka_to_nats").run()
Without code — mqb copy moves data between databases, queues, and files in a single line of
bash:
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 413,907 rows/s at ~55 MiB peak RSS (mq-bridge 0.4.20) — see Performance tuning for the full table and the conditions each row was measured under.
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
- Writing code? Start with the library quick start, then Embed the library.
- Moving data without code? Start with Installation and the
copyquick 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.
Library vs. app
mq-bridge is the engine/library; mq-bridge-app is the application built on it. This
book covers both; the engine’s Rust API reference lives on docs.rs,
and the language bindings API covers Python and Node.js.
Install
mq-bridge ships in three forms that share one engine and one config format: the
library (mq-bridge for Rust, Python, and Node.js), and the two forms of the
application mq-bridge-app — the CLI / server and the desktop app (UI). Pick the
install path for the form you need. To compile any of them yourself, see
Building from source.
Library in one line: cargo add mq-bridge · pip install mq-bridge ·
npm install mq-bridge — details in Library.
- CLI / server — Homebrew, Conda,
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.
Conda (Windows, macOS, Linux)
Conda is the package-manager install path for Windows and is also available on
macOS and Linux. It installs both mq-bridge-app and mqb:
conda install -c marcomq -c conda-forge mq-bridge-app
The marcomq channel carries mq-bridge itself; conda-forge supplies its build
and runtime dependencies.
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 - 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: library
The engine is a library first. The same route config runs embedded in your Rust,
Python, or Node.js service and in mqb — design it once, load it anywhere.
Install
| Language | Package | Install |
|---|---|---|
| Rust | mq-bridge | cargo add mq-bridge --features kafka,nats,yaml |
| Python | mq-bridge | pip install mq-bridge |
| Node.js | mq-bridge | npm install mq-bridge |
The Rust crate enables connectors through Cargo features (kafka, nats, mongodb, …, or
full for all of them); yaml adds YAML config files (JSON works without it). The Python
and Node.js packages ship with all of them built in.
Describe a route
A route moves messages from one input endpoint to one output endpoint. This one reads a
Kafka topic and publishes to NATS, retrying failed sends:
# routes.yaml
kafka_to_nats:
input:
kafka: { url: "localhost:9092", topic: "orders", group_id: "bridge" }
output:
middlewares:
- retry: { max_attempts: 5 }
nats: { url: "nats://localhost:4222", subject: "orders.processed" }
Run it
Python
from mq_bridge import Route
route = Route.from_file("routes.yaml", "kafka_to_nats")
route.run() # blocks; use route.start() to keep running your own code
Node.js
import { Route } from "mq-bridge";
Route.fromFile("routes.yaml", "kafka_to_nats").start();
Rust
#[tokio::main]
async fn main() -> anyhow::Result<()> {
mq_bridge::deploy_file("routes.yaml").await?; // every route in the file, in the background
tokio::signal::ctrl_c().await?;
Ok(())
}
Add business logic
Attach a handler to transform, filter, or answer messages in between. Returning a message publishes it to the output; returning nothing acknowledges and drops it.
def enrich(message):
order = message.json()
order["processed"] = True
return message.with_json(order)
Route.from_file("routes.yaml", "kafka_to_nats").with_handler(enrich).run()
use mq_bridge::{CanonicalMessage, Handled, Route};
Route::from_file("routes.yaml", "kafka_to_nats")?
.with_handler(|message: CanonicalMessage| async move {
let mut order: serde_json::Value = message.parse().map_err(anyhow::Error::from)?;
order["processed"] = true.into();
Ok(Handled::Publish(CanonicalMessage::from(order)))
})
.deploy("kafka_to_nats")
.await?;
Handlers, typed dispatch on the kind field, and request/reply are covered in
Embed the library and Core concepts.
Same settings everywhere
The YAML fields above are the same fields the CLI takes as URL query parameters, so everything in the connector pages and the URL parameter reference applies to library configs too:
mqb copy 'kafka://localhost:9092?topic=orders&group_id=bridge' \
'nats://localhost:4222?subject=orders.processed'
The full config grammar — routes, middleware lists, structural endpoints — is in Configuration grammar and Middleware & structural endpoints.
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 | --no-resume] [--drain] [--wait SECS]
--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.
--no-resume explicitly ignores optional cursor/checkpoint state and its
diagnostics for cursor-based sources; it is a no-op for native queue/CDC offsets.
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.
--wait SECS covers the case in between: a source another process is still
filling. It is long polling over a drain — it implies --drain, waits up to
SECS for the first message, and returns as soon as one arrives, so the value is
a ceiling rather than a delay. Without it a drain that starts a moment too early
sees an empty source and exits reporting nothing to do.
# Blocks until another process writes to the spool, then drains it and exits.
mqb copy --from spool:///var/spool/incoming --to postgres://... --wait 300
--wait waits by retrying the drain, so it lets go of the source between
attempts. That is invisible for a file or a table, but a source that allows only
one reader at a time — a dir_spool queue — is claimable by someone else in the
gap. Where that matters, use agent-listen, which holds its source open for the
whole wait.
Each retry is a fresh connection, roughly four a second. That is free for a spool
or a file, but a long --wait against a broker or a database reconnects thousands
of times; prefer a short wait there, or a continuous bridge.
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&database=db&collection=orders' \
--to null:
See the generated reference for each connector’s recognised field names.
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.
The three ways to run it
mq-bridge is one engine with one config format, exposed three ways: the library and the two forms of mq-bridge-app. 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 - 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.
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.
This page picks up where the library quick start ends (install lines are there). The exact constructor and type names are in the Language bindings API.
Design the route as config
The natural way to embed the library is to design the route as config, then load that
exact config from your code: hand-write the YAML, or build and test the route in the
desktop UI and export it. The running route behaves
identically to the mqb --config form, and every field in the
connector pages and the
middleware reference applies unchanged.
# routes.yaml
routes:
orders:
input:
kafka: { url: "localhost:9092", topic: "orders", group_id: "orders-service" }
output:
middlewares:
- retry: { max_attempts: 5 }
mongodb: { url: "mongodb://localhost:27017", database: "shop", collection: "orders" }
Handlers
A handler runs your business logic between input and output. Return a message to publish it, return nothing to acknowledge and drop the input, or raise/throw to fail it (retryable or not).
Python — handlers are synchronous; run() blocks, start() returns:
from mq_bridge import Route, NonRetryableError
def handle(message):
order = message.json()
if order.get("amount", 0) <= 0:
raise NonRetryableError("invalid amount")
return message.with_json({**order, "checked": True})
route = Route.from_file("routes.yaml", "orders").with_handler(handle)
route.start() # or route.run() to block, or `with route:` to scope it
Node.js
import { Message, Route } from "mq-bridge";
const route = Route.fromFile("routes.yaml", "orders");
route.withHandler(async (message) => {
const order = message.json();
return Message.fromJson({ ...order, checked: true });
});
route.start();
Typed dispatch — add_handler / addHandler routes on the message’s kind metadata field
and hands you decoded JSON:
route.add_handler("order.created", lambda order: {"seen": order["order_id"]})
Publishing into a route
A Publisher sends to any endpoint — for example the route’s input, to feed it from your own
code:
from mq_bridge import Publisher
publisher = Publisher.from_config({"kafka": {"url": "localhost:9092", "topic": "orders"}})
publisher.send_json({"order_id": 42}, {"kind": "order.created"})
Node.js has the same shape with Publisher.fromConfig(...) and await publisher.sendJson(...).
Rust
The Rust crate exposes the full engine: Route, Endpoint, Publisher, the handler types, and
the MessageConsumer / MessagePublisher traits. A config file loads into
mq_bridge::models::Config (a map of route name → Route), or build routes in code:
use mq_bridge::{models::Endpoint, CanonicalMessage, Handled, Route};
let handler = |mut msg: CanonicalMessage| async move {
msg.set_payload_str(format!("handled {}", msg.get_payload_str()));
Ok(Handled::Publish(msg))
};
let route = Route::new(Endpoint::new_memory("in", 200), Endpoint::new_memory("out", 200))
.with_handler(handler);
route.deploy("my_route").await?;
Typed handlers and sending commands
TypeHandler picks a handler by the message’s kind metadata field and deserializes the
payload into your type:
use mq_bridge::type_handler::TypeHandler;
use serde::{Deserialize, Serialize};
#[derive(Serialize, Deserialize)]
struct CreateUser { id: u32, username: String }
let typed_handler = TypeHandler::new()
.add("create_user", |cmd: CreateUser| async move {
println!("create_user {} {}", cmd.id, cmd.username);
// () maps to Handled::Ack
});
let route = Route::new(input, output).with_handler(typed_handler);
// Publish into the route's input; msg! sets the `kind` field.
let input_publisher = Publisher::new(route.input.clone()).await?;
input_publisher.send(msg!(&CreateUser { id: 1, username: "test".into() }, "create_user")).await?;
CQRS-style flows
Routes and typed handlers can act as a command bus and an event bus without becoming a domain framework. A command route handles the write side and emits an event; downstream routes subscribe to those events to update read models:
// Write side: handle the command, emit an event
let command_bus = TypeHandler::new()
.add("submit_order", |cmd: SubmitOrder| async move {
let evt = OrderSubmitted { id: cmd.id };
Ok(Handled::Publish(msg!(evt, "order_submitted")))
});
// Read side: project the event
let projection = TypeHandler::new()
.add("order_submitted", |evt: OrderSubmitted| async move {
// update read database / cache
Ok(())
});
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.
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 Request-Reply column in Consumer vs. subscriber, and nack support.
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).
Caveats
- If the input does not support responses (e.g. File, SQLx), a message sent to
responseis dropped. - Configure timeouts on the requester side: the bridge’s processing time adds latency.
- Middleware that drops metadata (like
correlation_id) can break the response chain. - MongoDB request/reply is emulated by polling for the reply; set its timeouts deliberately.
See also
request/responsestructural endpoints — the reference definitions.
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 | pip install mq-bridge |
| 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.
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.
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). The
checkpoint subcommand inspects and edits a source’s resume position. The
status subcommand shows what every mqb process on the machine is running.
mqb [OPTIONS] # config mode
mqb copy SOURCE TARGET [COPY OPTIONS] # one-route ad-hoc job
mqb mcp [MCP OPTIONS] # MCP server
mqb checkpoint show|reset|set ... # inspect or edit a resume position
mqb status [--no-watch] [--json] # what is running on this machine
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 127.0.0.1:9091 (0.0.0.0:9091 in the Docker image) |
No ui_addr, --no-ui passed | Not served, no prompt |
No ui_addr, interactive terminal | Asks Start the web UI on 127.0.0.1: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.
The UI has no login. --ui therefore listens on loopback, and a start with an address that
other machines can reach logs a warning. Two more rules apply to every UI:
- A UI on a loopback address answers only requests whose
Hostheader nameslocalhostor a loopback address, which stops a web page from reaching it through DNS rebinding. Behind a reverse proxy or a port forward that sends another name, list the names inMQB_UI_ALLOWED_HOSTS(comma-separated). GET /configshows each secret as a reference to the key it is stored under, such as${MQB__PUBLISHERS__<id>__HTTP__BASIC_AUTH__1}, except in theunencryptedandtemporary_messagesstorage modes. A URL keeps everything but its password. A save replaces each reference by the stored value, so a cloned or renamed endpoint keeps its secrets; a reference to a key that is not stored is refused with 400.
MQB_UI_DEFAULT_ADDR changes the address that --ui and the prompt use. It does not start
the UI.
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. |
--no-resume | off | Ignore optional cursor/checkpoint resume state, including its warnings and errors. Conflicts with --resume; native queue/CDC offsets are unchanged. |
--drain | off | Exit once the source yields an empty batch. Without it, copy runs as a continuous bridge until Ctrl-C. A drain does not wait for an endpoint: it fails on the first error while nothing has been delivered yet. |
--wait <secs> | off | Wait up to that long for the source to produce something, then drain and exit. |
--limit <n> | off | Stop after n rows reached the destination (with --filter: n matching rows). Ends the copy with or without --drain. For previews; conflicts with --resume. |
-v, --verbose | off | Log endpoints, connections and shutdown. Without it only warnings and errors are logged. |
--concurrency <N> | 4 | Route concurrency. |
--batch-size <N> | 1024 | Batch size. |
copy prints the copied … summary on stdout and its log lines on stderr, so a script can
read the summary alone. Set MQB_LOG_STDOUT=1 to get the log lines on stdout as before
0.4.18. It exits 1 when it could not deliver every row it read, and when rows could not be
decrypted. Rows sent to a dlq are reported apart from the copied ones
(copied 0 rows, dead-lettered 1_000) and leave the exit code at 0.
Two copy processes can listen on the same HTTP address: the port is opened with
SO_REUSEPORT and the kernel spreads the connections over them. That is how several
processes share one port; a second process started by mistake raises no error.
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.
Pipes: stdin and stdout
- as SOURCE reads stdin, - as TARGET writes stdout. Parameters follow as usual
(-?format=csv). The copy ends when the input does, with or without --drain, and the
copied … summary goes to stderr so it does not land among the rows.
pg_dump_rows | mqb copy '-?format=raw' 'kafka://localhost:9092?topic=rows'
mqb copy 'postgres://…/shop?table=orders&cursor_column=id' '-?format=raw' --limit 5
When the reader of stdout goes away (mqb copy … - | head -2), the copy ends with one
line, Error: stdout was closed; stopped after … rows, and exit code 1.
On a terminal a copy that runs longer than two seconds shows a running row count on stderr; it is not written to a pipe or a log file.
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.
When a full re-copy is intentional, --no-resume skips optional cursor_id and
checkpoint_store handling for SQL/ClickHouse cursor readers, MongoDB change streams, and
object-store sources. A SQL or ClickHouse cursor_column is still required for paging.
For native-position sources such as Kafka, NATS, and Postgres CDC, --no-resume is intentionally
a no-op; configure their normal group, durable, slot, or start-position settings instead.
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, as are
CSV fields and a SQL source’s numeric and timestamp columns, but comparing one against a numeric
literal reads it as a number: meta.retry_count < 3 and amount >= 50 both work untouched. A
string literal still compares as text (zip == "01234"), and number() remains available.
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=deleteopts 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,id,filter,delay,timeout,limiter,buffer,weak_join,cookie_jar,lookup,aggregate,otel,random_panic,compression,encryption,pack,unpack,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 caller gets production’s response: a fan-out answers with its first
tobranch in the order written, and amirrorbranch that is down does not change it.
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&database=db&collection=orders' \
--to null:
See the Quick start for complete, working copy
commands.
checkpoint — inspect and edit a resume position
mqb -c config.yaml checkpoint show --route orders_sync # a configured route or consumer
mqb checkpoint show SOURCE TARGET [--filter EXPR] # a `copy --resume` job
mqb -c config.yaml checkpoint reset --route orders_sync # next run starts from the beginning
mqb -c config.yaml checkpoint set --value int:1000 --route orders_sync
--route NAME names a route or consumer from the config, which -c loads (it goes before
checkpoint). Otherwise pass the same
SOURCE, TARGET and --filter the copy --resume ran with; they resolve to the same generated
cursor id. show prints the stored value, the source that wrote it, and when:
value: int:3
source: sqlx:orders:id
updated: 2026-09-30T08:12:44.120+00:00
reset prints the previous value before deleting it, so set --value can undo it. The value
format is the one show prints. -v logs connection details.
Stop the route or copy before reset or set: a reader that is still running saves its own
position over the edit with its next acknowledged batch.
It works for SQL, ClickHouse and object-store cursor readers and for MongoDB change streams. Postgres CDC is refused: its replication slot is the authoritative position. See Checkpoints & resumable copies.
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.
status — what is running on this machine
mqb status # live table, redrawn every second until Ctrl-C
mqb status --watch 5 # redraw every 5 seconds
mqb status --no-watch # one table, then exit
mqb status --json # the records as JSON, once
The table redraws by default only on a terminal. Piped output and --json print once, so
scripts end; add --watch to keep either going.
One row per route or consumer of every mqb process your user is running: a copy, a
headless config run, the web UI, the desktop app or an MCP server. It reads a local status
registry those processes write to, so it needs no config, starts nothing and connects to no
endpoint.
INSTANCE NAME FLOW STATE RATE AVG TOTAL PENDING UPTIME
cli copy [48211] copy kafka → postgres running 8123.4/s 7990.1/s 412034 - 51s
mcp default [4790] orders nats → file running 210.0/s 198.7/s 23844 3 2m00s
| Column | Meaning |
|---|---|
INSTANCE | Kind of process, its workspace and its pid. |
FLOW | Endpoint types only, never URLs. |
STATE | running, unhealthy, completed, stopped or failed; idle for a process that runs nothing. |
RATE / AVG | Messages per second now, and averaged over the route’s lifetime. |
TOTAL | Messages moved since the route started. |
PENDING | Messages waiting at the source, where the endpoint reports it. |
A process leaves the table within 7 seconds of exiting. --json prints an array of instance
records, each with its consumers, publishers and routes; with --watch it prints one
compact array per line. Errors appear as the word error unless MQB_STATUS_SHOW_ERRORS=1
is set for both the reporting process and mqb status; see
Runtime route status.
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 ~385 tokens — the same ~385 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 | 9 (11 with --agent-bus) | 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 |
--agent-bus | off | Offer the agent_listen / agent_send tools — Agent messaging |
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. |
generate_cli_command | route (input/output), name, batch_size, concurrency | Render the same route as a copyable headless mqb command, with credentials replaced by environment variable placeholders. Starts nothing. |
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. |
bridge_status | — | Every mq-bridge process of this user on this machine (CLI copies, web UI, desktop app, other MCP servers) with the state, rates and totals of its routes. Read-only; the same records as mqb status --json. |
server_info | — | Crate version, git hash, build profile and build time, plus the agent bus when it is on. |
agent_listen | name, input, capture_last | --agent-bus only. Open this server’s agent inbox so other agents can reach it. Off until called. |
agent_send | to, message, output | --agent-bus only. Send a message to another agent’s inbox. |
Call server_info before quoting any throughput number: a debug binary reports
much slower rates, and the figure would be meaningless.
Agent messaging
Two agents on one machine can message each other without a broker. This is off by
default: start the server with --agent-bus (or install it that way, see below)
and neither tool exists otherwise.
agent_listen opens this server’s inbox — a dir_spool directory under
$MQB_AGENTS_DIR (default ~/.mqb-agents/<name>) — and tails it for the session.
Nothing can reach an agent that has not called it, so the bus is opt-in twice
over: the flag offers the tools, the call opens the mailbox.
mqb mcp --agent-bus # without this the two tools are not registered
agent_listen {"name": "claude"} # opt in; off until called
agent_send {"to": "gpt-worker", "message": "..."}
route_messages {"name": "agent-inbox"} # collect mail
To register a client with the bus already enabled:
mqb mcp install --agent-bus
Mail collected this way is held in a ring of the last capture_last messages
(default 200), and the inbox file is deleted as it is read. An agent that leaves a
session running without calling route_messages therefore loses the overflow —
raise capture_last, or collect more often.
server_info reports the bus: who this server listens as, and which peers have an
inbox and are currently listening. One message is one atomically renamed file, so a
peer that is not running collects its mail when it next listens, and any process
that can write a file can take part — no MCP client required:
f=~/.mqb-agents/claude/$(printf '%09d' $SEQ)-$(uuidgen | tr -d - | cut -c1-12)
printf '%s' '{"from":"gpt","message":"hi"}' > "$f.bin.tmp" && mv "$f.bin.tmp" "$f.bin"
Both tools take an optional endpoint (input / output) replacing the local
directory, so the same bus works over NATS, Kafka or anything else when the peer is
not on this machine.
Being woken by mail
MCP has no way to wake an agent: a server cannot start a turn, so agent_listen
can only hold the mail until the agent asks for it. A background job can wake
one, and agent-listen is that job — it blocks until mail arrives, writes it out
and exits, so its completion is the notification:
mqb agent-listen claude --wait 600 # blocks; exits when mail lands
It is sugar for copy --from spool://<inbox> --wait <secs>, and writes each
envelope as one line. Point it somewhere explicit with --to.
Use agent_listen for a mailbox you poll during a session, and agent-listen
when you want the arrival itself to interrupt you — but only one of them per
inbox. dir_spool allows a single consumer, so while an MCP server is listening
as claude, mqb agent-listen claude fails with already held by a consumer.
The refusal works in both directions: agent-listen holds the inbox for its
whole wait rather than polling it, so a second listener on the same name fails
instead of quietly splitting the mail with the first.
It stops at the first delivery, not at an empty inbox, so a burst that is still
arriving may leave messages queued. They are not lost — the next agent-listen
picks them up, and the summary line says how many came through this time.
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, pack, unpack, buffer, delay, timeout, 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, and what separates them is
a fixed ~72 ms of route startup and completion polling — 0.426 s against the CLI’s
0.354 s on the same 1M-row dataset in the same session. Most of that is the measuring
client asking route_status every 50 ms, so completion is noticed up to one poll
late; the two tool calls bracketing the job cost ~0.05 ms each at p50. The gap is
fixed, so it shrinks to nothing as a proportion as the dataset grows — and it is more
visible than it used to be only because the job itself got ~2.6x faster.
| Measurement | Result |
|---|---|
| Tool-call round-trip latency (200 calls) | p50 0.054 ms · p95 0.070 ms · p99 1.343 ms |
1M-row CSV → JSONL via start_route (client wall-clock, 6 runs) | 2,466,515 rows/s (median 0.405 s) |
Same job, server’s own average_messages_per_second | no longer usable at this speed — see the note below |
copy CLI baseline, same dataset and session (§6 untyped) | 3,134,796 rows/s (5-run median 0.319 s, ±0.004) |
| Fixed cost of going through the MCP interface | ~86 ms |
| Agent tool traffic to move the whole dataset | 1,541 bytes (~385 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.
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 schema.jsonwrites it to a file,mqb --schema -prints it). - 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.
Use cases
Each page starts from a problem, shows one working solution with mq-bridge, and says where it
stops working. Every page has a runnable example in the repository’s
examples/ directory that CI executes.
| Problem | Solution | Example |
|---|---|---|
| A RAG or semantic-search index drifts from the Postgres rows it was built from | Keep a vector index in sync with Postgres: CDC → embeddings API → Qdrant, with backfill and deletes | postgres-cdc-to-qdrant |
| A service must update its database and publish an event, and the two writes can diverge | Transactional outbox with Postgres and Kafka: relay an outbox table from the WAL | transactional-outbox |
| Tests for Kafka, NATS or RabbitMQ consumers need a running broker or mock the client | Test message handlers without a broker: swap the transport for an in-memory endpoint | test-without-a-broker |
Every page has the same sections: the problem, the solution with a diagram and configuration, a runnable example, failure semantics (restarts, duplicates, deletes, an unavailable sink), when not to use mq-bridge, and a comparison with alternatives.
Related recipes elsewhere in this book: query a stream in DuckDB, Postgres CDC to a file, cross-process IPC.
How to keep a vector index in sync with Postgres (CDC → embeddings → Qdrant)
One mq-bridge route reads a Postgres table through logical replication, calls an embeddings API for each inserted or updated row and upserts the result into Qdrant; a deleted row removes its point. It copies the existing rows first and needs no Kafka in between.
Runnable example: examples/postgres-cdc-to-qdrant.
The problem: a RAG index that drifts from the database
Retrieval-augmented generation (RAG) answers from whatever is in the vector index. When the source rows live in Postgres, the index has to follow them, and the usual approaches each miss something:
- A nightly re-embedding job leaves the index up to a day stale and pays for embedding rows that did not change.
- Dual writes in the application (write the row, then call the embeddings API and the vector database) lose updates when the second call fails after the first committed, and miss every change made by a migration, an admin console or another service.
- Polling an
updated_atcolumn never sees deletes, so removed documents keep showing up in search results.
Change data capture reads the write-ahead log, so it sees every committed insert, update and delete regardless of who made it.
The solution: one route from the WAL to the index
flowchart LR
PG[(Postgres<br/>docs table)] -- logical replication --> B[mq-bridge route]
B -- "insert / update: row text<br/>POST /v1/embeddings" --> E[Embeddings API]
E -- "vector" --> B
B -- "upsert point with vector" --> Q[(Qdrant)]
B -- "delete:<br/>delete point" --> Q
Postgres needs wal_level = logical and a publication on the table:
CREATE PUBLICATION docs_pub FOR TABLE docs WITH (publish = 'insert, update, delete');
The route:
docs_to_qdrant:
input:
postgres_cdc:
url: "postgres://app:app@postgres:5432/app"
publication: "docs_pub"
slot_name: "docs_to_qdrant"
consume: capture_all # copy existing rows, then follow changes
checkpoint_store: "file:///app/checkpoint.json" # a restart does not repeat the copy
output:
switch:
metadata_key: "postgres.operation"
cases:
# Deletes carry only the primary key: remove the point, call no model.
delete:
qdrant: &qdrant
url: "http://qdrant:6333"
collection: docs
operation: "${metadata:postgres.operation}"
# Inserts and updates. Output middlewares run bottom-up.
default:
middlewares:
# 2. Shape the Qdrant point: {id, vector, payload}.
- transform:
mapping:
id: "$.id"
vector: "$.vector"
"payload.title": "$.title"
"payload.body": "$.body"
# 1. One embeddings request per batch; the vector lands in `vector`.
- lookup:
from:
openai_embeddings:
url: "http://ollama:11434"
model: all-minilm
input: "${payload:body}"
into: vector
- retry: { max_attempts: 5 }
qdrant: *qdrant
How it fits together:
postgres_cdcemits one message per changed row. The payload is the row as flat JSON; the operation is in thepostgres.operationmetadata key.switchsends deletes straight to Qdrant. Everything else goes through the embedding step first.openai_embeddingssends the texts of a whole batch in one request to an API that speaks the OpenAI wire format, which Ollama also serves. A hosted provider needs anotherurl, a model name and anapi_key.qdrantbatches points into Qdrant’s upsert and delete requests. The row’s primary key is the point id, so an update overwrites the point of that row.
This route was run with a file as its input against Ollama and Qdrant 1.19; with postgres_cdc
as its input it was not.
The collection must exist with the vector size of the model (384 for all-minilm); the example’s
compose file creates it.
The same route by hand
qdrant and openai_embeddings are requests filled in for you. Written out with the generic
http_bulk and http endpoints, the
route looks like this. It is the form to start from for a vector store or an embeddings API with
another request shape, and it is what the runnable example uses (routes.yaml), since its
container image predates the two endpoints.
docs_to_qdrant:
input:
postgres_cdc:
url: "postgres://app:app@postgres:5432/app"
publication: "docs_pub"
slot_name: "docs_to_qdrant"
consume: capture_all # copy existing rows, then follow changes
checkpoint_store: "file:///app/checkpoint.json" # a restart does not repeat the copy
output:
switch:
metadata_key: "postgres.operation"
cases:
# Deletes carry only the primary key: remove the point, call no model.
delete:
http_bulk: &qdrant
url: "http://qdrant:6333"
operation: "${metadata:postgres.operation}"
upsert:
method: PUT
path: /collections/docs/points?wait=true
format: json_array
envelope: '{"points": {documents}}'
delete:
path: /collections/docs/points/delete?wait=true
envelope: '{"points": {ids}}'
# Inserts and updates. Output middlewares run bottom-up.
default:
middlewares:
# 3. Shape the Qdrant point: {id, vector, payload}.
- transform:
mapping:
id: "$.id"
vector: "$.embedding.data[0].embedding"
"payload.title": "$.title"
"payload.body": "$.body"
# 2. Call the embeddings API; the response lands in `embedding`.
- lookup:
from:
http: { url: "http://ollama:11434" }
metadata:
http_path: "/v1/embeddings"
http_method: POST
into: embedding
# 1. Add the fields the embeddings API expects: `model` and `input`.
- transform:
mapping:
id: "$.id"
title: "$.title"
body: "$.body"
input: "$.body"
model: { path: "$.model", default: "all-minilm" }
- retry: { max_attempts: 5 }
http_bulk: *qdrant
Here the first transform builds the request the embeddings API expects, the lookup stores the
whole response under embedding, and the last transform picks the vector out of it. One request
is sent per row.
Run the route from Rust
The same file runs embedded in a service. Enable the features the route uses; the route written
by hand also needs http:
[dependencies]
mq-bridge = { version = "1", features = ["postgres-cdc", "http-bulk", "yaml"] }
tokio = { version = "1", features = ["full"] }
anyhow = "1"
#[tokio::main]
async fn main() -> anyhow::Result<()> {
mq_bridge::deploy_file("routes.yaml").await?;
tokio::signal::ctrl_c().await?;
Ok(())
}
Run the route from Python
The PyPI package includes every connector:
from mq_bridge import Route
Route.from_file("routes.yaml", "docs_to_qdrant").run()
Run it without code
The example uses the mq-bridge-app container: mq-bridge-app --config routes.yaml.
Runnable example
git clone https://github.com/marcomq/mq-bridge && cd mq-bridge/examples/postgres-cdc-to-qdrant
docker compose up -d
./search.sh "when do I get my money back"
The stack is Postgres 16, Qdrant 1.19.0, Ollama 0.12.3 with a local 384-dimension model, and
mq-bridge-app 0.4.18. ./test.sh checks the backfill, an insert, an update, a delete, a restart
and a semantic search; it runs in CI.
Failure semantics
| Event | What happens |
|---|---|
| mq-bridge restarts | It resumes from the replication slot and the checkpoint file. Changes made while it was down are applied after the restart. Without checkpoint_store, capture_all copies the whole table again, which re-embeds every row. Keep the checkpoint file on a volume. |
| Duplicates | Delivery is at-least-once: a change can be delivered again after a crash or restart. A repeated upsert writes the same point id again and a repeated delete removes nothing, so the index converges. The cost of a replay is the repeated embedding calls. |
| Deletes | A delete event carries only the key columns. The table needs a primary key (or a replica identity), and the point id must be that key. |
TRUNCATE | Not synchronized. A truncate event names no rows, so the route has nothing to delete by; the publication leaves it out (publish = 'insert, update, delete'). After truncating the table, delete the points or recreate the collection yourself. |
| Qdrant unavailable | qdrant treats 408, 429 and most 5xx responses as retryable and retry repeats the request. A message is acknowledged to Postgres only after the output accepted it, so unconfirmed changes stay in the replication slot. |
| Embeddings API unavailable or rejecting | A temporary error is retried. A permanent error (an HTTP 4xx) is logged and drops the rows of that request, which is the whole batch; add a dlq after the lookup to keep them. |
| mq-bridge stays down | Postgres keeps the WAL the slot has not confirmed. Monitor the slot’s lag and drop the slot if you retire the route. |
Limits of this recipe:
- One vector per row. There is no chunking step. To split long documents, do it in a handler and emit one point per chunk, or store chunks as rows.
- Every update re-embeds the row, including updates that did not change the embedded column.
- Large unchanged columns are omitted from update events. Postgres does not write an unchanged
TOASTed value (roughly: text over 2 kB) to the WAL, and mq-bridge leaves that field out of the
payload. An update that changes only
titleon a row with a longbodythen arrives withoutbody. The example’s rows are short and do not hit this; the recipe has not been tested with TOASTed columns. - Point ids are the unsigned integer primary key here. Qdrant accepts
unsigned integers and UUIDs as point ids, so a table with another key type needs a
transformthat derives one. - An empty text fails its batch. OpenAI refuses a request with an empty input, and one
request covers the whole batch. Drop such rows with a
filterfirst.
When not to use mq-bridge for this
- The vectors can live in Postgres. With pgvector the
embedding is a column next to the row, and a trigger or the application can keep it current in
the same transaction. There is no second system to sync. (Writing to a
vectorcolumn through mq-bridge’s SQL output has not been tested.) - You need chunking, re-ranking or multi-step document processing. That is application logic; a framework or your own worker that consumes the CDC stream fits better than a config file.
- The source is not Postgres or MongoDB. mq-bridge has native CDC for those two only. For MySQL, SQL Server or Oracle see CDC via Debezium.
- You already run Kafka Connect with Debezium. Adding a sink to that platform is less to operate than a second CDC reader on the same database.
Alternatives
| mq-bridge | Debezium | Redpanda Connect | |
|---|---|---|---|
| Postgres CDC | postgres_cdc endpoint, built in | Postgres connector | postgres_cdc input; its page states “This component requires an enterprise license” |
| Embedding step | openai_embeddings endpoint, for any OpenAI-compatible API | Embeddings transformation (Hugging Face, Ollama, ONNX MiniLM, OpenAI, Voyage AI) | Embedding processors for several providers |
| Writing to Qdrant | qdrant endpoint | Not part of Debezium’s documented sinks; a separate consumer or sink connector | qdrant output |
| How it runs | Library in a Rust, Python or Node.js process, or one binary | “Most commonly” on Kafka Connect; also Debezium Server or an embedded Java engine | One binary, YAML config |
| Sources with CDC | Postgres, MongoDB | Postgres, MySQL, SQL Server, Oracle, Db2, MongoDB and more | See its component list |
Why mq-bridge can be the better fit here
- One process covers the whole pipeline. Capture, the embeddings call and the Qdrant write are one route in one file. With Debezium the capture and the embedding step run in Kafka Connect or Debezium Server, and writing to Qdrant is a further component.
- No JVM and no Kafka to operate. mq-bridge is a native binary (the 0.4.18 macOS arm64 build of
mq-bridge-appis about 100 MB with every connector) or a library inside a Rust, Python or Node.js service you already run. - Postgres CDC is part of the open-source core. mq-bridge is licensed
MIT OR Apache-2.0, CDC included.
Redpanda Connect’s
postgres_cdcinput states that it requires an enterprise license (linked above). - Backfill and streaming are one setting.
consume: capture_allcopies the existing rows and then follows the WAL, so a new index needs no separate initial load. - The route is testable without infrastructure. The same route and handler code runs against in-memory endpoints; see Test without a broker.
Where the alternatives are the better fit: Debezium covers more databases and embedding providers, and Redpanda Connect has embedding processors for providers that do not speak the OpenAI wire format.
No throughput comparison against these tools has been measured, so none is claimed. mq-bridge’s own numbers are on the benchmark dashboard and in the tuning page.
How to implement the transactional outbox pattern with Postgres and Kafka
The application inserts an event row into an outbox table in the same transaction as its
business change, and one mq-bridge route relays those inserts from the Postgres write-ahead log to
Kafka. Events are published if and only if the transaction committed; delivery is at-least-once.
Runnable example: examples/transactional-outbox.
The problem: the dual write
A service that updates its database and then publishes an event performs two writes that cannot be committed together:
- Commit, then publish. If the process dies or the broker is unreachable between the two, the order exists and the event never does. Other services never hear about it.
- Publish, then commit. If the transaction rolls back, subscribers have acted on an order that does not exist.
- Publish inside the transaction. The broker call still succeeds or fails independently of the commit, and it holds the transaction open across a network call.
The transactional outbox
pattern replaces the second write with an insert into a table in the same database. A separate
relay publishes the rows. Reading them from the WAL instead of polling the table means no polling
query, no published flag to update and no lock contention with the application.
The solution: relay the outbox table from the WAL
flowchart LR
A[Application] -- "one transaction:<br/>orders + outbox" --> PG[(Postgres)]
PG -- logical replication --> B[mq-bridge route]
B -- "key = aggregate_id" --> K[[Kafka topic]]
K --> C[Consumers<br/>dedupe on event id]
The outbox table and a publication that carries only its inserts (Postgres needs
wal_level = logical):
CREATE TABLE outbox (
id bigint PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
aggregate_id text NOT NULL,
event_type text NOT NULL,
payload jsonb NOT NULL,
created_at timestamptz NOT NULL DEFAULT now()
);
-- Only inserts are relayed, so the application can delete old outbox rows freely.
CREATE PUBLICATION outbox_pub FOR TABLE outbox WITH (publish = 'insert');
The application writes both rows in one transaction:
BEGIN;
INSERT INTO orders (sku, qty) VALUES ('book-42', 2);
INSERT INTO outbox (aggregate_id, event_type, payload)
VALUES ('order-1', 'order.placed', '{"order_id": 1, "sku": "book-42", "qty": 2}');
COMMIT;
The relay (routes.yaml):
outbox_to_kafka:
input:
postgres_cdc:
url: "postgres://app:app@postgres:5432/app"
publication: "outbox_pub" # publishes inserts on the outbox table only
slot_name: "outbox_relay"
middlewares:
# Copies the aggregate id into the `mqb.id` metadata key ...
- id: "${payload:aggregate_id}"
output:
middlewares:
- retry: { max_attempts: 10 }
kafka:
url: "kafka:9092"
topic: "order-events"
partition_key: "mqb.id" # ... so events of one aggregate share a partition, in order
How it fits together:
postgres_cdcemits one message per inserted outbox row. The Kafka record value is that row as JSON, with thejsonbcolumn as a nested object.- The
idmiddleware puts the aggregate id into metadata, and the Kafka output’spartition_keyuses that metadata field as the record key. - A rolled-back transaction never reaches the WAL’s committed stream, so nothing is published.
For NATS, replace the output with a nats endpoint. The runnable example
and its test cover Kafka only.
Run the relay from Rust
[dependencies]
mq-bridge = { version = "1", features = ["postgres-cdc", "kafka", "yaml"] }
tokio = { version = "1", features = ["full"] }
anyhow = "1"
#[tokio::main]
async fn main() -> anyhow::Result<()> {
mq_bridge::deploy_file("routes.yaml").await?;
tokio::signal::ctrl_c().await?;
Ok(())
}
Running the relay inside the service that owns the outbox is possible, but then every replica of the service would start a relay on the same replication slot, and a slot serves one reader. Run it as one separate process or one designated instance.
Run the relay from Python
from mq_bridge import Route
Route.from_file("routes.yaml", "outbox_to_kafka").run()
Run it without code
The example uses the mq-bridge-app container: mq-bridge-app --config routes.yaml.
Runnable example
git clone https://github.com/marcomq/mq-bridge && cd mq-bridge/examples/transactional-outbox
docker compose up -d --wait
./test.sh
The stack is Postgres 16, Apache Kafka 3.9.0 and mq-bridge-app 0.4.18. ./test.sh commits one
transaction, rolls one back, deletes the outbox rows, restarts the relay and checks what is on the
topic; it runs in CI. The example’s README shows the same steps by hand.
Failure semantics
| Event | What happens |
|---|---|
| Transaction rolls back | No event. |
| Relay is down | The application is unaffected: it only writes to Postgres. Events accumulate in the replication slot and are published after the relay restarts. |
| Relay restarts | It resumes from the slot’s confirmed position. Events that were already published can be published again. With the 0.4.18 container, a plain docker compose stop and start republished earlier events in testing, so expect duplicates on every restart, not only after a crash. |
| Duplicates | Delivery is at-least-once; mq-bridge cannot commit the source position and the Kafka write atomically. Consumers must be idempotent. The outbox row’s id is in every record and identifies the event. |
| Ordering | Events with the same aggregate_id get the same Kafka key and therefore the same partition. There is no ordering across aggregates. |
| Kafka unavailable | retry repeats the send. A change is confirmed to Postgres only after Kafka accepted it, so nothing is skipped. |
| Outbox rows deleted | Not published: the publication carries inserts only. Delete rows on any schedule; the relay reads the WAL, not the table. |
| Rows written before the relay first started | Not published. The default mode, capture_new, sees changes made after the replication slot was created. Start the relay before the application writes events, or use consume: capture_all with a checkpoint_store to copy existing rows first. |
| Relay stays down | Postgres keeps the WAL the slot has not confirmed, and the disk fills eventually. Monitor the slot’s lag; drop the slot if you retire the relay. |
When not to use mq-bridge for this
- You need exactly-once delivery to Kafka. This relay is at-least-once. If consumers cannot deduplicate, this pattern with any CDC relay is the wrong tool.
- You cannot enable logical replication. Some managed databases or permission models rule out
wal_level = logicalor a replication role. A polling relay that selects unpublished rows works with plain SQL access. - The database is not Postgres. mq-bridge reads the WAL of Postgres and the change streams of MongoDB. For MySQL, SQL Server or Oracle see CDC via Debezium.
- You want events routed to a topic per aggregate type from one config. The example publishes every event to one topic. Debezium’s outbox router does this routing by default.
Alternatives
| mq-bridge | Debezium outbox event router | Redpanda Connect | |
|---|---|---|---|
| How it reads the outbox | postgres_cdc endpoint (logical replication) | Debezium Postgres connector plus the Outbox Event Router transformation | postgres_cdc input; its page states “This component requires an enterprise license” |
| Outbox conventions | None built in: you choose the columns and map the key with id and partition_key | Expects id, aggregatetype, aggregateid and payload columns by default; routes to a topic per aggregate type and sets the key | None built in |
| How it runs | Library in a Rust, Python or Node.js process, or one binary | A transformation inside Kafka Connect (“most commonly”); also Debezium Server | One binary, YAML config |
| Other databases | Postgres and MongoDB only | MySQL, SQL Server, Oracle, Db2 and more | See its component list |
Why mq-bridge can be the better fit here
- No Kafka Connect cluster. The relay is one native binary or a library call in a Rust, Python or Node.js process. Debezium’s outbox router is a transformation that runs inside Kafka Connect.
- Your outbox schema, not a prescribed one. Any columns work; the key is whichever field you
name in the
idmiddleware. Debezium’s router expects its default column names unless reconfigured. - The broker is a config line. Replacing the
kafkaoutput withnats,amqp,mqttorawsrelays the same outbox to another broker, with the same retry and dead-letter middleware. - Postgres CDC is part of the open-source core. mq-bridge is licensed
MIT OR Apache-2.0, CDC included.
Redpanda Connect’s
postgres_cdcinput states that it requires an enterprise license (linked above). - The consumers of the events can be tested without a broker, with the same library; see Test without a broker.
Where the alternatives are the better fit: Debezium’s router implements the outbox conventions (topic per aggregate type, event id header, key) out of the box and supports more databases. A polling publisher (a job that selects new outbox rows and marks them as sent) needs no replication privileges and no extra component, at the cost of polling load, added latency and write contention on the outbox table.
Measured: memory of the relay
Relaying a backlog of 500,000 outbox rows to Kafka, mq-bridge-app peaked at 51 to 142 MiB of container memory and Debezium Server at 562 to 603 MiB (two passes each, the same Kafka producer settings, Docker Desktop on an Apple M1, 2026-10-05).
Other measurements of mq-bridge are on the benchmark dashboard and in the tuning page.
How to test Kafka consumers and message handlers without a broker
Build the route from endpoints passed in by the caller: Kafka (or NATS, RabbitMQ, MQTT) in
production, mq-bridge’s in-process memory endpoint in tests. The handler, the route and the
middleware under test are the production code; only the transport is swapped, so the tests need no
Docker and wait for no broker to start.
Runnable example: examples/test-without-a-broker.
The problem: slow, flaky or shallow tests for messaging code
Code that consumes from a broker is awkward to test:
- A real broker in every test run (Docker Compose or Testcontainers) costs seconds to minutes of startup, needs Docker on every developer machine and CI runner, and fails for reasons that have nothing to do with the change under test.
- Mocking the client library tests the mock. Hand-written fakes of a Kafka consumer drift from the real API and skip the code between the client and the handler: deserialization, routing by message type, acknowledgement.
- Testing the handler function alone is fast but leaves that same glue untested.
The solution: make the transport a parameter
flowchart LR
subgraph Production
K1[[Kafka topic]] --> R1[route + handler] --> K2[[Kafka topic]]
end
subgraph Test
M1[memory channel] --> R2[same route + handler] --> M2[memory channel]
end
A route is an input endpoint, an output endpoint and an optional handler. The handler works with mq-bridge’s message type and never sees a Kafka record, so the same route runs over any endpoint. In production the two endpoints are a broker:
orders:
input:
kafka: { url: "localhost:9092", topic: "orders.in", group_id: "orders" }
output:
kafka: { url: "localhost:9092", topic: "orders.out" }
In a test they are in-process channels, which need no Cargo feature and no service:
orders:
input:
memory: { topic: "orders.in", capacity: 100 }
output:
memory: { topic: "orders.out", capacity: 100 }
Rust
The route takes its endpoints as arguments:
use mq_bridge::{models::Endpoint, CanonicalMessage, Handled, HandlerError, Route};
use serde_json::{json, Value};
/// Business logic: reject orders without a positive amount, mark the rest as checked.
async fn check_order(msg: CanonicalMessage) -> Result<Handled, HandlerError> {
let mut order: Value = msg
.parse()
.map_err(|e| HandlerError::NonRetryable(e.into()))?;
if order["amount"].as_f64().unwrap_or(0.0) <= 0.0 {
return Ok(Handled::Ack);
}
order["checked"] = json!(true);
let out = CanonicalMessage::from_json(order).map_err(|e| HandlerError::NonRetryable(e.into()))?;
Ok(Handled::Publish(out))
}
/// The route used in production and in tests. Only the endpoints differ.
pub fn orders_route(input: Endpoint, output: Endpoint) -> Route {
Route::new(input, output).with_handler(check_order)
}
The test passes memory endpoints, sends messages into one channel and reads the other:
use std::time::Duration;
#[tokio::test(flavor = "multi_thread")]
async fn valid_orders_are_checked_and_invalid_ones_dropped() {
let input = Endpoint::new_memory("orders.in", 100);
let output = Endpoint::new_memory("orders.out", 100);
let (orders_in, orders_out) = (input.channel().unwrap(), output.channel().unwrap());
orders_route(input, output).deploy("orders").await.unwrap();
for order in [json!({"id": 1, "amount": 25.0}), json!({"id": 2, "amount": 0})] {
let msg = CanonicalMessage::from_json(order).unwrap();
orders_in.send_message(msg).await.unwrap();
}
let mut received = Vec::new();
for _ in 0..100 {
received.extend(orders_out.drain_messages());
if !received.is_empty() && orders_in.is_empty() {
break;
}
tokio::time::sleep(Duration::from_millis(20)).await;
}
Route::stop("orders").await;
assert_eq!(received.len(), 1);
let order: Value = received[0].parse().unwrap();
assert_eq!(order, json!({"id": 1, "amount": 25.0, "checked": true}));
}
[dependencies]
mq-bridge = { version = "=0.4.18", default-features = false } # production adds e.g. features = ["kafka"]
serde_json = "1"
[dev-dependencies]
tokio = { version = "1", features = ["macros", "rt-multi-thread", "time"] }
Python
The route is built from a config dict, so the test passes a different dict:
from mq_bridge import Route
def check_order(order):
"""Business logic: drop orders without a positive amount, mark the rest as checked."""
if order.get("amount", 0) <= 0:
return None
return {**order, "checked": True}
def orders_route(config: dict) -> Route:
"""The route used in production and in tests. Only `config` differs."""
return Route.from_config(config).add_handler("order.created", check_order)
from mq_bridge import Consumer, Publisher
from orders import orders_route
INPUT = {"memory": {"topic": "orders.in", "capacity": 100}}
OUTPUT = {"memory": {"topic": "orders.out", "capacity": 100}}
def test_valid_orders_are_checked_and_invalid_ones_dropped():
publisher = Publisher.from_config(INPUT)
results = Consumer.from_config(OUTPUT)
with orders_route({"input": INPUT, "output": OUTPUT}):
publisher.send_json({"id": 1, "amount": 25.0}, {"kind": "order.created"})
publisher.send_json({"id": 2, "amount": 0}, {"kind": "order.created"})
publisher.send_json({"id": 3, "amount": 5.0}, {"kind": "order.created"})
received = []
while len(received) < 2:
batch = results.poll(max=10, timeout_ms=5000)
assert batch, "timed out waiting for the route"
received.extend(batch)
assert [m.json() for m in received] == [
{"id": 1, "amount": 25.0, "checked": True},
{"id": 3, "amount": 5.0, "checked": True},
]
The Node.js binding has the same memory endpoint; this page’s example covers Rust and Python.
Runnable example
This one needs no Docker:
git clone https://github.com/marcomq/mq-bridge && cd mq-bridge/examples/test-without-a-broker
cargo test # Rust
cd python && pip install -r requirements.txt && pytest # Python
Both pin mq-bridge 0.4.18 and run in CI.
Failure semantics: what an in-memory test does and does not prove
| Aspect | In the memory test | Against a real broker |
|---|---|---|
Handler logic, routing by kind, middleware in the route | Exercised as in production | Same code |
| Restarts | A memory channel lives in the process. Stopping the process loses its content. | The broker keeps messages and redelivers from the committed position |
| Duplicates | None occur by themselves. To test idempotency, send the same message twice. | At-least-once delivery produces them after crashes and rebalances |
| Ordering | One channel, in send order | Per partition, subject or queue, depending on the broker |
| Sink unavailable | Not simulated by the memory endpoint | Network errors, timeouts, throttling |
| Broker configuration | Not covered: topics, ACLs, TLS, consumer groups, serialization of headers | Covered |
A test over memory channels shares global state by topic name within one process. Give each test its own topic names (or route name) when tests run in parallel.
When not to use this
- You are testing the broker integration itself: consumer-group rebalancing, offset commits, partition assignment, authentication, schema-registry interaction. Use a real broker.
- Your code uses a broker client directly and you do not want mq-bridge in production. The memory endpoint replaces a transport behind mq-bridge’s route; it is not a fake Kafka server that other client libraries can connect to.
- You need wire compatibility checks between producers and consumers written in different stacks. Contract tests or a shared broker environment cover that.
Alternatives
mq-bridge memory endpoint | Testcontainers | Mocking the client | |
|---|---|---|---|
| What runs | Your route and handler, in-process | A real broker in a container, started by the test | Your handler against a hand-written or generated fake |
| Needs Docker | No | Yes | No |
| Covers broker behaviour | No | Yes | No |
| Covers the code between client and handler | Yes, if that code is the mq-bridge route | Yes | Usually not |
| Requires | mq-bridge as the messaging layer in production | Nothing about your production stack | Nothing about your production stack |
Why mq-bridge can be the better fit here
- The test runs the production route. Nothing is faked: the handler, the dispatch on the message type, the middleware and the acknowledgement path are the same objects, with a different endpoint underneath.
- No Docker in the inner loop. The tests start no container, so they run wherever
cargo testorpytestruns. - The transport stays a configuration choice afterwards. The same handler can move from Kafka to NATS or RabbitMQ by changing the endpoint, with the tests unchanged.
Measured with the example on an Apple M1 (2026-10-05, three runs each): the Rust test finished in
0.02 s (cargo test 0.16 s wall time after the build) and the Python test in 0.07 s. An
apache/kafka:3.9.0 container with the image already pulled took 4.3 to 4.8 s to pass its health
check, before any test could run.
Where the alternatives are the better fit: Testcontainers is independent of the library you use and is the only option in this table that exercises a real broker. The two combine well: most tests on memory endpoints, a few integration tests against a container.
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
output:
mongodb:
url: "mongodb://localhost:27017"
database: "app_db"
collection: "webhooks"
format: "json" # a bit slower, but stores readable documents
middlewares:
- retry:
max_attempts: 3
initial_interval_ms: 500
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.
When not to use it
IPC is at-most-once across a consumer crash. A send is acknowledged once its bytes are in the kernel socket buffer, so messages the consumer had not yet processed are lost when it dies, and the publisher is never told. It also serves one publisher at a time: a second one connects without an error and then waits until the first disconnects.
If the hand-over must survive a crash of either process, use a directory spool between the two processes, or a broker.
See also
- Configuration grammar — this is Route 9 of the annotated example, with the full URL grammar in comments.
- Endpoints (concepts).
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
delete competing-consumer mode, which requires a UUID _id.
SQL (sqlx) — ON CONFLICT / ON DUPLICATE KEY
For a table whose columns are the record’s fields, columns: auto with key: id generates the
upsert for you; see Writing by column name.
The rest of this section is for a hand-written insert_query.
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.
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 | — |
memory://[name][?max_keys=N] | per-process, forgotten on restart | — |
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>. Under mqb copy, whose route name
is new on every run, it defaults to mqb_dedup_copy_<id>: the id is derived from source,
destination and --filter without credentials, so the same command finds its keys again.
A sqlite:// URL has no place for a table name and keeps the per-run default. 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>". memory:// is the
fastest store (~3x sled) but forgets every key on restart, so redeliveries after a restart go
through again; it holds at most max_keys keys (default 1,000,000), evicting the oldest first.
# 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. - File / object storage — no constraint, so the file name does the job:
name_by: source_positionnames each part after the source range it holds, and a restart skips ranges already written. Needs a replayable input (Kafka, Postgres CDC, SQL cursor, MongoDB change stream,fileinconsumemode);object_storepicks it by default for one.
output:
object_store:
url: "s3://my-bucket/orders"
name_by: source_position # part-orders-<partition>-<start>-<end>.jsonl
Full examples in Upserts & insert-if-absent and, for files, Files & object storage.
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_storedisables resume with a warning. Use--resumewhen a missing resumable configuration must be fatal, or--no-resumewhen a full re-copy is intentional and resume diagnostics should be ignored. - 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. - The default name carries a hash for a source name with other characters than letters,
digits and
_:public.ordersgetsmqb_cursors_public_orders_<hash>, so it cannot share a table with a source namedpublic_orders. A table that already exists asmqb_cursors_public_orderskeeps being used. - A file store is written atomically (temp file + rename) and concurrent writers in one process are serialized, so several routes may share one file. Use one process per file: two processes that write the same file overwrite each other’s cursors.
- Cloud object-store checkpoints need the
object-storefeature compiled in.
Using it with copy
Pass --no-resume to make an intentional full copy without checkpoint diagnostics. For SQL and
ClickHouse, cursor_column is still required to page through the source; the flag only ignores
optional cursor_id / checkpoint_store state. It currently applies to cursor-based SQL and
ClickHouse sources, MongoDB change streams, and object-store sources. It does not change native
queue or CDC offsets and cannot be combined with --resume; for sources such as Kafka, NATS,
and Postgres CDC, the flag is intentionally a no-op.
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?database=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:
sqlx:
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 }
Translating a copy command into a config file
Most of a copy command maps field for field: URI query parameters become endpoint config keys,
--drain becomes exit_on_empty: true, and --filter becomes a filter middleware on the
input. mqb to-cli goes the other way — it prints a command for a loaded config — but there is no
converter in this direction, so three differences are worth knowing.
--no-resume is not a field to copy across. It does not remove cursor_id from the config;
it makes the consumer ignore it. Translating the fields literally therefore reverses the
behaviour — the CLI run re-copies everything, the config route resumes and copies nothing. The
correct translation is to omit cursor_id, which is how a config route says the same thing:
mqb copy 'sqlite:///data/app.db?table=orders&cursor_column=id&cursor_id=nightly' \
'file:///data/orders.jsonl' --drain --no-resume
orders_dump:
exit_on_empty: true
input:
sqlx: { url: "sqlite:///data/app.db", table: orders, cursor_column: id } # no cursor_id
output:
file: { path: /data/orders.jsonl }
--resume cannot be reproduced. It generates a copy-… cursor id from the
credential-redacted source, destination, and filter. Re-running the same command resumes
correctly, and mqb checkpoint show SOURCE TARGET finds its position, but a config route has
no way to name that id, so it starts a fresh checkpoint under whatever cursor_id you choose.
The defaults differ. copy defaults to concurrency: 4 and batch_size: 1024; a route
defaults to 1 and 512. Write both out explicitly, or the translated route runs at a quarter
of the concurrency for no visible reason.
--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.
Inspecting and editing a checkpoint
mqb checkpoint reads and changes a stored position without touching the store by hand:
mqb -c config.yaml checkpoint show --route orders_sync # a configured route or consumer
mqb checkpoint show 'sqlite:///data/app.db?table=orders&cursor_column=id' \
file:///data/orders.jsonl # a `copy --resume` job
value: int:3
source: sqlx:orders:id
updated: 2026-09-30T08:12:44.120+00:00
resetdeletes the position, so the next run starts from the beginning. It prints the old value first.set --value Voverwrites it, e.g. to replay from a known row (int:1000) or to undo a reset. Use the formatshowprints. For a SQLcursor_columnsource a value in another form thanint:<number>orstr:<text>is refused.- Stop the route or copy first.
resetandsetdo not coordinate with a running reader: its next acknowledged batch saves its own position over the edit. - A
copy --resumejob is named by the same SOURCE, TARGET and--filterit ran with. - Postgres CDC is refused: manage its replication slot instead.
Each position is stored with the source that wrote it and a timestamp. On SQL and ClickHouse
the source is the table and cursor_column, on MongoDB the database and collection. A run
refuses a position written by a different source instead of resuming from a value that
means something else, e.g. after cursor_column changed under the same cursor_id. show
marks it MISMATCH. Reset it, or pick a new cursor_id. Positions written by mq-bridge
0.4.16 or older still load and are upgraded on the next save; an older mq-bridge cannot read
the new format and starts from the beginning.
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; a file or object-store sink does this with
name_by: source_position. - 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, with more types on Postgres. Other types the
SQL
Anydriver can’t decode fail permanently; expose the column asBIGINT/TEXTthrough a view. On Postgres a timestamp,date,numericoruuidcolumn works too. 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. Changing the table orcursor_columnunder the same key fails the run instead; see Inspecting and editing a checkpoint. 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 —
copyandcheckpointflags, 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.
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.
MySQL, SQL Server & Oracle CDC via Debezium
mq-bridge captures changes natively from PostgreSQL and MongoDB. For MySQL, MariaDB, SQL Server, Oracle and Db2, run Debezium Server next to it: Debezium reads the database’s change log and hands each change to mq-bridge, which routes, transforms and writes it like any other message.
Debezium Server is a standalone process. It needs no Kafka and no Kafka Connect.
This recipe has not been run end to end. Verify the Debezium properties against the current Debezium Server documentation.
Hand over by HTTP (no broker)
Debezium’s HTTP sink POSTs one change per request, and mq-bridge’s HTTP input receives it.
# Debezium Server: application.properties
debezium.source.connector.class=io.debezium.connector.mysql.MySqlConnector
debezium.source.database.hostname=localhost
debezium.source.database.port=3306
debezium.source.database.user=debezium
debezium.source.database.password=${MYSQL_PASSWORD}
debezium.source.database.server.id=184054
debezium.source.topic.prefix=shop
debezium.source.table.include.list=shop.orders
debezium.source.offset.storage.file.filename=data/offsets.dat
debezium.source.schema.history.internal=io.debezium.storage.file.history.FileSchemaHistory
debezium.source.schema.history.internal.file.filename=data/schema-history.dat
debezium.sink.type=http
debezium.sink.http.url=http://localhost:8080
# Plain JSON rows instead of the schema + before/after envelope
debezium.format.key.schemas.enable=false
debezium.format.value.schemas.enable=false
debezium.transforms=unwrap
debezium.transforms.unwrap.type=io.debezium.transforms.ExtractNewRecordState
debezium.transforms.unwrap.delete.tombstone.handling.mode=rewrite
mysql_orders_to_postgres:
input:
http: { url: "127.0.0.1:8080" }
output:
sqlx:
url: "postgres://user:pass@localhost:5432/warehouse"
table: "orders"
insert_query: >-
INSERT INTO orders (id, status, amount)
VALUES (${payload:id}, ${payload:status}, ${payload:amount})
ON CONFLICT (id) DO UPDATE SET status = EXCLUDED.status, amount = EXCLUDED.amount
- The
unwraptransform makes each payload the row itself ({"id": 7, "status": "open", …}). Without it the row is underafter, inside Debezium’s change envelope. - A failed write answers the request with HTTP 500 and Debezium sends the change again, so delivery is at-least-once. The upsert makes the repeat harmless; see Upserts & insert-if-absent.
- Debezium keeps the log position in its offset file. Keep that file and the schema history on durable storage, or a restart re-reads the initial snapshot.
- For another database, change
connector.classand thedatabase.*properties. The mq-bridge route stays the same.
Hand over through a broker
If Debezium already publishes to Kafka, or mq-bridge should be able to stop without holding
Debezium up, read the change topic instead. Debezium names it <topic.prefix>.<database>.<table>:
input:
kafka: { url: "localhost:9092", topic: "shop.shop.orders", group_id: "mqb-orders-sync" }
Debezium Server also sinks to NATS JetStream, Redis Streams, Pulsar and RabbitMQ, each of which mq-bridge reads with its native connector.
Deletes
With delete.tombstone.handling.mode=rewrite, a delete arrives as the last known row with
"__deleted": "true"; every other change carries "__deleted": "false". The upsert above
would write a deleted row back, so send deletes to their own statement with a
switch, or drop them with a filter.
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. - Paths have no quoting: a CSV column whose header contains
.or[(e.g.price.usd) cannot be addressed by a mapping. - Coercions are the lossless ones only:
string → integer,string → number,string → boolean,number → string.string → numberkeeps integers exact within the i64/u64 range ("42"→42); larger integers and fractional text go through f64 (~17 significant digits) and may lose precision.
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.
Enrichment & lookups
The lookup middleware asks another endpoint for each message
and writes the answer into the payload. Use it to join an event with master data, or to add
features such as a previous value or a moving average (for counters and averages that need
no database, see running aggregates). The endpoint asked must answer:
http, static, mongodb with find, sqlx or clickhouse with lookup_query, nats /
memory with request_reply: true, or grpc to an mq-bridge grpc input whose route replies.
For a model’s answer or an embedding, see LLM calls in a pipeline.
Enrich, then route on the result
On an output, the enriched message goes on to the next endpoint. lookup.found tells a
switch whether the record exists:
orders_enrich:
input: { kafka: { topic: "orders", url: "localhost:9092" } }
output:
middlewares:
- lookup:
from:
sqlx:
url: "postgres://localhost/crm"
table: "customers"
lookup_query: "SELECT id, name, tier FROM customers WHERE id IN (${payload:customer_id}::int)"
into: customer
switch:
metadata_key: "lookup.found"
cases:
"true": { kafka: { topic: "orders.enriched", url: "localhost:9092" } }
"false": { kafka: { topic: "orders.unknown_customer", url: "localhost:9092" } }
Because the key sits inside IN (…), one query answers the whole batch and rows are matched
back to messages by id. Write WHERE id = ${payload:customer_id} instead for one query per
message. A failed lookup fails only its message; list retry / dlq after lookup to handle it.
Several lookups, before the handler
On an input, the handler already sees the enriched message. Lookups in entries run in
parallel, so a batch waits for the slowest one, not the sum:
input:
kafka: { topic: "payments", url: "localhost:9092" }
middlewares:
- lookup:
concurrency: 64
entries:
- into: features.user
from:
mongodb:
url: "mongodb://localhost:27017"
database: "features"
collection: "users"
find: '{"_id": "${payload:user_id}"}'
- into: features.card_avg
from:
sqlx:
url: "postgres://localhost/payments"
table: "payments"
lookup_query: >-
SELECT avg(amount)::text AS avg20 FROM (SELECT amount FROM payments
WHERE card_id = ${payload:card_id} ORDER BY ts DESC LIMIT 20) t
Each entry sets lookup.<into>.found; lookup.found is true only when all of them found
something. On an input a temporary error nacks the whole batch, so the source redelivers it,
and reconnects the route. A permanent error (non-JSON payload, HTTP 4xx, a query error) is
logged and drops only its message.
Postgres gotchas
- Cast placeholders to the column type:
WHERE id = ${payload:author_id}::int. A field missing from the payload, e.g. on a CDC delete, is otherwise bound as text and fails. - Cast
NUMERIC,TIMESTAMPTZand similar result columns to::text. They arrive as JSON strings. - A per-message
lookup_queryreturns the first row only; useORDER BY … LIMIT 1for “the latest”. A batched (IN) query takes no plainLIMIT.
LLM calls in a pipeline
Two endpoints call an OpenAI-compatible API:
openai_chat sends one chat completion per message, openai_embeddings one
embeddings request per batch. Both are used as the from of a
lookup, which writes the answer into the message. The message
then goes on to any output, with the retries, dead-letter queue and
back-pressure of an ordinary route.
Chat, embeddings and the Qdrant upsert were run against Ollama (qwen2.5:0.5b,
all-minilm) and Qdrant 1.19, with files in place of Kafka and Postgres.
OpenAI itself was not called.
Classify, then route on the answer
With json: true the answer is parsed, so a switch can read its fields:
tickets_triage:
input: { kafka: { topic: "tickets", url: "localhost:9092" } }
output:
middlewares:
- lookup:
concurrency: 8
from:
openai_chat:
api_key: "${env:OPENAI_API_KEY}"
model: gpt-4o-mini
json: true
system: 'Answer as JSON: {"urgent": boolean, "topic": string}'
prompt: "${payload:subject}\n\n${payload:body}"
options: { temperature: 0 }
into: triage
- retry: { max_attempts: 5 }
- dlq:
endpoint: { kafka: { topic: "tickets.failed", url: "localhost:9092" } }
switch:
when:
- if: "triage.urgent == true"
to: { kafka: { topic: "tickets.urgent", url: "localhost:9092" } }
default: { kafka: { topic: "tickets.normal", url: "localhost:9092" } }
retry repeats only the messages whose request failed, so answers already
paid for are kept. An answer that is not JSON fails its message for good and
goes to the dlq.
Summarise, and keep only the answer
As the to of a request endpoint, the
model’s answer replaces the message and is forwarded:
articles_summarise:
input: { file: { path: "articles.jsonl", format: raw } }
output:
request:
to:
openai_chat:
url: "http://localhost:11434"
model: llama3.2
prompt: "Summarise in one sentence: ${payload:text}"
forward_to: { file: { path: "summaries.jsonl", format: raw } }
A request endpoint sends one message at a time, so this form makes one API
request per message. For messages_per_request and batched embeddings use a
lookup, or put a buffer on the to endpoint
(middlewares: [{ buffer: { max_messages: 500, max_delay_ms: 0 } }] next to
openai_chat), which hands the sends of a batch to the endpoint together.
On the command line the endpoint is openai://host?model=…&prompt=….
openai://host is plain HTTP, for a local server such as Ollama;
openai+https://host is HTTPS. Without a url the endpoint calls
https://api.openai.com/v1.
Bundle messages into one request
A short classification repeats the same instructions for every message.
messages_per_request sends several messages as numbered inputs of one
completion and gives each message its own answer, so the system message is
paid for once per request:
reviews_scored:
batch_size: 100
input: { kafka: { topic: "reviews", url: "localhost:9092" } }
output:
middlewares:
- lookup:
from:
openai_chat:
api_key: "${env:OPENAI_API_KEY}"
model: gpt-4o-mini
system: "Answer with one word: positive, negative or neutral."
prompt: "${payload:text}"
messages_per_request: 20
into: sentiment
- retry: { max_attempts: 5 }
kafka: { topic: "reviews.scored", url: "localhost:9092" }
Each answer carries the number of its input, so the order the model answers
in does not matter. Answers that cannot be matched are asked again in halves,
so a model that loses count costs more, not less. Put the task into system
and only the data into prompt. What to expect is on the
connector page.
Embed a table into a vector store
One embeddings request covers a whole batch. The vector lands in the message,
and a transform shapes the point the store expects. Output middlewares run
bottom-up.
docs_to_qdrant:
input:
sqlx:
url: "postgres://user:pass@localhost/app"
table: docs
cursor_column: id
output:
middlewares:
- transform:
mapping:
id: "$.id"
vector: "$.vector"
"payload.title": "$.title"
- lookup:
from:
openai_embeddings:
url: "http://localhost:11434"
model: all-minilm
input: "${payload:title}: ${payload:body}"
into: vector
- retry: { max_attempts: 5 }
qdrant: { url: "http://localhost:6333", collection: docs }
To follow changes and deletes as well, read the table with postgres_cdc; the
Qdrant page has that route.
From Rust
Each endpoint has a typed configuration that converts into an Endpoint:
use mq_bridge::endpoints::http_bulk::OpenAiChatConfig;
use mq_bridge::models::Endpoint;
let llm: Endpoint = OpenAiChatConfig {
model: "gpt-4o-mini".into(),
prompt: "Summarise: ${payload:text}".into(),
api_key: std::env::var("OPENAI_API_KEY").ok(),
..Default::default()
}
.into();
It needs the http-bulk feature and no registration.
Cost and limits
- Every message is a request. For chat, the
lookup’sconcurrency(default 16) is the number of requests in flight. Lower it, or add alimiter, to stay under a provider’s rate limit.messages_per_requestputs several messages into one request instead. - A 429 is retryable. With
retrythe route slows down instead of losing messages; the input is not acknowledged until the answer is in. - Nothing is cached. A message that is delivered twice is sent, and billed,
twice. Put
deduplicationon the input if that matters. - Prompts are escaped. Text taken from a message cannot break the request body. It can still contain instructions to the model; treat the answer as untrusted input.
Stream tokens
These endpoints wait for the whole answer. For a token stream, call the API
with the http output and stream_response_to, which
publishes each server-sent event as its own message.
Other providers
| Provider | How |
|---|---|
| OpenAI | default url; set api_key |
| Ollama, vLLM, LM Studio | url: "http://host:port"; /v1 is added |
| Groq, Mistral, OpenRouter and other compatible APIs | their base URL including the path, and api_key |
| Anthropic and other APIs of another shape | http_bulk with query.per_message |
Of these, only Ollama was called.
Running aggregates
The aggregate middleware keeps one state per key inside
the process and updates it with every message: counters, sums, moving averages. The message
goes on with the state written into its payload. Use it for values that depend on earlier
messages, such as “how many readings did this sensor send so far” or “what is this sensor’s
usual reading”. No database is asked, so it is much faster than a lookup that
lets a database keep the counter.
Where it fits
mq-bridge moves messages, and a few middlewares keep something about them between
messages: deduplication the keys it has seen, aggregate a value
computed from them. It is meant for a value the pipeline needs while the message is on its
way: to filter, route or alert on it, to hand it to the receiver, or to thin out what reaches
an expensive sink. It is kept small on purpose: one state per key, updated message by
message, in the order the messages arrive.
| You need | Use |
|---|---|
a running value per key that a filter, a switch or the receiver decides on | aggregate |
| the same, surviving restarts or shared by several instances | aggregate with a store |
| a counter that other applications update too | a writing lookup |
| totals for reports only, in a database that aggregates on its own | write the raw rows and let the sink aggregate |
| time windows, event time and late data, joins between streams, exactly-once state | a stream processor, with mq-bridge in front of it or behind it |
Letting the database keep the counter is the obvious alternative, and the slow one: it
costs a read and a write per message and key. In our measurements a lookup that updated
five keys per message reached about 17k msg/s on PostgreSQL and about 2k on MongoDB. With
single_writer, aggregate writes a changed state once per flush instead of once per
message and reaches 240k on the same PostgreSQL (see Performance).
Windows, event time and joins are out of scope. They need the state and the input position committed together, which a bridge between arbitrary endpoints cannot promise; see How this compares to stream processors.
A counter and a sum per key
sensor_stats:
input:
kafka: { topic: "readings", url: "localhost:9092" }
middlewares:
- aggregate:
consistency: single_writer
key: "${payload:sensor_id}"
into: sensor
expression: "{ total: (state.total ?? 0) + reading, count: (state.count ?? 0) + 1 }"
output:
kafka: { topic: "readings.enriched", url: "localhost:9092" }
{"sensor_id": 7, "reading": 20} leaves as
{"sensor_id": 7, "reading": 20, "sensor": {"total": 20, "count": 1}}. The next message for
sensor 7 continues from that state.
consistency: single_writer without a store says that the states may live in this process
only: they are gone after a restart and one instance runs the route. Leaving out both is a
startup error, so that states in memory are never the result of a forgotten store.
A CSV sink writes that nested result as the columns sensor.total and sensor.count, not as
JSON text in one cell; see CSV.
expression returns the new state. It reads payload fields by name, metadata as meta.<key>,
and state, the value it returned for the previous message with the same key. For a new key
state is null, so state.total ?? 0 gives the starting value. The language is the one
transform and filter use.
The usual form avg * 0.99 + reading * 0.01 starts at the first reading and needs hundreds
of messages to forget it. Keep the weighted sum and the weight, and divide when writing:
- aggregate:
consistency: single_writer
key: "${payload:sensor_id}"
into: sensor.avg_reading
expression: "{ s: (state.s ?? 0) * 0.99 + reading, w: (state.w ?? 0) * 0.99 + 1 }"
output: "state.s / state.w"
output shapes what the message carries; the stored state stays {s, w}. After a first
reading of 500 followed by readings of 50, this reads 52.6 after 100 messages. The usual form
still reads 216.
Compare a message with what came before
With emit: previous the message carries the state from before its own update. That is what
a rule such as “this reading is five times the sensor’s average” needs:
- aggregate:
consistency: single_writer
key: "${payload:sensor_id}"
into: sensor.avg_before
emit: previous
expression: "{ s: (state.s ?? 0) * 0.99 + reading, w: (state.w ?? 0) * 0.99 + 1 }"
output: "state.s / state.w"
For the first message of a sensor sensor.avg_before is null.
A fixed factor is a rough rule. With the built-in aggregates (see below) the message can
carry the average and the standard deviation from before its own update, and a filter or
switch behind it can ask how many deviations the reading is away:
- aggregate:
consistency: single_writer
key: "${payload:sensor_id}"
into: before
emit: previous
fields:
avg: ema(reading, 0.01)
sd: ema_stddev(reading, 0.01)
stddev and variance cover all readings of the key; the ema_ forms weight them like
ema, so old readings fade and the result follows a sensor that drifts. Both are sample
values (divided by n − 1) and null for the first message of a key, which has no spread yet.
Several keys from one message
entries updates one state per key, each with its own expression:
- aggregate:
consistency: single_writer
entries:
- key: "${payload:sensor_id}"
into: stats.sensor
expression: "{ sum: (state.sum ?? 0) + reading, n: (state.n ?? 0) + 1 }"
- key: "${payload:site_id}"
into: stats.site
expression: "{ max: max([state.max ?? reading, reading]) }"
Built-in aggregates: the fast path
For counters, sums and averages you do not need an expression. fields names the results and
what each one computes:
- aggregate:
consistency: single_writer
entries:
- key: "${payload:sensor_id}"
into: sensor
fields:
n: count
total: sum(reading)
avg: ema(reading, 0.01)
- key: "${payload:site_id}"
into: site
fields: { high: max(reading), usual: mean(reading) }
Available are count, sum, min, max, last, mean, stddev, variance, and
ema(path, alpha), ema_stddev(path, alpha), ema_variance(path, alpha), and the
time-based decayed_count(half_life) and decayed_sum(path, half_life). ema is the
moving average from above: it has no start bias, so the first message reads its own value. The value may be a number or a numeric string, as a CSV source delivers it. fields
is several times faster than an expression; use an expression where you need logic that the
built-ins do not cover.
A moving average by time
ema(reading, 0.01) counts messages: a sensor that reports every second forgets a value a
thousand times faster than one that reports every quarter of an hour. Give a half-life
instead of alpha and the weight follows the time between two messages of a key:
- aggregate:
consistency: single_writer
key: "${payload:sensor_id}"
into: sensor
time: measured_at
fields:
avg: ema(reading, 5m)
spread: ema_stddev(reading, 1h)
A reading counts half after five minutes, a quarter after ten. time names the payload
field with the message’s time: epoch seconds or milliseconds, or an RFC 3339 text. Without
time the clock of the process is used, and a replay then computes different values.
A message that is older than the newest one its state has seen counts with the weight it
would have had by then: half per half-life it is late. The averages and decayed values
therefore come out the same in any arrival order. last keeps the newest value. The message
carries the metadata mqb.aggregate.late, which names the entries concerned. Filter or route
on it where late data must not count at all:
- filter: 'meta["mqb.aggregate.late"] == null'
A wrong time far in the future makes every later message late. Send one message with the
metadata mqb.aggregate.overwrite: true to set the state’s clock back to its time.
Rising or falling activity
decayed_count(half_life) counts messages that fade: one counts half after a half-life. At
a steady rate it settles at about the messages of the last 1.44 half-lives, without a
window. decayed_sum(path, half_life) does the same for a value. A short and a long
half-life side by side compare the recent activity of a key with its own usual level:
- aggregate:
consistency: single_writer
key: "${payload:customer_id}"
into: orders
time: created_at
fields:
last_hour: decayed_count(1h)
last_week: decayed_count(7d)
amount_hour: decayed_sum(amount, 1h)
amount_week: decayed_sum(amount, 7d)
last_hour × 168 well above last_week means far more orders than this customer usually
places.
Messages with missing fields
A message that lacks a field an entry reads, or a key, cannot be folded. on_error says
what happens then:
on_error | Effect |
|---|---|
drop (default) | On an input the message is logged, acked and dropped; an HTTP caller still gets 202. On an output it fails. |
fail | On an input the message is nacked: an HTTP caller gets 500, a broker delivers it again. |
skip | Only the entries that cannot be computed are left out. The message goes on and carries mqb.aggregate.skipped with their into paths. |
- aggregate:
consistency: single_writer
on_error: skip
entries:
- { key: "${payload:sensor_id}", into: sensor, fields: { avg: "ema(reading, 0.01)" } }
- { key: "${payload:site_id}", into: site, fields: { high: max(reading) } }
A message without site_id leaves with sensor filled, without site, and with the
metadata mqb.aggregate.skipped: site.
Try a configuration without changing the states
read_only: true reads the states from the store, computes and writes the result into the
message, and stores nothing. Use it for a dry run against production states, or for a second
route that only reads what another one maintains. With emit: previous the message carries
the stored state as it is; with emit: updated what it would become.
- aggregate:
store: "postgres://localhost/telemetry/sensor_states"
read_only: true
key: "${payload:sensor_id}"
into: sensor
fields: { n: count, avg: "ema(reading, 0.01)" }
Keep the states across restarts
Without store the states are gone after a restart. Give the middleware a database and they
are kept there:
- aggregate:
store: "postgres://localhost/telemetry"
key: "${payload:sensor_id}"
into: sensor
fields: { n: count, avg: "ema(reading, 0.01)" }
PostgreSQL, SQLite (sqlite:///var/lib/mqb/agg.db?mode=rwc) and MongoDB
(mongodb://localhost/telemetry) work. The table or collection is created on start and is
named mqb_aggregate_<route> unless the URL names one.
By default (consistency: shared) several instances of the route can run against the same
store: every batch loads its states, updates them and writes them back, and a state that
another instance changed in between is computed again. That is safe and bound by the
database, about two round trips per batch, so use a large batch_size (4096 to 8192).
If exactly one instance runs the route, consistency: single_writer is several times faster:
the states stay in memory and are written to the store continuously. A batch is still only
acked once its states are stored, so no update is lost on a crash.
How it works internally
Folding a batch
The middleware works on whole batches. For each message, in order, and for each entry:
- The
keytemplate is rendered from the message. - The state of that key is looked up in a hash map; every entry has its own map.
- The new state is computed, by the expression or by the built-in aggregates.
- The state (or the previous one, or what
outputreturns) is written into the payload atinto. Fields the middleware does not write are copied byte for byte.
A message that fails in any step changes no state. Without a store this is everything:
the maps live in the process and the message goes on at once.
What a store holds
One row per key, in one table for all entries of the middleware:
| Column | Content |
|---|---|
agg_key | <into>:<key>, for example sensor:7; at most 512 characters |
state | the state as JSON text |
version | a counter, raised by one with every write |
MongoDB holds the same as a document {_id, state, v}. Every write names the version the
state was computed from and only takes effect while the row still has that version. This is
optimistic concurrency: nobody locks a state while computing, and a writer that was overtaken
notices it when it writes.
shared: the store owns the states
Per batch:
- The distinct keys of the batch are collected and loaded with one query (
WHERE agg_key IN (…), 1000 keys per statement), each with its version. - The batch is folded in memory, as above.
- Every changed state is written back with the version it was loaded with. PostgreSQL and SQLite use one transaction of upserts, so the states of a batch land completely or not at all. MongoDB uses one bulk command; each state is atomic, the batch is not.
- If a write is refused, another instance changed that key in between. The states are loaded again and the affected messages are folded again from them. MongoDB repeats only the refused keys; PostgreSQL and SQLite roll back and repeat the whole batch. After 32 attempts the batch fails and is redelivered.
- Only then do the messages go on. Nothing stays in memory for the next batch.
One instance runs its batches against the store one after the other, so it does not collide
with itself. The cost is two round trips per batch whatever its size, which is why large
batches pay off and why fields versus expression hardly matters here.
Between instances there is no order: of two batches that change the same key, the one that writes first wins and the other computes again on top of it. Counters and sums come out right either way; a moving average depends on which batch was first.
single_writer: memory owns the states
- A key is loaded from the store the first time a batch needs it. After that its state
stays in memory, until
max_keyspushes it out. - The batch is folded in memory, the changed keys are marked, and the batch gets a number.
- The messages go on at once; they do not wait for the store.
- A background task writes the marked states. As soon as one write returns it takes everything changed in the meantime, so under load one write covers many batches, and a key that changed a hundred times is written once. This is where the speed comes from.
- The ack of a batch (on an output: the return of
send) waits until a write has covered its number.
The writes carry versions here too. A refused write can only mean that somebody else writes the same states, and is treated like a failed write: the states in memory are dropped, every batch not yet stored is nacked, and the redelivered batches load their states again from the store. So a second instance is noticed, but not prevented; nothing fences it off.
What happens on a crash or a retry
Storing a state and acking a message are two steps, not one transaction. A message is never
acked before its state is stored, so no update is lost. The other way round is possible: the
state is stored, then the process dies or a later step fails, and the message is delivered
again. It then counts twice. A deduplication in front narrows that
window, but it has a store of its own and does not close it completely.
How this compares to stream processors
The building blocks are common ones: keyed state folded per message, as in Kafka Streams or
Flink; versioned conditional writes for shared; write-behind with one write for many
batches and an ack that waits for it for single_writer. Three things those systems do are
not done here, and they decide whether the middleware fits:
- No exactly-once. Kafka Streams and Flink commit the state and the input position together. Here a redelivered message counts twice, as described above.
- No key ownership. They assign each key to exactly one worker and move the state when
workers come and go. Here you choose:
sharedlets every instance write every key and pays a database round trip per batch,single_writeris fast and leaves it to you to run one instance. - No windows and no expiry by time. There is no “sum of the last hour” and no state
that ends with a window or a time to live. A moving average stands in for a window, by
message count or by time. States
leave memory only when
max_keysis reached, and rows in a store are kept for good. For fixed windows you can put the window into the key (key: "${payload:sensor_id}:${payload:hour}") and use astorewithshared, which keeps nothing in memory; the rows of past windows stay until you delete them.
If you need one of these, compute the aggregate in the database with a
lookup, or use a stream processor for that step.
What to know before relying on it
- Without
store, state lives in memory only. A restart starts from empty states and two instances do not share them. That is why it has to be asked for withconsistency: single_writer. - Memory is bounded. An entry keeps at most
max_keysstates in memory, one million unless you set it. See Memory for what happens beyond that. single_writerwith two instances is wrong. The second writer is noticed as a failed write and its batches are redelivered, but do not rely on that: run one instance.- A replayed message counts twice. Where that matters, let
deduplicationsee the message first: on an input, list it afteraggregate, because a message passes the last middleware of an input first. - The table is named after the route. Renaming a route, or a consumer that has no
id, starts with empty states. Name the table in the URL (postgres://host/db/sensor_states) to keep them. - Order matters for a moving average. Messages of a batch are folded in order. With a
route
concurrencyabove 1 the order across batches is not guaranteed. Withtime, a message older than its state is weighted by its lateness and markedmqb.aggregate.late, not reordered;ema(path, alpha)and expressions still depend on the order. - A message that cannot be folded fails alone and changes no state: its payload is not a
JSON object, its key has no value, a field is missing, or an expression fails. On an input
it is logged and dropped, and the source is told it succeeded; on an output only that
message fails, so a following
dlqcan keep it.on_error: failorskipchanges that, see Messages with missing fields. - Field order. The fields of a written state object are not in the order the expression lists them.
Performance and tuning
Measured on one Apple M1 core, one route worker, states in memory unless a store is named. Read the numbers as proportions; absolute values depend on the machine and payload.
| Setup | msg/s |
|---|---|
fields, one key, three aggregates | 1.4M to 1.8M |
fields, five keys, three moving averages each | 700k |
expression, one key | 330k to 370k |
expression, five keys | 100k |
PostgreSQL store, single_writer, five keys, fields | 240k |
PostgreSQL store, single_writer, five keys, expression | 68k |
PostgreSQL store, shared, five keys | 17k |
The update itself costs about 0.3 µs per message with fields and about 2 µs with an
expression. The same counter, sum and moving average built from Redpanda Connect’s branch,
cache and mapping processors cost about 8 µs per message on the same machine.
What to change, in the order it pays off:
- Use
fieldswhere the built-ins cover it. It is four to seven times faster than an expression, because no expression runs and the payload is not parsed into a tree. Keep expressions for the entries that need logic. - With a store, pick the consistency first.
single_writeris about 14 times faster thanshared, but only correct with one instance. Undersharedthe database sets the pace, andfieldsversusexpressionhardly matters. - With a store, raise
batch_sizeto 4096 or 8192. A batch costs about two round trips undersharedwhatever its size. Without a store the batch size has little effect. - Update fewer keys per message. The cost grows with the number of
entries, not with the number of aggregates in one entry: five keys cost about three times as much as one. - Do not raise
concurrencyfor speed if the result depends on order, as a moving average does. Counters and sums stay correct; undershared, workers that update the same keys collide and compute those states again. - Mind the neighbours. A
deduplicationkeyed on a payload field adds about 0.5 µs per message, onmessage_idabout 0.2 µs. Atransformbefore or after parses the payload again; letaggregatewrite the fields you need instead of reshaping afterwards.
Memory
An entry keeps at most max_keys states in memory, one million unless you set it. When it is
full, the half that was used least recently is dropped, so a key that keeps coming stays and
a key that went quiet goes. What “dropped” means depends on where the states live:
| Setup | A dropped state |
|---|---|
no store (single_writer) | is forgotten; its key starts again from an empty state |
store, single_writer | is only dropped from memory and loaded again when its key returns |
store, shared | does not occur; nothing is kept in memory between batches |
Without a store a warning is logged the first time states are forgotten. If the number of
keys is bounded, such as sensors or customers, and below the limit, nothing is ever dropped.
If it is unbounded, such as a session id, the limit is what keeps the process alive for
weeks: set max_keys to what you can afford, or use a store so that no state is lost.
- aggregate:
consistency: single_writer
key: "${payload:session_id}"
into: session
max_keys: 200000
fields: { n: count }
A fields state takes roughly 100 to 300 bytes, so the default limit stays within a few
hundred megabytes per entry; an expression state with a large object takes more. max_keys: 0 removes the limit. Rows in a store are never removed; delete them yourself if the key is
unbounded.
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.
SMS & email notifications
mq-bridge has no SMS or email endpoint of its own. It sends notifications through the service that delivers them, using one of three outputs:
| Way out | Output | Pick it when |
|---|---|---|
| A notification gateway (Apprise, ntfy) | http | You want one route for many channels, and to change provider without changing the route |
| The provider’s REST API | http or http_bulk | You use one provider (SendGrid, Postmark, SES, an API Gateway of your own) |
| An AWS SNS topic | aws | A fixed group of phones or addresses gets every message |
Whichever you pick, the message goes to the provider with the retries, dead-letter queue and back-pressure of an ordinary route.
http or http_bulk
http | http_bulk | |
|---|---|---|
| Requests | One per message | One per batch |
| Body | The payload, shaped by a transform | Payloads in an array, with envelope and document templates around them |
| Credentials | Fixed headers, basic_auth | Fixed headers, OAuth2 client credentials, AWS SigV4 |
| Result | The status of each request | The status, or per recipient with upsert.result.items |
| Fits | An API that sends one notification per call | An API that sends many in one call, or needs OAuth2 or SigV4 |
Both send to a fixed URL, so the recipient goes in the body, not in the path.
transform builds objects but not arrays: an API that wants
"to": [{"email": …}] from http needs the payload in that shape already, or an
http_bulk document template. Neither output sends form-encoded bodies, so an
API that takes only those (Twilio’s own) is reached through a gateway.
Through a notification gateway
Apprise API takes one JSON request and passes it to email (SMTP, SES, SendGrid, …), SMS (Twilio, Vonage, SNS, …) or chat. Which services and recipients a message reaches is configured in Apprise, so the route stays the same when the provider changes.
With the recipients stored in Apprise under the key alerts, each with a tag
such as ops or billing, the message picks its audience by tag:
alerts_notify:
input: { kafka: { topic: "alerts", url: "localhost:9092" } }
output:
middlewares:
- transform:
mapping:
tag: "$.team"
title: "$.subject"
body: "$.text"
- retry: { max_attempts: 5, initial_interval_ms: 500 }
- dlq:
endpoint: { file: { path: "undelivered-alerts.jsonl" } }
http:
url: "http://apprise:8000/notify/alerts"
custom_headers:
Content-Type: "application/json"
A message {"team": "ops", "subject": "Disk full", "text": "db-1 at 97%"}
becomes {"tag": "ops", "title": "Disk full", "body": "db-1 at 97%"}.
Set Content-Type yourself: the http output sends none of its own.
ntfy is the lighter choice for push notifications to phones:
the payload is the message text, and the topic is the path of url.
Through the provider’s API
One notification per request: http
An API Gateway of your own in front of SES, SNS or a Lambda, or a mail API
such as Postmark’s /email, takes one notification per request. Put the key in
a header and shape the body with a transform:
receipts_mail:
input: { kafka: { topic: "receipts", url: "localhost:9092" } }
output:
middlewares:
- transform:
mapping:
To: "$.email"
Subject: "$.subject"
TextBody: "$.text"
From: { path: "$.from", default: "shop@example.com" }
- retry: { max_attempts: 5, initial_interval_ms: 500 }
- dlq:
endpoint: { file: { path: "unsent-receipts.jsonl" } }
http:
url: "https://api.postmarkapp.com/email"
custom_headers:
Content-Type: "application/json"
Accept: "application/json"
X-Postmark-Server-Token: "${env:POSTMARK_TOKEN}"
From falls back to the default when the message has none. For an API
Gateway the same shape applies, with its x-api-key header in place of the
token. A gateway that checks IAM signatures cannot be called this way: http
does not sign requests; use http_bulk or put an API key or a Lambda authorizer
on the gateway.
Many notifications per request: http_bulk
SendGrid sends one template to up to 1,000 recipients per request, each with
data of its own. With http_bulk each message becomes one recipient, and the
message itself is the template data:
welcome_mails:
batch_size: 1000
input: { kafka: { topic: "signups", url: "localhost:9092" } }
output:
middlewares:
- retry: { max_attempts: 5, initial_interval_ms: 1000 }
- dlq:
endpoint: { file: { path: "unsent-welcome.jsonl" } }
http_bulk:
url: https://api.sendgrid.com
headers:
Authorization: "Bearer ${env:SENDGRID_API_KEY}"
upsert:
path: /v3/mail/send
format: json_array
envelope: '{"from": {"email": "hello@example.com"}, "template_id": "d-0123456789abcdef", "personalizations": {documents}}'
document: '{"to": [{"email": "${payload:email}"}], "dynamic_template_data": {document}}'
A batch of signups {"email": "ada@example.com", "name": "Ada"} is one request
whose personalizations hold one entry per signup. SendGrid answers the
request as a whole, so a refused request fails every message in it; the route’s
batch_size must stay within the API’s limit, since http_bulk has no limit of
its own on documents per request.
Amazon SES is called the same way, with requests signed by the AWS credentials
of the environment instead of a header. SendBulkEmail takes up to 50
recipients and reports each one, so result.items fails only the recipients
SES refused:
http_bulk:
url: https://email.eu-central-1.amazonaws.com
auth:
aws_sigv4: { region: eu-central-1, service: ses }
upsert:
path: /v2/email/outbound-bulk-emails
format: json_array
envelope: '{"FromEmailAddress": "hello@example.com", "DefaultContent": {"Template": {"TemplateName": "welcome", "TemplateData": "{}"}}, "BulkEmailEntries": {documents}}'
result:
items:
path: /BulkEmailEntryResults
error: /Error
success: { path: /Status, equals: SUCCESS }
SES may leave out Error for a refused recipient, so success fails every
entry whose Status is not SUCCESS.
Here every payload must already be one SES entry, such as
{"Destination": {"ToAddresses": ["ada@example.com"]}, "ReplacementEmailContent": {"ReplacementTemplate": {"ReplacementTemplateData": "{\"name\":\"Ada\"}"}}}:
SES wants the template data as a JSON string, which a document template
cannot produce from the payload. Set the route’s batch_size to 50 at most.
The request bodies of the Apprise and SendGrid routes were checked against a local HTTP server; the SES example for its configuration only. None of the services themselves was called.
Through an AWS SNS topic
An aws output with a topic_arn publishes every
message to an SNS topic. Phone numbers (SMS) and email addresses subscribed to
the topic in AWS each receive it:
alerts_sns:
input: { kafka: { topic: "alerts", url: "localhost:9092" } }
output:
aws:
topic_arn: "arn:aws:sns:eu-central-1:123456789012:ops-alerts"
region: "eu-central-1"
On the command line: --to 'aws://?topic_arn=arn:aws:sns:eu-central-1:123456789012:ops-alerts®ion=eu-central-1'.
- A subscriber without a filter policy gets every message; one with a filter
policy gets only the messages it matches. To reach different people, use one
topic per audience and a
switchto choose between them. - The payload is the text that is sent. Emails arrive as plain text with the subject “AWS Notification”, and each address must confirm its subscription before it receives anything.
- Message metadata is sent as SNS message attributes, which subscription filter policies can match.
This example was checked for its configuration only.
Replies and delivery reports
Notifications have answers too: delivery reports, bounces, SMS replies.
- Providers that call a webhook (SendGrid, Postmark, Twilio) reach an
httpinput, which writes each call on to any output. - SES reports bounces and complaints to an SNS topic. Subscribe an SQS queue to
it and read the queue with an
awsinput withqueue_url.
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).
Acknowledgement. By default (
ack: on_join) a buffered message is acknowledged only once the joined message is committed, so a crash before the group completes redelivers its members. The source must allow that many unacknowledged messages: with a prefetch limit (e.g. AMQPprefetch_count) a group can stall until its timeout.ack: on_receiveacknowledges on receipt instead and loses the buffered members on a crash.
Full field list in the middleware reference → weak_join.
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
username: "svc-orders"
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.
Custom endpoints and plugins
When the app stores secrets outside the config file (the balanced config security mode), it
also takes them out of a custom endpoint’s or middleware’s config, such as a
Connect component:
- a string field whose name contains
password,passphrase,secretorcredentials, or ends intoken,api_key,apikey,access_key,account_key,private_keyorconnection_string, at any depth; - a URL with
user:pass@in it, also inside a list such asurls, and adsncontaining@.
Left in the file, because an environment variable cannot carry them back unchanged:
- a field whose name is not lowercase
snake_case(apiKey,X-Api-Key), and everything below it; - a value that reads as a number or boolean (
"12345","true"); - a value that already is a reference (
"${DB_PASSWORD}"); - a Connect
yamldocument.
For those, reference the secret from the environment as above.
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.
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 fails decryption. By default (on_error: drop) such a message is logged,
acknowledged and skipped, so a reader with the wrong key drains its input; set on_error: fail
to stop the route instead.
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.
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.
Query a stream in DuckDB
There is no DuckDB connector, and for analytics you rarely need one: the
object_store sink writes Parquet, and DuckDB queries Parquet
files where they lie, whether in a local directory or a bucket. The same works in reverse: Parquet
that DuckDB exports can be read back into a route.
format: parquet needs the parquet feature, which the app’s default build includes.
Export a stream as Parquet
orders_to_lake:
batch_size: 10000
input:
kafka: { topic: "orders", url: "localhost:9092" }
output:
object_store:
url: "file:///var/lib/mqb/lake/orders" # or s3://bucket/orders, gs://…, az://…
format: parquet
compression: zstd # the Parquet column codec
The same export from the CLI:
mqb copy --drain \
'file:///data/orders.jsonl?format=raw' \
'local-store:///var/lib/mqb/lake/orders?format=parquet&compression=zstd'
- Each message payload must be a JSON object; its top-level fields become the columns. Message metadata is not written.
- The sink writes one Parquet file per flushed batch, so
batch_sizesets the file size. Thousands of tiny files slow every DuckDB scan, so keep batches large. - The schema is inferred per batch. A field whose values disagree in type within one batch is written as JSON text in that file rather than failing the batch.
Query it
SELECT country, sum(amount)
FROM read_parquet('/var/lib/mqb/lake/orders/*.parquet', union_by_name = true)
GROUP BY country;
Always pass union_by_name = true. Because each file has its own inferred schema, a field
that first appears in a later batch is missing from the earlier files. Without the option DuckDB
takes the columns from the first file and silently drops the new one; with it, the columns are
merged by name, missing values read as NULL, and a column that is BIGINT in one file and
VARCHAR in another becomes VARCHAR.
For a bucket, point the same glob at s3://… and give DuckDB credentials through its httpfs
extension.
Choose the file layout
Where the input carries a replay position (Kafka, Postgres CDC, an SQL cursor, a MongoDB change
stream, or a file in consume mode), the default name_by: auto names each file after the
source range it holds: part-orders-0000000003-…-….parquet. A restart or replay then skips the
ranges already written, so re-running the export never duplicates rows. These names are
written flat, without date folders. A route with a middleware that drops messages (filter,
deduplication, …) falls back to write-time names under auto, to avoid one file per gap; set
name_by: source_position to keep replay safety anyway. See
Files & object storage for the details.
To partition by date instead, ask for write-time names and the Hive layout:
object_store:
url: "file:///var/lib/mqb/lake/orders"
format: parquet
name_by: write_time
date_partition_style: hive # year=YYYY/month=MM/day=DD/<uuidv7>.parquet
SELECT year, month, day, count(*)
FROM read_parquet('/var/lib/mqb/lake/orders/**/*.parquet',
hive_partitioning = true, union_by_name = true)
GROUP BY ALL;
DuckDB reads year as a number but keeps the zero-padded month and day as text, so filter
with month = '09'. The dates are the write time (UTC), not a field of the record. The two
layouts exclude each other: write-time names do not recognise a replay.
Feed DuckDB results back into a route
Export a query result to Parquet in a directory of its own, then read it with an object_store
source. Each row arrives as one JSON-object message:
COPY (SELECT id, country, amount * 2 AS doubled FROM read_parquet('lake/*.parquet', union_by_name = true))
TO '/var/lib/mqb/outbox/2026-09-28.parquet' (FORMAT parquet);
duckdb_results_to_nats:
input:
object_store:
url: "file:///var/lib/mqb/outbox"
format: parquet
cursor_id: "duckdb-outbox"
checkpoint_store: "file:///var/lib/mqb/checkpoints/outbox.json"
output:
nats: { subject: "orders.scored", url: "nats://localhost:4222" }
The source reads files in name order and checkpoints the last one it finished, so give each export a name that sorts after the previous one, such as a date or timestamp.
Not covered
Writing into a .duckdb database file (tables, upserts, transactions) needs a native DuckDB
endpoint, which does not exist. Parquet covers the common case of making a stream queryable.
Load into Snowflake
There is no Snowflake connector. The object_store sink writes
Parquet to S3, GCS or Azure, and Snowflake loads it from an external stage with COPY INTO or
Snowpipe.
format: parquet needs the parquet feature, which the app’s default build includes.
Write Parquet to the stage location
orders_to_snowflake_stage:
batch_size: 10000
input:
kafka: { topic: "orders", url: "localhost:9092", group_id: "snowflake-export" }
output:
middlewares:
- transform:
schema:
type: object
required: [id]
properties:
id: { type: integer }
country: { type: string }
amount: { type: number }
note: { type: string, default: "" }
object_store:
url: "s3://my-bucket/orders"
format: parquet
compression: zstd
- Each payload must be a JSON object; its top-level fields become the columns.
- The sink writes one Parquet file per flushed batch, so
batch_sizesets the file size. - The
transformschema coerces types ("42"becomes42) and rejects rows that do not fit, so a column does not flip between string and number across files. Anumbercolumn is still written as a 64-bit integer when a batch holds only whole numbers, so declare it as a float column in the warehouse. - A rejected row is dropped and logged. Add a
dlqafter thetransformto keep it. nulltakes the field’sdefaultif it has one and is rejected otherwise. Mark a column the source can leave empty asnullable: trueto keep thenull.- S3 credentials are read from the environment (
AWS_ACCESS_KEY_ID, …).
Schema drift between files
The Parquet schema is inferred per batch. An optional field that no row of a batch carries is
absent from that file, not written as a null column. Give such a field a default in the
schema, as note has above, or match columns by name on the Snowflake side. Never load by column
position.
Load it
Verify this SQL against the current Snowflake documentation.
CREATE STAGE orders_stage
URL = 's3://my-bucket/orders/'
STORAGE_INTEGRATION = my_s3_integration
FILE_FORMAT = (TYPE = PARQUET);
COPY INTO orders
FROM @orders_stage
FILE_FORMAT = (TYPE = PARQUET)
MATCH_BY_COLUMN_NAME = CASE_INSENSITIVE;
MATCH_BY_COLUMN_NAME loads each file by column name, so a file that lacks a column loads NULL
for it.
For continuous loading, wrap the same statement in a pipe and point the bucket’s event notifications at it:
CREATE PIPE orders_pipe AUTO_INGEST = TRUE AS
COPY INTO orders
FROM @orders_stage
FILE_FORMAT = (TYPE = PARQUET)
MATCH_BY_COLUMN_NAME = CASE_INSENSITIVE;
Replays
With a replayable input such as Kafka, the default name_by: auto names each file after the
source range it holds, and a restart skips ranges already written. See
Choose the file layout for the naming options and date
partitions.
Load into BigQuery
There is no native BigQuery connector. The object_store sink writes
Parquet to Google Cloud Storage, and BigQuery either loads the files or queries them in place
through an external table.
format: parquet needs the parquet feature, which the app’s default build includes.
To insert rows into a table directly instead, the Connect plugin has
a gcp_bigquery output.
Write Parquet to GCS
orders_to_gcs:
batch_size: 10000
input:
postgres_cdc:
url: "postgres://user:pass@localhost:5432/app"
publication: "mqb_pub"
slot_name: "mqb_bigquery"
output:
middlewares:
- transform:
schema:
type: object
required: [id]
properties:
id: { type: integer }
country: { type: string, default: "" }
amount: { type: number, default: 0 }
note: { type: string, default: "" }
object_store:
url: "gs://my-bucket/orders"
format: parquet
compression: zstd
- Each payload must be a JSON object; its top-level fields become the columns.
- The sink writes one Parquet file per flushed batch, so
batch_sizesets the file size. - The
transformschema coerces types ("42"becomes42) and rejects rows that do not fit, so a column does not flip between string and number across files. Anumbercolumn is still written as a 64-bit integer when a batch holds only whole numbers, so declare it as a float column in the warehouse. - A rejected row is dropped and logged. Add a
dlqafter thetransformto keep it. nulltakes the field’sdefaultif it has one and is rejected otherwise. Mark a column the source can leave empty asnullable: trueto keep thenull.postgres_cdcemits one message per change, so the files are a change log, not the table’s current state. A delete carries the key with every other columnnull(default replica identity); it is rejected by the schema above unless those columns arenullable. The operation is in thepostgres.operationmetadata, not in the payload.- GCS credentials are read from the environment (
GOOGLE_SERVICE_ACCOUNT, …).
Schema drift between files
The Parquet schema is inferred per batch. An optional field that no row of a batch carries is
absent from that file. Give such a field a default in the schema, as the optional columns have above, or
create the BigQuery table with every column up front so a file that lacks one loads NULL.
BigQuery matches Parquet columns by name.
Load it
Verify this SQL against the current BigQuery documentation.
A load job copies the data into a native table:
LOAD DATA INTO mydataset.orders
FROM FILES (
format = 'PARQUET',
uris = ['gs://my-bucket/orders/*.parquet']
);
A wildcard load requires every matched file to have the same schema. A default on every optional
column keeps the column set the same, but not the types: a number column is INT64 in a file
whose batch held only whole numbers and FLOAT64 otherwise, and the destination table’s schema
does not reconcile the files. Load files with differing schemas in separate jobs.
A load job appends whatever the URI matches. Running it twice over the same files loads them twice, so load each file once (for example by date prefix) or use an external table instead.
An external table leaves the data in GCS and reads the current set of files on every query:
CREATE EXTERNAL TABLE mydataset.orders_ext
OPTIONS (
format = 'PARQUET',
uris = ['gs://my-bucket/orders/*.parquet']
);
Replays and layout
With a replayable input such as Postgres CDC, the default name_by: auto names each file after
the source range it holds, and a restart skips ranges already written. For date folders that a
load job can address by prefix, see Choose the file layout.
Load into Databricks / Spark
There is no Databricks or Spark connector. The object_store sink
writes Parquet to S3, GCS or Azure, and Spark reads Parquet from all three.
format: parquet needs the parquet feature, which the app’s default build includes.
Write Parquet to the lake
orders_to_lake:
batch_size: 10000
input:
kafka: { topic: "orders", url: "localhost:9092", group_id: "lake-export" }
output:
middlewares:
- transform:
schema:
type: object
required: [id]
properties:
id: { type: integer }
country: { type: string }
amount: { type: number }
note: { type: string, default: "" }
object_store:
url: "s3://my-bucket/orders" # or gs://…, az://container/…
format: parquet
compression: zstd
- Each payload must be a JSON object; its top-level fields become the columns.
- The sink writes one Parquet file per flushed batch, so
batch_sizesets the file size. Keep batches large: many small files slow every scan. - The
transformschema coerces types ("42"becomes42) and rejects rows that do not fit, so a column does not flip between string and number across files. Anumbercolumn is still written as a 64-bit integer when a batch holds only whole numbers, so declare it as a float column in the warehouse. - A rejected row is dropped and logged. Add a
dlqafter thetransformto keep it. nulltakes the field’sdefaultif it has one and is rejected otherwise. Mark a column the source can leave empty asnullable: trueto keep thenull.
Schema drift between files
The Parquet schema is inferred per batch. An optional field that no row of a batch carries is
absent from that file. Give such a field a default in the schema, as note has above, or
ask Spark to merge the file schemas by name with mergeSchema. Without it Spark takes the schema
from one file and drops columns that file lacks.
Load it
Verify this code against the current Databricks and Spark documentation.
Read the files directly:
df = spark.read.option("mergeSchema", "true").parquet("s3://my-bucket/orders/")
Or load them into a Delta table. COPY INTO skips files it has already loaded:
COPY INTO orders
FROM 's3://my-bucket/orders/'
FILEFORMAT = PARQUET
FORMAT_OPTIONS ('mergeSchema' = 'true')
COPY_OPTIONS ('mergeSchema' = 'true');
For continuous ingestion, Databricks Auto Loader (cloudFiles with cloudFiles.format = parquet)
picks up new files as they arrive.
Replays and layout
With a replayable input such as Kafka, the default name_by: auto names each file after the
source range it holds, and a restart skips ranges already written. For Hive-style date partitions
(year=…/month=…/day=…), which Spark discovers as columns, see
Choose the file layout.
Query from Athena / Trino
There is no Athena or Trino connector, and none is needed: both query Parquet files where they
lie. The object_store sink writes the files, and an external
table makes them queryable.
format: parquet needs the parquet feature, which the app’s default build includes.
Write Parquet to S3
orders_to_s3:
batch_size: 10000
input:
kafka: { topic: "orders", url: "localhost:9092", group_id: "athena-export" }
output:
middlewares:
- transform:
schema:
type: object
required: [id]
properties:
id: { type: integer }
country: { type: string }
amount: { type: number }
note: { type: string, default: "" }
object_store:
url: "s3://my-bucket/orders"
format: parquet
compression: zstd
name_by: write_time
date_partition_style: hive # year=YYYY/month=MM/day=DD/<uuidv7>.parquet
- Each payload must be a JSON object; its top-level fields become the columns.
- The sink writes one Parquet file per flushed batch, so
batch_sizesets the file size. Athena bills by data scanned and slows down on many small files, so keep batches large. - The
transformschema coerces types ("42"becomes42) and rejects rows that do not fit, so a column does not flip between string and number across files. Anumbercolumn is still written as a 64-bit integer when a batch holds only whole numbers, so declare it as a float column in the warehouse. - A rejected row is dropped and logged. Add a
dlqafter thetransformto keep it. nulltakes the field’sdefaultif it has one and is rejected otherwise. Mark a column the source can leave empty asnullable: trueto keep thenull.- The date folders are the write time (UTC), not a field of the record. Write-time names do not recognise a replay; drop the last two lines to get replay-safe names in a flat layout instead. See Choose the file layout.
Schema drift between files
The Parquet schema is inferred per batch. An optional field that no row of a batch carries is
absent from that file. Declare every column in the table definition and let the engine
resolve Parquet columns by name: a file that lacks a column then reads NULL for it. Giving
the field a default in the schema, as note has above, keeps the column in every file.
Query it
Verify this SQL against the current Athena and Trino documentation.
CREATE EXTERNAL TABLE orders (
id bigint,
country string,
amount double,
note string
)
PARTITIONED BY (year string, month string, day string)
STORED AS PARQUET
LOCATION 's3://my-bucket/orders/';
MSCK REPAIR TABLE orders; -- registers new year=/month=/day= folders
SELECT country, sum(amount)
FROM orders
WHERE year = '2026' AND month = '09'
GROUP BY country;
MSCK REPAIR TABLE has to run again when a new day’s folder appears; partition projection
avoids that.
In Trino, the same files are a Hive-connector table:
CREATE TABLE hive.lake.orders (
id bigint, country varchar, amount double, note varchar,
year varchar, month varchar, day varchar
)
WITH (
external_location = 's3://my-bucket/orders/',
format = 'PARQUET',
partitioned_by = ARRAY['year', 'month', 'day']
);
Keep the Hive connector reading Parquet columns by name (hive.parquet.use-column-names).
ClickHouse: endpoint or s3()
ClickHouse is the one analytics database here with a native endpoint. You can insert rows
directly, or write Parquet to object storage and let ClickHouse read it with the s3() table
function.
clickhouse endpoint | Parquet + s3() | |
|---|---|---|
| Latency | Rows are queryable after each batch insert | Rows appear when you run the load |
| Schema | The target table defines it; a bad row fails the insert | Inferred per file |
| Keeps a copy outside ClickHouse | No | Yes, the Parquet files |
| Best fit | Streaming into one ClickHouse table | A lake that other engines read too |
Insert directly
orders_to_clickhouse:
batch_size: 10000
input:
kafka: { topic: "orders", url: "localhost:9092", group_id: "clickhouse-export" }
output:
middlewares:
- transform:
schema:
type: object
required: [id]
properties:
id: { type: integer }
country: { type: string }
amount: { type: number }
clickhouse: { url: "http://localhost:8123", database: "analytics", table: "orders" }
The transform schema rejects rows that would not fit the table before they
reach ClickHouse. Put a dlq after it to keep them.
Write Parquet, load with s3()
orders_to_lake:
batch_size: 10000
input:
kafka: { topic: "orders", url: "localhost:9092", group_id: "lake-export" }
output:
middlewares:
- transform:
schema:
type: object
required: [id]
properties:
id: { type: integer }
country: { type: string }
amount: { type: number }
note: { type: string, default: "" }
object_store:
url: "s3://my-bucket/orders"
format: parquet
compression: zstd
format: parquet needs the parquet feature, which the app’s default build includes. The sink
writes one Parquet file per flushed batch.
The Parquet schema is inferred per batch. An optional field that no row of a batch carries is
absent from that file. Give such a field a default in the schema, as note has above, or
let ClickHouse fill missing columns and merge the file schemas by name.
Verify this SQL against the current ClickHouse documentation.
INSERT INTO analytics.orders
SELECT id, country, amount, note
FROM s3('https://my-bucket.s3.amazonaws.com/orders/*.parquet', 'Parquet')
SETTINGS
input_format_parquet_allow_missing_columns = 1, -- a column absent from a file reads as default
schema_inference_mode = 'union'; -- merge the schemas of all files by name
Running the INSERT again loads the same files again. Select by path (_path) or load each
prefix once.
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 library configs and the
CLI alike. Each connector has its own connector page with YAML and
URL examples, plus a generated parameter table (name, type, default, description).
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).
Plugin endpoints
These live in their own repositories and load as native plugins, so the core library carries none of their dependencies:
| Plugin | What it does | In mqb |
|---|---|---|
| Pulsar | Apache Pulsar input and output | Built in |
| Meilisearch | Document sink into a search index; index scan as input | Built in |
| Connect | Redpanda Connect’s 56 inputs, 68 outputs (connect+mqtt://…) and 86 processors; per-message processors also run as middleware | Separate install (size) |
The Connect plugin has its own page, with every linked input, output and processor.
Outside mqb (Python, Node.js, your own Rust host), or for Connect, install the plugin with
brew install marcomq/tap/<repo> or conda install -c marcomq <repo>; it is then discovered
automatically.
Consumer vs. subscriber, and nack support
One table per question (which inputs acknowledge, which commit in order, which outputs reply, where TLS is configured) is in CAPABILITIES.md. It is rendered from the table the engine exposes in code and checked by tests.
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. - MySQL, MariaDB, SQL Server, Oracle, Db2 — no native endpoint. Run Debezium Server beside mq-bridge and read its output; see CDC via Debezium.
- 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 |
delete (also consumer) | claim → lock → re-fetch → delete | yes | yes | no | work queues, competing readers |
These are not interchangeable. delete 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: delete → 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.
Object storage (local / cloud)
The object_store endpoint reads and writes local directories, Amazon S3, Google Cloud Storage,
Azure Blob, Cloudflare R2, and anything else the object_store crate speaks. Enable it with the
object-store feature. Use local-store:///absolute/path in CLI URLs (it becomes
file:///absolute/path internally), or use url: file:///absolute/path in structured YAML.
Credentials and cloud backend options come from the environment
(AWS_ACCESS_KEY_ID, AWS_REGION, GOOGLE_SERVICE_ACCOUNT, AZURE_STORAGE_ACCOUNT_NAME, …); the
URL scheme picks the backend (file://, s3://, gs://, az://). The variables and URL form
of each cloud are on the object storage connector page.
- 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.
A source on normal/json/text expects that same wrapper. A line that is valid JSON but not
the wrapper becomes the whole payload (its own metadata is discarded, with a warning); use
format: raw for plain JSON lines.
In the wrapper, a UTF-8 payload is a plain JSON string under payload; a binary one
(compressed, encrypted, Protobuf, …) is base64-encoded under payload_base64. The two are
mutually exclusive, as in the
CloudEvents JSON format:
{"message_id":"019f9b12-d786-7ebe-a7ec-a1aa71bc47ae","payload":"{\"order_id\":7}"}
{"message_id":"019f9b12-d78a-7c01-b0f4-2f0f4d6a1c33","payload_base64":"KLUv/SBOAQAA"}
Sources still read the older byte-array form ("payload":[123,34,…]). A binary record
written by this version is not readable by an older mq-bridge; text records are compatible in
both directions.
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).
Connectors
One page per connector: purpose, practical examples, and the same settings in both forms —
as a mqb copy URL and as a YAML endpoint for config files and the library. The
nested Parameters page under each connector lists every field (type, default, description),
generated from the same schema the engine uses.
From URL to config
A URL’s query parameters are config fields of the same name. The scheme picks the config
key, and the part before the query string becomes url (or path):
| URL scheme | Config key | url / path in config |
|---|---|---|
kafka://host:9092 | kafka | url: "host:9092" (bare host list) |
nats://host:4222 | nats | url: "nats://host:4222" |
amqp://, rabbitmq:// | amqp | url: "amqp://host:5672/%2f" |
mqtt://, mqtts:// | mqtt | url: "mqtt://host:1883" |
redis://, rediss://, redis+unix://, valkey(s)://, valkey+unix:// | redis_streams | url: "redis://host:6379" |
postgres://, mysql://, sqlite:// | sqlx | the connection string as url |
postgres-cdc://, pgcdc:// | postgres_cdc | url: "postgres://…" |
mongodb://, mongodb+srv:// | mongodb | url: "mongodb://host:27017" |
clickhouse://, clickhouses:// | clickhouse | url: "http://host:8123" / https:// |
http-bulk:?config_file=<path> | http_bulk | nested config from a file, or inline with config=<YAML or JSON> |
typesense://host:8108/<collection>, elasticsearch://host:9200/<index>, qdrant://host:6333/<collection> | the endpoint of that name | +https in the scheme for HTTPS; see Typesense, Elasticsearch, Qdrant |
openai://host?model=…&prompt=…, openai-embeddings://host?model=…&input=… | openai_chat / openai_embeddings | +https in the scheme for HTTPS; see OpenAI-compatible APIs |
http://, https:// | http | unchanged |
ws://, wss:// | websocket | unchanged |
grpc://, grpcs:// | grpc | url: "http://host:50051" / https:// |
zeromq://, zmq:// | zeromq | url: "tcp://host:5555" |
ibmmq://host:1414 | ibmmq | url: "host(1414)" |
aws:// | aws | no url; queue_url / topic_arn |
file:///path | file | path: "/path" |
dir-spool:///path | dir_spool | path: "/path" |
s3://, gs://, az://, local-store:// | object_store | url: "s3://bucket/prefix", "gs://bucket/prefix", "az://container/prefix" (local-store:// → file://); credentials come from the environment |
So kafka://localhost:9092?topic=orders&group_id=sync becomes:
kafka: { url: "localhost:9092", topic: "orders", group_id: "sync" }
Transport behaviour shared by all connectors — consumer vs. subscriber, nack support, CDC vs. polling, file formats, plugins — is on Overview & capabilities.
All connectors
- 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)
- Directory spool
- Object storage: S3, MinIO / R2, Google Cloud Storage, Azure Blob, local directory
- Apache Pulsar
- Meilisearch
- HTTP bulk (search engines)
- Typesense
- Elasticsearch
- PostgREST and Supabase
- Qdrant
- OpenAI-compatible APIs (LLMs)
- Connect plugin (Redpanda Connect components)
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.
Config (YAML / library)
The same settings as a route endpoint in a config file, or in Route.from_config /
fromConfig. Every URL query parameter is a field of the same name under sqlx:.
input:
sqlx: { url: "postgres://user:pass@localhost/app", table: "orders", cursor_column: "id" }
The config key is sqlx for PostgreSQL, MySQL/MariaDB, and SQLite; the connection string is the url.
Examples
Full-table read (source), one-shot:
mqb copy --drain \
--from 'postgres://user:pass@localhost/app?table=orders&cursor_column=id' \
--to null:
cursor_column names a unique, increasing column to read in order. Without it the source
treats the table as an mq-bridge work queue, which needs id and locked_until columns.
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.
Table to table, or file to table, by column name:
mqb copy --drain \
--from 'postgres://user:pass@src/app?table=orders&cursor_column=id' \
--to 'postgres://user:pass@dst/app?table=orders&columns=auto&key=id'
mqb copy --drain \
--from 'file://orders.csv?format=csv' \
--to 'sqlite://app.db?table=orders&columns=auto&key=id'
columns=auto writes each top-level field of a record into the column of the same name; the
table must exist. key=id makes a rerun update the rows instead of failing on the duplicate
key. See Writing by column name.
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. |
timestamps | (Consumer, cursor_column mode) text (default) renders timestamptz as Postgres prints it, 2026-10-04 09:15:23.923277+00. rfc3339 renders it in UTC as 2026-10-04T09:15:23.923277Z, which other systems parse; a timestamp without zone gets the T but no offset. |
auto_create_table | Publisher creates the destination table if missing. |
columns | (Publisher) auto writes each JSON field into the table column of the same name. The table must exist. |
key | (Publisher, with columns) Key column(s), comma-separated. A row with the same key is updated; needs a UNIQUE or PRIMARY KEY on them. |
extra_column | (Publisher, with columns) Column that takes the fields without a column of their own, as one JSON object (jsonb, json or text). |
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.
Reading a table
With cursor_column the source pages through the table and leaves it unchanged:
SELECT … FROM orders WHERE id > <last value> ORDER BY id LIMIT <batch size>
Each row becomes one message whose payload is a JSON object with one field per column:
{"id": 42, "sku": "A-1", "qty": 3, "price": "19.90", "created_at": "2026-10-04 09:15:23.923277+00"}
The cursor column must be unique and only grow, because the next page starts after the last value read:
- A row whose value equals the last one read is skipped, so a column with repeated values
(a plain
updated_at) loses rows. - A row that appears later with a smaller value is never read. This happens with a sequence
when a long transaction commits after a shorter one that took a higher number. On
PostgreSQL the reader stops each page before the first row written by a transaction that
is not older than every open one, so a sequence column is safe; while a write transaction
stays open anywhere on the server, newer rows wait for it. A column filled from
now()(the transaction’s start time) or by the application can still go wrong, and so can a view, which has noxmin(the reader logs a warning). On MySQL, MariaDB and SQLite there is no such guard. Where writers run concurrently, prefer PostgreSQL CDC. - Updates and deletes of rows already read are not seen. Use PostgreSQL CDC for those.
- The column can be an integer or a text type. On PostgreSQL it can also be a
timestamp,timestamptz,date,numericoruuid.
Column types. Integers, floats, booleans and text keep their JSON type. The other types arrive as text:
| Database | As a string | As JSON |
|---|---|---|
| PostgreSQL | numeric, uuid, dates and timestamps, interval, inet, enums | json / jsonb and arrays, embedded as they are |
| MySQL / MariaDB | decimal, dates and times, json, bit, and tinyint (so a BOOLEAN is "0" / "1") | |
| SQLite | columns declared BOOLEAN, DATE, TIME, DATETIME, TIMESTAMP |
Binary columns (bytea, blob) are written as a hex string. To get numbers or timestamps in
another form, add a transform, or set timestamps: rfc3339 on
PostgreSQL.
Progress. cursor_id stores the last value, by default in a table mqb_cursors_<table>
of the source database. For a read-only user, point checkpoint_store somewhere else; see
Checkpoints & resumable copies. Without cursor_id the position
is kept for the current run only.
Polling. With --drain (exit_on_empty) the run ends at the last row. Otherwise the
source asks for new rows every polling_interval_ms (default 100); set
max_polling_interval_ms to let the interval grow while the table is idle.
Queue tables
Without cursor_column the table is used as a queue between mq-bridge routes: one route writes
messages into it, another takes them out. This is not a way to read an existing table.
A sink without columns and insert_query writes the whole payload into a payload column.
auto_create_table: true creates the table; on PostgreSQL it is:
CREATE TABLE jobs (
id BIGSERIAL PRIMARY KEY,
payload BYTEA NOT NULL,
locked_until TIMESTAMPTZ,
created_at TIMESTAMPTZ DEFAULT NOW()
);
enqueue:
input: { http: { url: "0.0.0.0:8080" } }
output: { sqlx: { url: "postgres://localhost/app", table: "jobs", auto_create_table: true } }
work:
input: { sqlx: { url: "postgres://localhost/app", table: "jobs", delete_after_read: true } }
output: { kafka: { url: "localhost:9092", topic: "jobs" } }
- The source claims a batch of rows for 60 seconds (
locked_until) and skips rows another consumer has claimed, so several consumers can share one table. - With
delete_after_read: truean acknowledged row is deleted. A row that failed stays and is delivered again when its claim ends. - Set
delete_after_read: truefor a queue. Without it a row is only hidden for the 60 seconds of its claim: nothing marks it as done, so it is delivered again and again, once a minute, for as long as the route runs. The default is off so that a route deletes nothing unless told to; it is only useful if something else removes the processed rows. - To copy a table instead of emptying it, use
cursor_column(Reading a table). Themqb copycommand warns when a source has neither setting; a route in a config file starts without a warning. - Only the payload is stored. Message metadata does not pass through the table.
Writing
| Goal | Setting |
|---|---|
| Fields into the columns of the same name, with upsert | columns: auto, key; see Writing by column name |
Your own statement, renamed fields, casts, ON CONFLICT … DO NOTHING | insert_query with ${payload:field} tokens; see Upserts |
| Fastest load into PostgreSQL | bulk_copy: true, with columns: auto or a token insert_query |
| The whole payload in one column | neither; see Queue tables |
A row the database rejects for a reason that will not change (a type error, a violated
constraint) fails as non-retryable and goes to a dlq if one is
configured. Connection errors and deadlocks are reported as retryable, for a
retry middleware to repeat.
Connection, TLS and pool
- Credentials can be in the URL or in
username/password, which take precedence. Keep them out of the config file with secrets. - TLS. Either put the driver’s option into the URL (
?sslmode=verify-fullon PostgreSQL,?ssl-mode=VERIFY_IDENTITYon MySQL/MariaDB), or settls.required: true, which selects exactly these modes.tls.ca_filenames a private CA,tls.cert_fileandtls.key_filea client certificate; the key file must be unencrypted. The drivers’ default mode falls back to plain text when the server offers no TLS. - Pool. Endpoints with the same URL share one pool of at most
max_connections(10) connections. Raise it when a route’sconcurrencyis higher, and setshared: falseto give an endpoint a pool of its own.
Database notes
- PostgreSQL. A table in another schema is
table: "sales.orders".bulk_copyand the type casts ofcolumns: autoexist only here. - MySQL / MariaDB. Both schemes use the same driver. A
keyneeds aUNIQUEorPRIMARY KEYindex and becomesON DUPLICATE KEY UPDATE. - SQLite.
sqlite:///var/data/app.dbis an absolute path,sqlite://app.dba relative one. A sink creates the file when it does not exist.
Recipes
- Upserts & insert-if-absent:
ON CONFLICTandON DUPLICATE KEY. - Enrichment & lookups:
lookup_queryreads a row for each message. - Checkpoints & resumable copies: where the cursor is stored and how to reset it.
- Deduplication and Running aggregates can keep their state in a SQL table.
- Postgres CDC → JSONL, and MySQL, SQL Server & Oracle CDC via Debezium.
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>
In config, the key is postgres_cdc:
input:
postgres_cdc: { url: "postgres://user:pass@localhost:5432/app", publication: "orders_pub", slot_name: "mqb_orders" }
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.
The server must be PostgreSQL 14 or newer and run with wal_level = logical.
TLS follows the URL (?sslmode=verify-full&sslrootcert=/ca.pem, with sslcert and
sslkey for a client certificate) or the tls block; both connections the source
opens use the same settings. sslmode=prefer, the default, tries TLS
first and falls back to plain text, so it does not guarantee TLS.
A nacked change makes the source reconnect and deliver again from the last confirmed position, so the changes after it come twice. Rows of one transaction are confirmed together, when its last row is acked.
What a change event contains
The payload is the row as a JSON object; the metadata names the change: postgres.operation
(insert, update, delete, truncate), postgres.schema, postgres.table,
postgres.lsn and postgres.key (the primary key values as a JSON array).
- An
updatethat changes the primary key also carriespostgres.old_key. A sink that upserts by key has to delete that row itself, or the old row stays. Withsplit_key_change: truethe source does it: it sends adeletefor the old key first, then theupdate. That is two events for one change, and the events after it in the same transaction get other message ids than without the option, so do not switch it on a route whose deduplication state must stay valid across the change. - A
deletecarries the key columns only, the other columns arenull. SetREPLICA IDENTITY FULLon the table to get the whole old row. - A large value that an update did not change (an unchanged TOAST column) is not in the
log; its field is left out of the payload, which is different from
null. numericis a string, to keep every digit.bigintis a JSON number: a reader that holds numbers as doubles loses precision above 2^53. Numbers insidejson/jsonbare read as doubles too.byteais base64. Dates, times,uuidandintervalare the text Postgres prints.NaNandInfinityare strings. One-dimensional arrays of the common types are JSON arrays; other arrays, ranges, enums and composite types are their Postgres text.
Full field list: reference/postgres-cdc.md.
Writing by column name
With columns: auto the sink reads the table’s columns at start and builds the INSERT from each
record. It works on PostgreSQL, MySQL/MariaDB and SQLite.
- Matching. A field goes into the column with the same name; if there is none, a name that
differs only in case matches (
ID→id). When a record has both spellings, the exact one is written and the other counts as a field without a column. Fields without a column are not written, and the first one is logged as a warning. extra_column. Names a column that takes those fields instead, as one JSON object: withextra_column=extra,{"id":1,"color":"red"}writesid = 1andextra = {"color":"red"}. A record with no such field leaves the column out. When a record fills the column itself with an object (or with JSON text holding one, as a SQL source delivers it), the collected fields are added to it and its own keys win. An upsert replaces the stored object; it does not merge into it.- Missing fields. A column a record does not name is left out of the statement: on insert it
gets its default, on update it keeps its value. An explicit
nullwritesNULL. - Types. On PostgreSQL every value is cast to the column’s type, so a
numeric,timestamptz,uuid,jsonbor enum column accepts the text a SQL source or a CSV file delivers, a JSON array goes into an array column, and0/1go into aboolean. A nested object or array is written as JSON text. A value the column cannot take fails the batch with the database’s message. key. GeneratesON CONFLICT (key) DO UPDATE(PostgreSQL, SQLite) orON DUPLICATE KEY UPDATE(MySQL/MariaDB). When one batch has several records with the same key, the last one wins.bulk_copy(PostgreSQL) works withcolumns: auto, but not together withkey. A batch whose records name different columns is written in one transaction.- A record that cannot be mapped — not a JSON object, or no field is a column — is rejected alone; the rest of the batch is written.
columns cannot be combined with insert_query or auto_create_table.
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. |
columns | auto | no | — | (Publisher only) auto: write each JSON field into the table column of the same name. The table must exist. |
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. |
extra_column | string | no | — | (Publisher only, with columns) Column that takes the fields without a column of their own, as one JSON object. |
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). |
key | string | no | — | (Publisher only, with columns) Key column(s), comma-separated: a row with the same key is updated, not inserted again. |
lookup_query | string | no | — | (Publisher only) SELECT for lookup, answering with the first row. Write WHERE id IN (${payload:id}) to answer a whole batch in one query, matching rows by id. |
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. |
timestamps | text | rfc3339 | no | — | (Consumer only, cursor_column mode, PostgreSQL) How timestamp columns are rendered: text (default) or rfc3339. |
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 | — | Local file (path or file://) for resume state; unset, the slot alone tracks the stream. |
consume | capture_new | capture_all | snapshot | no | — | (Consumer only) What to read: capture_new (default, changes only), capture_all (backfill the tables first, then changes) or snapshot (backfill only). |
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 the confirmed LSN and backfill progress; defaults to slot_name. |
id_hash | fnv1a | sha256 | no | fnv1a | Hash for change message_ids: fnv1a (default, keeps existing dedup state) or sha256. |
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. |
split_key_change | boolean | no | — | An update that changes the primary key becomes a delete of the old key, then the update. |
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. |
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.
Config (YAML / library)
The same settings as a route endpoint in a config file, or in Route.from_config /
fromConfig. Every URL query parameter is a field of the same name under clickhouse:.
output:
clickhouse: { url: "http://localhost:8123", database: "analytics", table: "orders" }
The url is the HTTP interface (http:// or https://); clickhouse:// is CLI-only.
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. |
request_timeout_ms | Limit for one request. Unset means none, which suits very large inserts; set it when a stalled server must not stall the route. |
tls | ca_file, cert_file + key_file (client certificate, unencrypted key), accept_invalid_certs. required: true refuses an http:// URL. |
Rows ClickHouse refuses. An insert is one statement, so ClickHouse answers for the
batch, not per row. When it refuses the data (HTTP 400, for example a string in a number
column), the batch is halved and sent again until the refused rows stand alone. Those fail
as non-retryable: add a dlq to keep them, otherwise they are logged and dropped. The
other rows are inserted. With async_insert and wait_for_async_insert: false the server
answers before it looked at the rows, so a bad row is never reported.
Full field list: reference/clickhouse.md.
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. |
lookup_query | string | no | — | (Publisher only) SELECT for lookup, answering with the first row. Write WHERE id IN (${payload:id}) to answer a whole batch in one query, matching rows by id. |
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. |
Kafka
Reads from or writes to a Kafka topic, through librdkafka. It works with anything that
speaks the Kafka protocol: Apache Kafka, Redpanda, Confluent Platform.
URL format
kafka://broker[:port]?topic=<topic>[&group_id=<group>]
| What you want | URL |
|---|---|
| Write to a topic | kafka://localhost:9092?topic=orders |
| Read with a consumer group (resumes after a restart) | kafka://localhost:9092?topic=orders&group_id=orders-sync |
| Read only what arrives from now on | kafka://localhost:9092?topic=orders |
| Several bootstrap brokers | kafka://_/?url=broker1:9092,broker2:9092&topic=orders |
| Write to a topic that must already exist | kafka://localhost:9092?topic=orders&create_topic=false |
| Key records by a metadata field | kafka://localhost:9092?topic=orders&partition_key=customer_id |
| Pass a librdkafka option | kafka://localhost:9092?topic=orders&producer_options=[["compression.type","gzip"]] |
| Read a group from the newest record | kafka://localhost:9092?topic=orders&group_id=g&consumer_options=[["auto.offset.reset","latest"]] |
| TLS with a private CA | kafka://broker:9093?topic=orders&tls={"required":true,"ca_file":"/etc/ssl/kafka-ca.pem"} |
| User and password (SASL PLAIN over TLS) | kafka://broker:9093?topic=orders&username=svc&password=secret |
Rules that the table follows:
- One broker is enough. The client asks it for the rest of the cluster. A list of brokers
is not a valid URL, so it goes in
url=, with_as a placeholder host. tls,producer_optionsandconsumer_optionstake JSON. Put the whole URL in single quotes so the shell leaves the brackets and quotes alone.- An unknown parameter is an error.
kafka://host?topic=orders&foo=bardoes not start; librdkafka options go inproducer_optionsorconsumer_options.
Config (YAML / library)
Every URL parameter is a field of the same name under kafka:. In a config file url is the
bare host:port list, without kafka://.
Read a topic with a consumer group and write it to PostgreSQL:
orders_to_postgres:
input:
kafka:
url: "broker1:9092,broker2:9092"
topic: "orders"
group_id: "orders-sync"
output:
sqlx:
url: "postgres://app:secret@localhost/shop"
table: "orders"
columns: auto
key: "id"
Write to a topic, keyed by customer, with eight partitions if the topic is new:
webhooks_to_kafka:
input:
http: { url: "0.0.0.0:8080", path: "/orders", method: "POST" }
output:
kafka:
url: "localhost:9092"
topic: "orders"
partition_key: "customer_id" # a metadata field; see Writing
partitions: 8
Copy one topic to another cluster, with retries and a dead-letter topic for what fails:
mirror_orders:
input:
kafka: { url: "old-kafka:9092", topic: "orders", group_id: "mirror" }
output:
middlewares:
- retry: { max_attempts: 5 }
- dlq:
endpoint:
kafka: { url: "new-kafka:9092", topic: "orders.failed" }
kafka: { url: "new-kafka:9092", topic: "orders" }
Reading
Whether a restart continues where it stopped depends on group_id:
With group_id | Without group_id | |
|---|---|---|
| Consumer group | the one you name | a new one on every start (event-sub-<id>) |
| First start | the oldest record still in the topic | only records that arrive from now on |
| After a restart | continues after the last acknowledged batch | starts at “now” again |
| Several instances | share the partitions between them | each gets every record |
- Offsets are committed after the output accepted the batch, never before. A crash in between delivers that batch again (at least once). Make the output idempotent or add deduplication where a duplicate matters.
- A record’s headers become the message’s metadata, and the record value is the payload, byte for byte. Kafka knows no format; a JSON value stays JSON.
--drainends at the end of the topic. Withmqb copy --drainand agroup_id, the copy stops when every partition has been read to its end. A topic that does not exist counts as empty.source_metadata: trueadds the topic, partition and offset of each record asmqb.src.kafka_*metadata.consumer_optionsoverride the defaults. mq-bridge setsauto.offset.reset(earliestwith a group,latestwithout) andenable.auto.commit=false; leave the second one alone.
Writing
- The topic is created if it is missing, with
partitionspartitions (default 6) and the cluster’s default replication factor. Setcreate_topic: falsewhere topics are managed elsewhere or the user may not create them. An existing topic is never changed. - The record key decides the partition. By default the key is the message id, which
spreads records evenly. With
partition_key: customer_idthe key is the value of the metadata fieldcustomer_id, so all records of one customer keep their order. To key by a payload field, copy it into metadata first with atransform. - Metadata is written as record headers, together with the message id in
mq_bridge.message_id. - Sent means acknowledged by all in-sync replicas. The producer is idempotent (
acks=all), compresses with lz4 and waits 1 ms to fill a batch.delayed_ack: truestops waiting for the broker: faster, and a crash can lose what was not yet confirmed. producer_optionsoverride all of that. Withacksset to0or1, idempotence is switched off too, as librdkafka requires.
output:
kafka:
url: "localhost:9092"
topic: "clicks"
producer_options:
- ["compression.type", "gzip"]
- ["linger.ms", "20"]
- ["message.max.bytes", "5000000"]
Authentication and TLS
input:
kafka:
url: "broker.example.com:9093"
topic: "orders"
group_id: "orders-sync"
username: "svc-orders"
password: "${KAFKA_PASSWORD}"
tls:
required: true
ca_file: "/etc/ssl/kafka-ca.pem" # leave out for a public CA
cert_file: "/etc/ssl/client.pem" # client certificate (mutual TLS)
key_file: "/etc/ssl/client.key"
| Setting | Effect |
|---|---|
tls.required: true | Connect with TLS (security.protocol=ssl) |
tls.ca_file | The CA that signed the broker certificate |
tls.cert_file, tls.key_file, tls.cert_password | Client certificate for mutual TLS |
tls.accept_invalid_certs: true | Do not verify the broker certificate. For tests only |
username and password | SASL PLAIN over TLS (sasl_ssl). Setting both switches TLS on, also without tls.required |
This is what Confluent Cloud expects, with the API key as username and the API secret as
password. Another mechanism or protocol is set through the options, which are applied last:
consumer_options:
- ["sasl.mechanism", "SCRAM-SHA-512"]
Without ca_file the broker certificate is checked against the CA certificates of the
system.
Building it yourself? TLS and SASL are in the prebuilt
mqb, the Python wheels and the Node packages. In your own Rust build they need the Cargo featurekafka-ssl(part offull) in addition tokafka. Without it such a connection fails at start withOpenSSL not available at build time.
Troubleshooting
| Message or symptom | Cause |
|---|---|
not a valid URI … invalid port number | A broker list in the URL. Use kafka://_/?url=b1:9092,b2:9092&topic=… |
unrecognised query param 'x' | Not a field of this connector. Use producer_options / consumer_options |
Kafka broker not reachable | Wrong host or port, or a TLS port addressed without TLS |
OpenSSL not available at build time | Built with kafka but without kafka-ssl. See Authentication and TLS |
libzstd not available at build time | compression.type=zstd is not in this build; use lz4 or gzip |
| The source reads nothing from an existing topic | No group_id: it only sees new records |
| Records arrive twice after a restart | The batch was written but not yet committed. See Delivery guarantees |
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 (URL: a JSON literal, tls={"required":true}). |
partition_key | Publisher-only: metadata field used as the record key. |
delayed_ack | Publisher-only: don’t wait for broker acknowledgement. |
create_topic | Publisher-only: create the topic at start if it is missing (default true). The topic gets partitions partitions (default 6) and the cluster’s default.replication.factor. Set false (URI: ?create_topic=false) on a cluster where topics are managed elsewhere. |
producer_options / consumer_options | librdkafka options as [name, value] pairs. |
Full field list: reference/kafka.md.
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). |
create_topic | boolean | no | true | (Publisher only) Create the topic at start if it is missing (default: true). |
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
Publishes to or consumes from NATS subjects. By default it uses JetStream: messages are
stored in a stream and acknowledged. With no_jetstream=true it uses Core NATS, which keeps
nothing: a message goes to whoever is subscribed at that moment.
URL format
nats://[user:password@]host[:port]?subject=<subject>&stream=<stream>
| What you want | URL |
|---|---|
| Publish to a JetStream stream | nats://localhost:4222?subject=orders.new&stream=orders |
| Consume a stream, resuming after a restart | nats://localhost:4222?subject=orders.new&stream=orders |
| Consume every subject of the stream | nats://localhost:4222?subject=orders.>&stream=orders |
| Only messages that arrive from now on | nats://localhost:4222?subject=orders.new&stream=orders&deliver_policy=new |
| Every instance gets every message | nats://localhost:4222?subject=orders.new&stream=orders&subscriber_mode=true |
| Core NATS, no JetStream | nats://localhost:4222?subject=orders.new&no_jetstream=true |
| Request / reply | nats://localhost:4222?subject=rpc.price&no_jetstream=true&request_reply=true |
| Several servers of a cluster | nats://_/?url=nats://n1:4222,nats://n2:4222&subject=orders.new&stream=orders |
| User and password | nats://app:secret@localhost:4222?subject=orders.new&stream=orders |
| Token | nats://localhost:4222?subject=orders.new&stream=orders&token=s3cr3t |
| TLS with a private CA | nats://nats.example.com:4222?subject=orders.new&stream=orders&tls={"required":true,"ca_file":"/etc/ssl/nats-ca.pem"} |
Rules that the table follows:
streamis required with JetStream, for an input and an output. Without it the route stops withstream must be provided when JetStream is enabled.- Name the subject after the stream. A stream that mq-bridge creates for an output takes
the subjects
<stream>.>, sosubject=orders.newfitsstream=orders, andsubject=newdoes not. See Streams. - One server is enough. The client learns the other members of a cluster from the server
it connects to. To name several, so that the first connection does not depend on one of
them, put a comma-separated list in
url=, with_as a placeholder host. tlstakes JSON, so put the URL in single quotes. An unknown parameter is an error.
Config (YAML / library)
Every URL parameter is a field of the same name under nats:.
Take orders in over HTTP and publish them to JetStream:
orders_in:
input:
http: { url: "0.0.0.0:8080", path: "/orders" }
output:
nats:
url: "nats://localhost:4222"
subject: "orders.new"
stream: "orders"
deduplicate: true
Consume them into PostgreSQL:
orders_to_postgres:
input:
nats:
url: "nats://localhost:4222"
subject: "orders.new"
stream: "orders"
output:
sqlx:
url: "postgres://app:secret@localhost/shop"
table: "orders"
columns: auto
With credentials and TLS:
input:
nats:
url: "nats://nats.example.com:4222"
subject: "orders.>"
stream: "orders"
username: "app"
password: "${NATS_PASSWORD}"
tls: { required: true, ca_file: "/etc/ssl/nats-ca.pem" }
For a cluster, url takes a comma-separated list: "nats://n1:4222,nats://n2:4222". Use
username and password, or token. With tls.required: true the connection is TLS or
fails; without ca_file the public CAs are trusted. cert_file and key_file add a client
certificate.
Streams
JetStream stores messages in a stream, and a stream lists the subjects it stores.
- An existing stream is used as it is. mq-bridge does not change its subjects or limits. Then any subject the stream stores can be used.
- An output creates a missing stream with the subjects
<stream>.>: everything below the stream’s name. With a wildcard insubjectit takes that subject instead. The new stream keeps at moststream_max_messagesmessages (default 1,000,000) andstream_max_bytesbytes (default 1 GiB). - An input creates a missing stream with exactly its
subject.
For streams with other settings (replicas, retention, a work-queue policy), create them with
the nats command line first:
nats stream add orders --subjects 'orders.>' --storage file --replicas 3 --defaults
Reading
| Default | subscriber_mode: true | |
|---|---|---|
| JetStream consumer | durable, named after stream and subject | not durable |
| After a restart | continues after the last acknowledged message | starts again by deliver_policy |
| Several instances | share the messages | each gets every message |
deliver_policydecides where a new consumer starts:all(default, the oldest stored message),new(only what arrives from now on),last, orlast_per_subject. A durable consumer that already exists keeps its position.- A message is acknowledged when the output has accepted it. One that failed is handed back to JetStream and delivered again.
prefetch_countis the number of messages that may be unacknowledged at a time (default 10000). Lower it when messages are large.- Headers are metadata.
source_metadata: trueadds the stream sequence asmqb.src.nats_*. - The durable consumer’s name is built from stream and subject, with
*written_any_and>written_all_. Two routes with the same stream and subject therefore share one consumer and split the messages. - A
--drainright after a crash waits for redelivery. What the crashed run held comes back afterack_wait(default 30 s), so the drain ends only once those messages are copied.
Writing
- JetStream waits for the server’s acknowledgement of every message.
delayed_ack: truedoes not wait: faster, and a crash can lose what was not yet stored. deduplicate: truesends the message id asNats-Msg-Id. JetStream then drops a message it has seen within the stream’s duplicate window, so a retry does not store it twice.- Metadata is sent as headers.
- Core NATS confirms nothing. With
no_jetstream: truea message published while nobody is subscribed is gone.
Request / reply
With request_reply: true the output sends each message as a NATS request and waits up to
request_timeout_ms (default 30000) for the reply. This route is an HTTP front for a NATS
service:
price_api:
input:
http: { url: "0.0.0.0:8080", path: "/price" }
output:
request:
to:
nats:
url: "nats://localhost:4222"
subject: "rpc.price"
no_jetstream: true
request_reply: true
request_timeout_ms: 2000
forward_to:
response: {}
And this one is such a service: it answers every request on rpc.echo with its payload.
echo_service:
input:
nats: { url: "nats://localhost:4222", subject: "rpc.echo", stream: "rpc", no_jetstream: true }
output:
response: {}
An input needs stream even with no_jetstream: true; the value is checked and not used.
A JetStream consumer sends a reply to the subject in the message’s reply_to header; a
Core NATS consumer to the native reply subject.
Examples
Load a JSONL file into a JetStream subject, one-shot:
mqb copy --drain \
--from 'file:///data/orders.jsonl?format=raw' \
--to 'nats://localhost:4222?subject=orders.new&stream=orders'
Consume a durable JetStream stream into Postgres, continuous:
mqb copy \
--from 'nats://localhost:4222?subject=orders.new&stream=orders' \
--to 'postgres://user:pass@localhost/app?table=orders&auto_create_table=true'
HTTP in front of a Core NATS service, continuous:
mqb copy \
--from 'http://0.0.0.0:8080?path=/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 unless a publisher sets no_jetstream. |
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={...}). With required: true the connection is TLS or fails; without ca_file the public CAs are trusted. |
Full field list: reference/nats.md.
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)
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.
Config (YAML / library)
The same settings as a route endpoint in a config file, or in Route.from_config /
fromConfig. Every URL query parameter is a field of the same name under amqp:.
input:
amqp: { url: "amqp://localhost:5672/%2f", queue: "orders" }
The config key is amqp; rabbitmq:// is a CLI-only alias for amqp://.
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'
The exchange and a binding for the routing key (the queue value) must exist: mq-bridge
declares the queue, not the exchange or the binding. A message the broker can route to no
queue fails the send; it is not dropped. With delayed_ack: true or subscribe_mode: true
the publish is not mandatory and such a message is dropped.
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. |
tls | TLS configuration (object; set with a JSON literal ?tls={...}). An amqps:// URL uses it; tls.required: true makes an amqp:// URL amqps:// (port 5671 unless the URL names one). |
Full field list: reference/rabbitmq.md.
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
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.
Config (YAML / library)
The same settings as a route endpoint in a config file, or in Route.from_config /
fromConfig. Every URL query parameter is a field of the same name under mqtt:.
input:
mqtt: { url: "mqtt://localhost:1883", topic: "sensors/+/temperature" }
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. Without it a route’s consumer is mq-bridge-<route> (persistent session) and its publisher mq-bridge-<route>-out. |
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). |
A source can lose a few in-flight messages when the broker restarts abruptly: MQTT guarantees QoS 1/2 redelivery only across a session that survives, and a message the broker dropped never reaches the consumer to be retried. The sink is not affected; it re-publishes until confirmed.
Full field list: reference/mqtt.md.
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
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), rediss:// (TLS) and redis+unix:///path/to.sock (Unix socket) are
accepted; redis_streams:// is an explicit alias. On the command line valkey://,
valkeys:// and valkey+unix:// work too. If stream is omitted it defaults to the route name.
TLS
A rediss:// URL, or tls.required: true, connects with TLS and checks the server certificate
and host name.
| Setting | Effect |
|---|---|
| none | The certificate is checked against the public roots, as managed Redis services need. |
tls.ca_file | Trusts that CA instead. Needed for a server with a private or self-signed CA. |
tls.cert_file and tls.key_file | Presents a client certificate (PEM), for a server with tls-auth-clients yes. |
tls.accept_invalid_certs is ignored: the certificate is always checked.
output:
redis_streams:
url: "rediss://redis.internal:6380"
stream: "orders"
tls: { ca_file: /etc/mqb/ca.pem, cert_file: /etc/mqb/client.pem, key_file: /etc/mqb/client.key }
mqb copy --drain \
--from file:///data/events.csv?format=csv \
--to 'rediss://redis.internal:6380?stream=events&tls={"ca_file":"/etc/mqb/ca.pem"}'
Config (YAML / library)
The same settings as a route endpoint in a config file, or in Route.from_config /
fromConfig. Every URL query parameter is a field of the same name under redis_streams:.
input:
redis_streams: { url: "redis://localhost:6379", stream: "orders", group: "mqb" }
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'
Entries pending longer than redelivery_timeout_ms (default 60s) are re-delivered via
XAUTOCLAIM: a nacked entry, or one left behind by a consumer that is gone. An entry this
consumer still holds is not. With 0, a nacked entry is never delivered again.
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 $. A new group that starts at $ on a stream with entries logs a warning. |
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.
Redis Streams
Schemes: redis://, rediss://, redis+unix://, valkey://, valkeys://, valkey+unix://
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 |
|---|---|---|---|---|
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. |
tls | object | no | see below | TLS: a private CA in ca_file, a client certificate in cert_file and key_file. |
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). |
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. |
MongoDB
Reads from or writes to a MongoDB collection. As a source it can copy a collection, follow its
changes (change data capture), or work a collection as a job queue. database and
collection are parameters of their own; the database is not read from the URL path.
URL format
mongodb://[user:password@]host[:port]/?database=<db>&collection=<name>
| What you want | URL |
|---|---|
| Write JSON documents to a collection | mongodb://localhost:27017/?database=shop&collection=orders&format=raw |
The same, with the payload’s order_id as _id | mongodb://localhost:27017/?database=shop&collection=orders&format=raw&id_field=order_id |
| Copy a collection, then follow its changes | mongodb://localhost:27017/?database=shop&collection=orders |
| Follow changes only | mongodb://localhost:27017/?database=shop&collection=orders&consume=capture_new |
| Copy a collection once, without a replica set | mongodb://localhost:27017/?database=shop&collection=orders&consume=snapshot |
| User and password | mongodb://app:secret@db.example.com:27017/?database=shop&collection=orders&authSource=admin |
| Replica set, several hosts | mongodb://_/?url=mongodb://db1:27017,db2:27017,db3:27017/?replicaSet=rs0&database=shop&collection=orders |
MongoDB Atlas (mongodb+srv) | mongodb+srv://<user>:<password>@<cluster>.mongodb.net/?database=shop&collection=orders |
Rules that the table follows:
databaseis required and is always a parameter.mongodb://localhost/shop?collection=ordersdoes not start.- Driver options stay on the connection string. A parameter that is not a field of this
connector, such as
replicaSet,authSourceortls=true, is passed to the MongoDB driver. - A host list goes in
url=, with_as a placeholder host, because it is not a URL the command line can parse. In a config file the connection string is used as it is.
Config (YAML / library)
Every URL parameter is a field of the same name under mongodb:. url takes any MongoDB
connection string.
output:
mongodb:
url: "mongodb+srv://cluster0.ab12c.mongodb.net"
username: "app"
password: "${MONGO_PASSWORD}"
database: "shop"
collection: "orders"
format: raw
username and password take precedence over credentials in the url. For TLS with a
private CA or a client certificate, set tls:
output:
mongodb:
url: "mongodb://db.example.com:27017"
database: "shop"
collection: "orders"
tls: { required: true, ca_file: "/etc/ssl/mongo-ca.pem" }
Writing
format decides what a stored document looks like. For a collection that other programs
query, use format: raw. The default keeps the message intact for another mq-bridge route
to read, and stores the payload as binary.
For the message {"order_id": "A-1", "total": 20}:
format | Stored document | Use for |
|---|---|---|
raw | { _id: …, order_id: "A-1", total: 20 } | Documents you query |
json | { _id: …, payload: { order_id: "A-1", total: 20 }, metadata: {…}, created_at: … } | Queryable payload, metadata kept |
text | { _id: …, payload: "{\"order_id\": …}", metadata: {…}, created_at: … } | Text payloads |
normal (default) | { _id: …, payload: <binary>, metadata: {…}, created_at: … } | Queues between mq-bridge routes; any payload |
With raw, a payload that is not a JSON object is stored like normal.
Load a JSONL file into a collection, with the order number as _id:
load_orders:
input:
file: { path: "/data/orders.jsonl", format: raw }
output:
mongodb:
url: "mongodb://localhost:27017"
database: "shop"
collection: "orders"
format: raw
id_field: "order_id"
id_fieldmakes the write repeatable. Its value becomes_id, with its JSON type kept. A document whose_idexists is left as it is and counts as written, so running the load twice does not duplicate anything. It does not update the stored document. A message without the field fails. More in Upserts & insert-if-absent.- Without
id_fieldthe_idis the payload’s own_id(raw), or one that is generated. ttl_secondscreates a TTL index, so documents are removed that long after they were written.capped_size_bytescreates the collection as a capped collection.
Reading
consume selects one of four ways to read:
consume | What it does | Changes the collection | Needs a replica set |
|---|---|---|---|
capture_all (default) | Reads every document, then follows changes | no | yes |
capture_new | Follows changes from now on | no | yes |
snapshot | Reads every document once, then ends | no | no |
delete | Job queue: claims a document, deletes it when done. Also accepted as consumer | yes, deletes | no |
A single-node replica set is enough for the two capture modes; without one they refuse to
start. The comparison in detail is under
MongoDB consume modes.
Follow a collection and publish every change to Kafka:
orders_cdc:
input:
mongodb:
url: "mongodb://localhost:27017/?replicaSet=rs0"
database: "shop"
collection: "orders"
consume: capture_all
cursor_id: "orders-to-kafka"
output:
kafka: { url: "localhost:9092", topic: "shop.orders" }
- The payload is the document as JSON. Types that JSON does not have are written the
MongoDB way: an ObjectId as
{"$oid": "…"}, a date as{"$date": …}. mongodb.operationsays what happened:insert,update,replaceordelete. An update carries the whole document as it is after the change. A delete carries only the key,{"_id": …}.mongodb.document_idhas the_idin every case.cursor_idnames the saved position, so a restart continues where it stopped. By default the position is kept in the source database, in the collectionmqb_cursors_<collection>;checkpoint_storeputs it elsewhere, for a source you may not write to. See Checkpoints.
Copy a collection of a standalone mongod into a file, once:
mqb copy --drain \
--from 'mongodb://localhost/?database=shop&collection=customers&consume=snapshot' \
--to 'file:///data/customers.jsonl?format=raw'
snapshot delivers what exists when it runs. It cannot resume and refuses a cursor_id.
A collection as a job queue
With consume: delete several instances share the work: each document goes to one of them
and is deleted when it was processed. The documents must have been written by an mq-bridge
output with the format normal, json or text, which is the shape the queue expects; do
not point this mode at a collection of your own data.
enqueue_jobs:
input:
http: { url: "0.0.0.0:8080", path: "/jobs" }
output:
mongodb: { url: "mongodb://localhost:27017", database: "work", collection: "jobs", format: json }
work_jobs:
input:
mongodb: { url: "mongodb://localhost:27017", database: "work", collection: "jobs", consume: delete }
output:
http: { url: "http://worker.internal:9000/run" }
The queue is read in the order the documents were written. The publisher stores that
position in the field insert_order, apart from the _id, so a message_id from another
system does not change the order. It is a UUIDv7 that always rises within one process;
two writers are ordered by their clocks. A reader that needs an exact order uses
capture_all or capture_new.
Documents without insert_order are read first, in _id order: those written with
format: raw, by another program, or by an earlier version.
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&format=raw'
Copy a collection into PostgreSQL (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'
Watch for changes only, 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. |
format | Publisher: how a message is stored: normal (default), json, text, raw. |
id_field | Publisher-only: payload field that becomes _id. |
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 delete (durable work queue — destructive; also consumer). |
cursor_id | Names the saved read position of capture_new/capture_all. |
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.
MongoDB
Schemes: mongodb://, mongodb+srv://
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 | delete | 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 delete (competing-consumers work queue — destructive and intended for jobs; also consumer). 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. |
find | string | no | — | (Publisher only) Extended-JSON filter template for lookup, e.g. {"_id": "${payload:id}"}. Answers with the first match. {"_id": {"$in": ["${payload:id}"]}} answers a batch at once. |
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 | — | Deprecated — unused since the publisher no longer writes seq. Kept for compatibility. |
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. |
HTTP
Two different things under one name:
- As an output it is an HTTP client: every message is sent as one request to a URL.
- As an input it is an HTTP server: it listens on an address, and every request it receives is one message. This is how you take in webhooks or put an HTTP API in front of a queue.
To write many documents per request to a search engine or a REST API, see HTTP bulk. For a response that stays open (server-sent events), the same page has An open stream.
URL format
http://host[:port]/path output: the target URL
http://address:port input: the address to listen on
| What you want | URL |
|---|---|
| POST each message to an API | https://api.example.com/v1/orders |
| PUT instead of POST | https://api.example.com/v1/orders?method=PUT |
| Send an API key header | https://api.example.com/v1/orders?custom_headers={"X-API-Key":"abc123"} |
| Basic authentication | https://api.example.com/v1/orders?basic_auth=["user","secret"] |
| Give up on a request after 5 s | https://api.example.com/v1/orders?request_timeout_ms=5000 |
| Listen on all interfaces, port 8080 | http://0.0.0.0:8080 |
Accept only POST /hooks/github | http://0.0.0.0:8080?path=/hooks/github&method=POST |
Answer 202 at once, process afterwards | http://0.0.0.0:8080?fire_and_forget=true |
Rules that the table follows:
- An output keeps its path and its own query parameters. In
https://api.example.com/search?api-version=2the parameterapi-versionis not a field of this connector, so it stays in the request URL. A parameter that is a field, such asmethodorpath, is taken as configuration and removed. - An input has no path in the URL.
http://0.0.0.0:8080/hookis refused withInvalid listen address; write?path=/hook. Withoutpathevery path is accepted. custom_headers,basic_authandtlstake JSON. Put the URL in single quotes.
Config (YAML / library)
Every URL parameter is a field of the same name under http:. For an input url is the
listen address, without a scheme; for an output it is the target URL.
Calling an API (output)
Send every row of a table to an API, with a header, retries, and a file for what the API keeps refusing:
orders_to_api:
input:
sqlx:
url: "postgres://app:secret@localhost/shop"
table: "orders"
cursor_column: "id"
cursor_id: "orders-to-api"
output:
middlewares:
- retry: { max_attempts: 5, initial_interval_ms: 500 }
- dlq: { endpoint: { file: { path: "refused-orders.jsonl" } } }
http:
url: "https://api.example.com/v1/orders"
method: "POST"
custom_headers:
Authorization: "Bearer ${API_TOKEN}"
Content-Type: "application/json"
request_timeout_ms: 10000
batch_concurrency: 8
- The message payload is the request body, sent as it is. Metadata entries are sent as
request headers;
custom_headersare added to every request. - A message from an HTTP input keeps its method and path. If the message has
http_method,http_pathorhttp_queryin its metadata, as every message of an HTTP input does, they replacemethodand the path ofurl. See Answering a request. - A status outside 2xx fails the message. With
pass_through_status: trueevery status counts as an answer instead, and the status is in the metadata of the response for aswitchto act on. - Requests of one batch run in parallel,
batch_concurrencyat a time (default 20). Lower it for an API with a rate limit, or add alimitermiddleware. compression: gzipcompresses request bodies abovecompression_threshold_bytes(default 1024).
Receiving webhooks (input)
Take in webhooks and put them on a Kafka topic:
github_webhooks:
input:
http:
url: "0.0.0.0:8080"
path: "/hooks/github"
method: "POST"
max_body_bytes: 1048576
output:
kafka: { url: "localhost:9092", topic: "github-events" }
curl -X POST http://localhost:8080/hooks/github \
-H 'X-GitHub-Event: push' -d '{"ref":"refs/heads/main"}'
# 202 Message processed
- The caller gets its answer when the output has accepted the message:
202after the Kafka broker confirmed it. If the output fails, the caller gets an error and can send again. Withfire_and_forget: truethe answer is202at once, and a failure after that is only in the log. - The request body is the payload; headers are metadata. The listener adds
http_method,http_path,http_queryandhttp_version. In the example above the message hasx-github-event: pushandhttp_path: /hooks/githubin its metadata. - Other requests are refused:
404for another path,405for another method,401without the credentials ofbasic_auth,413abovemax_body_bytes(default 256 MiB). - Several routes can listen on one port when each has its own
path.
Protect a listener with a user and password, and serve it over TLS:
input:
http:
url: "0.0.0.0:8443"
basic_auth: ["webhook", "${WEBHOOK_PASSWORD}"]
tls:
required: true
cert_file: "/etc/ssl/server.pem"
key_file: "/etc/ssl/server.key"
A certificate without required: true is not used; the listener warns at start.
Answering a request (request / reply)
With the response output the caller gets the message back as the HTTP response, after the
middlewares of the route have changed it. This route answers every POST /echo with the body
it was sent:
echo:
input:
http: { url: "0.0.0.0:8080", path: "/echo" }
output:
response: {}
An HTTP output in the middle makes the route a proxy: the request goes to another service, and that service’s response goes back to the caller.
proxy:
input:
http: { url: "0.0.0.0:8080" }
output:
request:
to:
http: { url: "http://backend.internal:9000", pass_through_status: true }
forward_to:
response: {}
GET /users/7?full=1 on port 8080 becomes GET http://backend.internal:9000/users/7?full=1:
a message that came in over HTTP keeps its method, path and query string on the way out, and
the output’s url supplies only the scheme, host and port.
More in the Request / reply tutorial.
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?path=/hooks&method=POST' \
--to 'kafka://kafka.local:9092?topic=webhooks'
Send a file line by line, with a 5-second timeout per request:
mqb copy --drain \
--from 'file:///data/events.jsonl?format=raw' \
--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. |
path | Consumer-only: accept only this request path. |
custom_headers | Headers added to every request (publisher) or response (consumer). |
basic_auth | [user, password]. Publisher: sent with each request. Consumer: required from each caller. |
request_timeout_ms | Per-request timeout. Defaults to 30000ms. |
batch_concurrency | Publisher-only: requests in flight for one batch. Defaults to 20. |
pass_through_status | Publisher-only: treat a status outside 2xx as an answer, not as a failure. |
workers | Consumer-only: worker thread count. Defaults to unlimited. |
fire_and_forget | Consumer-only: respond 202 immediately, don’t wait for downstream processing. |
max_body_bytes | Consumer-only: largest request body. Defaults to 256 MiB. |
message_id_header | Header to extract the message ID from. Defaults to message-id. |
Metadata and headers. A consumer stores each request header as metadata; a publisher
sends each metadata entry as a request header, and a consumer sends the metadata of a reply
as response headers. Not passed on: host, connection, keep-alive, proxy-connection,
upgrade (request side), the framing headers content-length, transfer-encoding,
content-encoding, and anything that is no valid header (a space in the name, a line break
in the value). To send a fixed Host, use custom_headers. A publisher decodes a gzip,
lz4 or zstd response and removes its content-encoding.
Full field list: reference/http.md.
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. |
header_read_timeout_ms | integer | no | — | (Consumer only) Close an HTTP/1 connection whose request head takes longer than this. Unset or 0 = no limit. |
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. |
max_body_bytes | integer | no | — | (Consumer only) Largest request body in bytes, also after decompression; a larger one gets 413. Defaults to 256 MiB. |
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). |
pass_through_status | boolean | no | false | (Publisher) Treat every HTTP response status as response data instead of classifying non-2xx statuses as publisher errors. Transport and response-read failures remain errors. (Consumer) When every output sink opts in, transient sink failures return 502 without stopping a non-streaming request/reply route. Defaults to false. |
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. A stream that breaks after its first item ends with an item marked http_stream_error and is not retried. 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
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> or wss://<bind-address>
# Publisher (connect): ws://host[:port]/path or wss://host[:port]/path
A publisher takes ws:// (plain) and wss:// (TLS); its URL is the target server. For a
consumer the URL is the listen address (e.g. ws://0.0.0.0:9000).
TLS and authentication
| Side | Setting | Effect |
|---|---|---|
| Consumer | tls.required: true with tls.cert_file and tls.key_file | Listens with TLS. On the command line a wss:// source sets tls.required. |
| Consumer | tls.ca_file in addition | Only clients with a certificate signed by that CA are accepted (mutual TLS). |
| Publisher | a wss:// URL | Connects with TLS and checks the server certificate and host name against the public roots. |
| Publisher | tls.ca_file | Trusts that CA instead of the public roots. |
| Publisher | tls.cert_file and tls.key_file | Presents a client certificate. |
| Consumer | basic_auth: [user, password] | An upgrade request without these credentials gets 401. |
| Publisher | basic_auth: [user, password] | Sends them as the Authorization header of the upgrade request. |
| Consumer | max_connections: N | At most N connections at once; one more gets 503. Unset means no limit. |
A publisher with tls.required: true and a ws:// URL is refused when the route starts.
A listener without basic_auth and without tls.ca_file accepts every client, so bind it
to loopback or put a proxy in front. basic_auth without TLS sends the password in the clear.
A client has 10 seconds to complete the TLS handshake and send its upgrade request. Behind
TLS a connection over max_connections is closed before the handshake instead of getting a
503. On the command line: ?basic_auth=["user","password"]&max_connections=1000.
With TLS a request for a path other than path is closed after the upgrade; without TLS it
is answered with 404.
Delivery
WebSocket has no acknowledgement. A publisher answers pings, drops frames the server sends
and connects again after the server closed the connection; a batch that is being written
at the moment the server goes away is lost. Connect, TLS and upgrade together may take
10 seconds. A send has no limit of its own: add the timeout middleware for a server that
may stop reading.
A listener closes its connections when its route stops. A client that does not read its
replies for 10 seconds is disconnected. A message the route rejects is not signalled to
the client; only a reply is. A route with a websocket input is therefore at-most-once, and
required_delivery: at_least_once refuses it.
Config (YAML / library)
The same settings as a route endpoint in a config file, or in Route.from_config /
fromConfig. Every URL query parameter is a field of the same name under websocket:.
input:
websocket: { url: "0.0.0.0:9000" } # listen address
output:
websocket: { url: "ws://localhost:9000/events" } # target server
A TLS listener, and a publisher that trusts a private CA:
input:
websocket:
url: "0.0.0.0:9443"
tls: { required: true, cert_file: /etc/mqb/server.pem, key_file: /etc/mqb/server.key }
output:
websocket:
url: "wss://feed.internal:9443/events"
tls: { ca_file: /etc/mqb/ca.pem }
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
Listen with TLS:
mqb copy \
--from 'wss://0.0.0.0:9443?tls={"cert_file":"/etc/mqb/server.pem","key_file":"/etc/mqb/server.key"}' \
--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). |
tls | TLS configuration (object; set with a JSON literal ?tls={...}). See TLS and authentication. |
basic_auth | [user, password]: required by a consumer, sent by a publisher. |
max_connections | Consumer-only: most connections open at once (default: no limit). |
routed_queue_capacity | Consumer-only: queue capacity for the routed adapter (default 100). |
Full field list: reference/websocket.md.
WebSocket
Schemes: ws://, wss://
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 |
|---|---|---|---|---|
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. |
basic_auth | array of any | no | — | HTTP Basic credentials (username, password): a consumer requires them, a publisher sends them. |
execution_mode | auto | direct_only | routed | no | auto | (Consumer only) Selects whether WebSocket routes run directly or through the routed pipeline. |
max_connections | integer | no | — | (Consumer only) Maximum number of concurrent connections; additional connections receive 503. Unset = no limit. |
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. |
tls | object | no | see below | TLS: a consumer listens with cert_file and key_file; a publisher uses it for a wss:// URL. |
url | string | yes | — | For consumers, the listen address (e.g. “0.0.0.0:9000”). For publishers, the target 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. |
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).
Config (YAML / library)
The same settings as a route endpoint in a config file, or in Route.from_config /
fromConfig. Every URL query parameter is a field of the same name under grpc:.
input:
grpc: { url: "0.0.0.0:50051", server_mode: true }
output:
grpc: { url: "http://localhost:50051", topic: "orders" }
grpc:// / grpcs:// are CLI-only; in config the client url is http:// / https://.
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. The server keeps at most 1024 of them per
subscriber and closes a subscription that falls further behind; the client subscribes
again and is sent what was retained. Messages published while a subscriber is away are
not kept for it.
Serving a unary method
A server-mode input with a descriptor, a service and a method also serves that one unary method.
Each call arrives as its request in canonical JSON, and a response output replies:
input:
grpc:
url: 0.0.0.0:50051
server_mode: true
descriptor_set_path: proto/benchmark.bin
service_name: benchmark.BenchmarkService
method_name: GetSum
output:
response: {}
The reply payload must be JSON matching the method’s output message. Streaming methods are rejected.
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, or the served unary method in server 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.
gRPC
Schemes: grpc://, grpcs://
Query parameters recognised as config fields for this connector. The object-typed binary_metadata is set with a JSON literal, e.g. ?binary_metadata={...}. 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 |
|---|---|---|---|---|
api_key | string | no | — | API key sent as api_key_name (default x-api-key) on dynamic calls and the reflection RPC. |
api_key_name | string | no | — | Metadata key used for api_key. Defaults to x-api-key. |
bearer_token | string | no | — | Bearer token sent as authorization on dynamic calls and the reflection RPC; rejected in Bridge/server mode. |
binary_metadata | object | no | — | Static binary metadata for dynamic calls and the reflection RPC. Keys must end in -bin; each value is raw bytes, written as a JSON array of byte values 0-255 (the only accepted form — a base64 or text string is rejected). As a URL parameter: ?binary_metadata=%7B%22x-trace-bin%22%3A%5B1%2C2%2C3%5D%7D, which is {"x-trace-bin": [1, 2, 3]} percent-encoded. |
concurrency_limit_per_connection | integer | no | null | Maximum number of concurrent requests handled per connection. Server-mode only. |
connect_timeout_ms | integer | no | null | Maximum time to establish a client connection. |
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_bytes | array of integer | no | — | Compiled protobuf FileDescriptorSet bytes for embedded callers. Takes precedence over descriptor_set_path and avoids writing a temporary descriptor file. |
descriptor_set_path | string | no | null | Compiled protobuf FileDescriptorSet for dynamic calls or a served method. |
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. |
idle_stream_timeout_ms | integer | no | null | Maximum time a dynamic response stream may remain idle between messages. |
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. |
metadata | object | no | — | Static ASCII metadata attached to dynamic RPCs and to the reflection RPC that fetches their descriptors. Values for keys that look sensitive are extracted by mq-bridge’s normal secret handling. |
method_name | string | no | null | RPC method name for dynamic calls or a served unary method. |
overall_timeout_ms | integer | no | null | Maximum lifetime of a dynamic RPC; exceeding it stops the route instead of reconnecting. |
reflection | boolean | no | false | Discover descriptors from the remote gRPC server reflection v1 service. |
request | any | no | null | JSON request mapped to the dynamic protobuf input message. |
request_timeout_ms | integer | no | null | Maximum time to establish an RPC and receive its initial response. |
server_mode | boolean | no | false | If true, start an embedded tonic gRPC server that accepts Publish / PublishBatch RPCs, plus one descriptor-defined unary method when method_name is set. If false (the default), connect to a remote server as a client. |
server_streaming | boolean | no | false | Deprecated compatibility hint. Dynamic RPC shape is always derived from the descriptor. |
service_name | string | no | null | Fully-qualified protobuf service name for dynamic calls or a served method. |
shared | boolean | no | true | (Publisher only) Share one gRPC channel per connection (default: true); false forces a dedicated channel. |
timeout_ms | integer | no | — | Deprecated compatibility timeout in milliseconds. Used as the fallback for connection and initial-request deadlines. Prefer the dedicated settings. |
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. |
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.
Config (YAML / library)
The same settings as a route endpoint in a config file, or in Route.from_config /
fromConfig. Every URL query parameter is a field of the same name under aws:.
input:
aws: { queue_url: "https://sqs.us-east-1.amazonaws.com/000000000000/orders", region: "us-east-1" }
output:
aws: { topic_arn: "arn:aws:sns:us-east-1:000000000000:events", region: "us-east-1" }
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'
Subscribers of the topic can be phone numbers and email addresses; see SMS & email notifications.
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). |
binary_payload_mode | Publisher-only: send the payload as base64 with the attribute aws_binary_encoded; a consumer decodes it. Needed for payloads that are not UTF-8 text. |
Metadata travels as message attributes. SQS carries at most 10 per message; an entry with
an empty value or a key with characters other than letters, digits, _, -, . is left out.
Full field list: reference/aws.md.
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). |
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.
Config (YAML / library)
The same settings as a route endpoint in a config file, or in Route.from_config /
fromConfig. Every URL query parameter is a field of the same name under zeromq:.
input:
zeromq: { url: "tcp://0.0.0.0:5555", socket_type: "pull", bind: true }
output:
zeromq: { url: "tcp://localhost:5556", socket_type: "push" }
zeromq:// / zmq:// are CLI-only; in config url is the ZeroMQ transport address.
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 | sub: only messages whose topic starts with it are received. pub: the topic sent in front of each message. |
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.
A pub socket sends its topic as the first frame (an empty one with raw_framed when
no topic is set), and a sub socket drops that frame on receive. A foreign subscriber sees
[topic, metadata, payload] for raw_framed.
Reconnecting. A socket that binds is found again by its peers. A socket that connects
reconnects after its peer restarted, with one exception: on the zmq backend a pull or
rep socket that connects does not, and receives nothing more until the route is restarted.
Bind on that side, or use the omq backend. The route logs a warning at start when it is
configured that way.
Full field list: reference/zeromq.md.
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 | — | Topic: the prefix a SUB socket filters on, or the topic frame a PUB socket sends first. |
url | string | yes | — | The ZeroMQ URL (e.g., “tcp://127.0.0.1:5555”). |
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.
Config (YAML / library)
The same settings as a route endpoint in a config file, or in Route.from_config /
fromConfig. Every URL query parameter is a field of the same name under ibmmq:.
input:
ibmmq:
url: "localhost(1414)"
queue_manager: "QM1"
channel: "DEV.APP.SVRCONN"
queue: "DEV.QUEUE.1"
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 | Consumer-only: largest message that can be read, in bytes (default 4 MiB). A larger one stops the route with an error and stays on the queue. |
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.
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. |
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
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).
The names are historical, so read them by what ends up in the file. Each has a second, descriptive name that means exactly the same:
format | Also | One line of the file |
|---|---|---|
raw | payload | the payload and nothing else. For JSON payloads this is plain JSON lines |
json | envelope_json | the whole message (id, metadata, payload) as JSON, the payload as a JSON value |
text | envelope_text | the whole message as JSON, the payload as a string |
normal | envelope | the whole message as JSON, the payload as text or base64 |
For a file other tools read, raw is almost always the one you want; json is for a file
mq-bridge reads back with ids and metadata intact.
Config (YAML / library)
The same settings as a route endpoint in a config file, or in Route.from_config /
fromConfig. Every URL query parameter is a field of the same name under file:.
input:
file: { path: "/data/in.csv", format: csv }
output:
file: { path: "/data/out.jsonl", format: raw }
The URL path becomes the path field.
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. On the command line a path ending in .gz, .zst or .lz4 sets it; compression=none overrides. |
Full field list: reference/file.md.
CSV
- Reading: the first record is the header, and each later record becomes a JSON object
keyed by it. Every value is read as a string; type them with a
transformschema. Quoted fields may hold commas, doubled quotes ("") and line breaks. CRLF files, a leading UTF-8 byte-order mark (Excel’s “CSV UTF-8”) and blank lines are handled: the BOM is stripped and blank lines are skipped. A repeated header name gets a suffix (a,a→ keysa,a_2) so no column is lost. - Writing: the payload must be a JSON object. A new file takes its columns from the
first message’s keys, sorted; appending to a non-empty file keeps that file’s header, so
rows stay aligned with it. Fields containing the separator, the quote character, a line
break or the
delimiterare quoted. A nested object becomes oneparent.childcolumn per leaf (stats.avg), which is what anaggregateresult needs;csv.nested: jsonwrites its JSON text into one cell instead. Arrays are written as their JSON text, andnullasnull. A payload that is not an object, or a string with no UTF-8 spelling (a lone\ud800escape), fails that message instead of writing a broken row. A header that exists but cannot be read fails the write as retryable; nothing is appended under guessed columns. delimiterseparates records (rows), not fields. It must not contain the field separator or the quote character.
Dialects
Comma-separated with " quotes and a header record is the default. Anything else goes in the
csv block:
input:
file:
path: export.csv
format: csv
csv:
separator: auto
| Field | Default | Meaning |
|---|---|---|
separator | , | One character, tab, space, hex (0x1f), or auto. auto is for sources: it takes whichever of , ; tab | occurs most often outside quotes in the first record. |
quote | " | One character, or none when the file quotes nothing. |
header | true | false when the first record is already data; a source then needs columns, and a sink writes rows only. |
columns | — | Source: the keys to use instead of the header’s. Sink: the columns to write, in this order. |
nested | flatten | Sink: flatten or json, see above. |
on_mismatch | warn | Sink: warn writes a record whose keys differ from the columns, leaving missing columns empty and dropping extra keys. fail rejects it, so a dlq takes it. |
| Export | Setting |
|---|---|
| Excel “CSV UTF-8”, semicolon locales | separator: ";" (or auto); the byte-order mark and CRLF need nothing |
| Excel “Text (Tab delimited)”, saved as UTF-8 | separator: tab |
mongoexport --type=csv | default |
mongoexport --type=tsv | separator: tab |
psql --csv, COPY … WITH (FORMAT csv) | default; add separator for DELIMITER ';' |
COPY … WITH (FORMAT csv, HEADER false), mongoexport --noHeaderLine | header: false plus columns |
On the command line the block is one JSON parameter:
mqb copy 'file:///data/export.csv?format=csv&csv={"separator":"auto"}' ….
Not read: UTF-16 files (Excel’s “Unicode Text”; re-save as UTF-8) and PostgreSQL’s COPY
text format, which escapes with backslashes and writes \N for null.
JSON lines (normal, json, text)
With format: json, a pretty-printed payload is written on one line (its line breaks are
insignificant JSON whitespace), so each message stays one line of the file. With a custom
delimiter, any occurrence of it inside a JSON string is written as a \uXXXX escape, which
decodes to the same value. A delimiter that would appear in JSON syntax itself, such as ,,
fails the message; 0x1e (record separator) never occurs in JSON and is a safe choice.
raw writes the payload untouched, so its delimiter must not occur in the payloads.
File (CSV / JSON / JSONL)
Schemes: file://
Query parameters recognised as config fields for this connector. The object-typed csv is set with a JSON literal, e.g. ?csv={...}. 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. |
csv | object | no | see below | CSV dialect (separator, quote, header, columns); only read with format: csv. |
delete | boolean | no | false | Remove acknowledged lines from the file. Only safe if no other process writes to it. |
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 | parquet | no | normal | The format for writing messages to the file (Publisher) or interpreting them (Consumer). Defaults to normal. |
fsync | off | batch | periodic | no | — | (Sink only) When appended data is fsynced: off (default), batch (before each ack) or periodic. |
fsync_interval_ms | integer | no | — | (Sink only) Interval for fsync: periodic in milliseconds. Defaults to 1000. |
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. |
Struct-typed fields
csv
CSV dialect for format: csv: field separator, quoting, header and nested values.
| Name | Type | Required | Default | Description |
|---|---|---|---|---|
columns | array of string | no | — | Column names. A source uses them instead of the header’s; a sink writes exactly these, in this order. |
header | boolean | no | — | Whether the first record names the columns. Defaults to true; a source without one needs columns. |
nested | flatten | json | no | flatten | (Sink only) Nested objects: flatten into parent.child columns (default) or json text in one cell. |
on_mismatch | warn | fail | no | — | (Sink only) A record whose keys differ from the columns: warn (default) writes it anyway, fail rejects it. |
quote | string | no | — | Quote character, or none for unquoted fields. Defaults to ". |
separator | string | no | — | Field separator: one character, tab, space, hex (0x1f) or auto (source: guessed from the first record). Defaults to ,. |
Directory spool
Stores each message as a payload file in a directory, with an optional JSON metadata sidecar. Use it as a durable FIFO hand-off between processes when you want a queue that can be inspected with ordinary filesystem tools and do not want to operate a broker.
Unlike the file connector and local object storage, which can frame many records in a file, the directory spool writes one opaque payload per file. A producer may exit while a consumer continues draining the backlog.
URL format
dir-spool:///absolute/path/to/spool?<option>=<value>
The aliases spool:// and dirspool:// are also accepted. The directory path
comes from the URI path, not a path query parameter.
Config (YAML / library)
The same settings as a route endpoint in a config file, or in Route.from_config /
fromConfig. Every URL query parameter is a field of the same name under dir_spool:.
output:
dir_spool: { path: "/var/spool/orders" }
The URL path becomes the path field.
Source, target, and on-disk layout
As a target (sink), the connector writes each incoming message as one chunk:
/var/spool/orders/
├── 000000000.bin # raw payload bytes for message 0
├── 000000000.json # message id and string metadata for message 0
├── 000000001.bin # raw payload bytes for message 1
├── 000000001.json # message id and string metadata for message 1
├── PRODUCER # producer claim file
├── CONSUMER # draining-consumer claim file, when one is running
└── DONE # optional completion sentinel
The default {seq:09} naming pattern produces the zero-padded names above.
payload_extension and metadata_extension change only the suffixes. A sidecar
has this shape:
{
"message_id": "0195f4a0c7d77801a2b3c4d5e6f78901",
"metadata": {
"content_type": "application/json",
"source": "orders"
}
}
The payload file contains exactly the message payload, byte for byte. It may be
UTF-8 text, JSON, an image, compressed data, or any other binary content. The
connector does not add a delimiter, envelope, or encoding. Set
metadata_extension to an empty string when sidecars are not needed.
With the default atomic=true, files are first written to sibling .tmp names.
The sidecar is finalized before the payload; the payload’s final rename makes
the chunk visible to readers without exposing incomplete data.
As a source, the connector scans for files with payload_extension, reads
each whole file as one message, and restores its message id and metadata from
the matching sidecar when present. A payload without a sidecar is valid and is
delivered with empty metadata, so another program can produce chunks without
knowing the sidecar format. Such a producer should likewise finalize the
payload last.
With sharding enabled, the leading sequence digits become directories. For
example, {seq:09}, shard_depth=2, and shard_width=3 store message 1 as:
/var/spool/orders/000/000/001.bin
/var/spool/orders/000/000/001.json
Both source and target must use the same sharding depths, widths, and file extensions.
Payload formats and batching
dir_spool does not parse or generate JSONL, CSV, or other record-oriented
formats. A .jsonl or .csv payload file is still one opaque message, even if
it contains many lines. Changing payload_extension changes file selection and
naming, not parsing behavior.
Use a record-oriented connector when records must be framed:
- The file connector reads or writes one named file, for example
file:///data/orders.jsonl?format=jsonorfile:///data/orders.csv?format=csv. - Local object storage watches a directory of immutable, multi-record files, making it a better fit for CSV/JSONL ETL drop zones.
- Sending either source to a directory spool creates one chunk per emitted message, rather than one chunk containing the whole input file.
Route-level batching is supported in both directions. A target can receive a batch of messages and writes one payload/sidecar pair for each message. A source can return up to the route’s batch-size limit per read. Batching therefore reduces routing and filesystem-sync overhead but does not change the one-file, one-message chunk format.
Examples
Write a finite input to a spool and mark it complete:
mqb copy --drain \
--from file:///data/orders.jsonl?format=json \
--to 'dir-spool:///var/spool/orders?emit_done=success'
Drain that spool into PostgreSQL, then exit:
mqb copy --drain \
--from 'dir-spool:///var/spool/orders?stop_on_done=true' \
--to 'postgres://user:pass@localhost/app?table=orders'
This one-shot example starts after the producer command has completed, so
--drain exits when the backlog is empty. In a continuously running route,
stop_on_done=true ends the source only when both the queue is empty and the
producer’s DONE sentinel exists. Set emit_done only on the last producer;
a producer opening the spool removes a stale sentinel before writing again.
Shard a high-volume spool:
mqb copy \
--from mqtt://broker.local:1883?topic=telemetry \
--to 'dir-spool:///var/spool/telemetry?naming_pattern={seq:012}&shard_depth=2&shard_width=3'
Configure the consumer with the same shard_depth, shard_width, payload
extension, and metadata extension. Sharding uses leading sequence digits as
subdirectories and avoids placing an unbounded number of files in one
directory.
Delivery and concurrency
Chunks are delivered in lexical filename order. Keep a zero-padded sequence at
the start of naming_pattern; the default {seq:09} is safe. The producer
writes temporary files and renames them into place, so the consumer does not
observe a partial chunk. With the default fsync=chunk, acknowledged writes
are also flushed to durable storage.
A draining consumer deletes a chunk only after its message is acknowledged. A
nack leaves the chunk on disk for redelivery. Set drain_on_read=false for a
non-destructive pass that leaves files in place.
By default, one producer and one draining consumer may use a spool at the same
time. Separate PRODUCER and CONSUMER lock files enforce those roles without
preventing the normal producer-consumer pair. Avoid disabling these claims:
multiple producers can collide, and multiple draining consumers can deliver
duplicates because claiming is in memory rather than an on-disk rename.
Key options
| Option | Purpose |
|---|---|
naming_pattern | Sink only: chunk name template. It must begin with a sequence; default {seq:09}. |
payload_extension / metadata_extension | File suffixes. Set metadata_extension to an empty string to omit sidecars. |
atomic | Sink only: write through a temporary file and rename; default true. |
fsync | chunk (default) for durable writes, or off for higher throughput with weaker crash guarantees. |
emit_done | Sink only: write the completion sentinel on success, on any end, or never (default). |
stop_on_done | Source only: exit after the sentinel is present and the backlog is empty. |
drain_on_read | Source only: delete acknowledged chunks; default true. |
shard_depth / shard_width | Spread chunks across sequence-derived subdirectories. Both ends must agree. |
claim | exclusive (default), warn, or off for same-role locking. |
Full field list: reference/dir-spool.md.
For detailed durability, sharding, lock, and completion-sentinel behavior, see the configuration guide.
Directory spool
Schemes: spool://, dir-spool://, dirspool://
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 |
|---|---|---|---|---|
atomic | boolean | no | true | (Sink only) Write to a .tmp name and rename into place, so a reader never sees a partial chunk. Defaults to true; turning it off trades that guarantee for one less rename per chunk. |
claim | exclusive | warn | off | no | exclusive | How this endpoint claims its side of the spool against a second instance in the same role. Defaults to exclusive. See [SpoolClaim]. |
consumer_file | string | no | CONSUMER | Name of the file holding the consumer lock, which keeps a second draining consumer out. Defaults to CONSUMER. Same rules as producer_file. |
done_file | string | no | DONE | Name of the producer-completion sentinel file. Defaults to DONE. Production can span several producers — they run one at a time, which producer_file enforces — so “a producer closed” is not “production finished”. This file is what says the latter, and only the last producer should write it. |
drain_on_read | boolean | no | true | (Source only) Delete each chunk’s files once its message is acknowledged. Defaults to true — this is what makes the directory a queue rather than a growing archive. With it off, chunks are left in place. Acknowledged chunks are emitted at most once per consumer run; nacked chunks are redelivered. |
emit_done | never | success | end | no | never | (Sink only) When to create done_file, marking production finished for a stop_on_done consumer. Defaults to never. See [SpoolDone]. Set it on the last producer only. A publisher opening the spool deletes an existing sentinel, since it is producing again. |
fsync | chunk | off | no | chunk | How hard to work at making a chunk survive a power loss. Defaults to chunk, which is two fsyncs per message with a sidecar. See [SpoolFsync]. |
metadata_extension | string | no | json | Extension of the JSON metadata sidecar, with or without the leading dot. Defaults to json. Set to an empty string to write and expect payload files only. |
naming_pattern | string | no | {seq:09} | (Sink only) Name template for each chunk, without extension. Supports {seq}, {seq:06} / {seq:06d} (zero-padded), {timestamp} (unix millis) and {message_id}. Defaults to {seq:09}. Lexical order must match sequence order, so keep a zero-padded {seq} first. |
path | string | yes | — | Directory holding the spool. Created if missing. |
payload_extension | string | no | bin | Extension of the payload file, with or without the leading dot. Defaults to bin. |
poll_interval_ms | integer | no | 100 | (Source only) Idle poll interval in milliseconds when the directory holds no new chunks. Defaults to 100. |
producer_file | string | no | PRODUCER | Name of the file holding the producer lock, which keeps a second producer out. Defaults to PRODUCER. Every instance sharing the directory must agree on this name — that is what makes them exclude each other — and it must not collide with the other control files or with a chunk name. See [SpoolClaim]. |
shard_depth | integer | no | 0 | How many levels of shard subdirectory to spread chunks over. Defaults to 0, which writes every chunk straight into path. A flat spool is one directory per stream, and most filesystems degrade long before mq-bridge does: 30fps with sidecars is over 200,000 files an hour. Sharding takes the leading digits of the sequence number as directory names, so {seq:09} with a depth of 2 and a width of 3 writes chunk 1 as 000/000/001.bin and gives every directory at most 1000 entries. Both ends must agree: a consumer only descends as far as its own shard_depth. It warns when a scan finds subdirectories it is not configured to enter, since the alternative is a spool that reads as permanently empty. |
shard_width | integer | no | 3 | How many characters of the sequence number each shard level consumes. Defaults to 3, giving 1000 entries per level. Ignored when shard_depth is 0. With sharding on, naming_pattern must start with a zero-padded {seq:0N} wider than shard_depth * shard_width, so that every chunk shards identically and one character is left for the file itself. |
source_metadata | boolean | no | false | (Source only) Include mqb.src.spool_* source positions in each message’s metadata. Defaults to false. |
stop_on_done | boolean | no | false | (Source only) End the stream once the directory holds no unread chunks and done_file is present. Defaults to false, which tails the directory indefinitely. Both halves matter: a producer that finished long ago still has its backlog drained first, and a spool that is merely empty keeps the stream open, because another producer may still be coming. |
Object storage (S3 / GCS / Azure / local)
The object_store endpoint reads and writes files in a bucket or a directory: Amazon S3 and
S3-compatible stores (MinIO, Cloudflare R2), Google Cloud Storage, Azure Blob Storage and a
local directory. The url picks the store; everything else on this page is the same for all
of them.
| Store | url | Environment variables for the usual setup |
|---|---|---|
| Amazon S3 | s3://bucket/prefix | AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY, AWS_REGION |
| MinIO, Cloudflare R2, other S3-compatible | s3://bucket/prefix | the same, plus AWS_ENDPOINT_URL |
| Google Cloud Storage | gs://bucket/prefix | GOOGLE_SERVICE_ACCOUNT |
| Azure Blob Storage / ADLS Gen2 | az://container/prefix | AZURE_STORAGE_ACCOUNT_NAME, AZURE_STORAGE_ACCOUNT_KEY |
| Local directory | file:///absolute/path (CLI: local-store:///absolute/path) | none |
Use it for files that hold many records each and are written once: exports, archives, data-lake tables, drop zones. As a sink it writes one file per batch; as a source it reads the files under the prefix in name order and remembers the last one it finished.
orders_to_s3:
input:
sqlx: { url: "postgres://user:pass@localhost/app", table: "orders", cursor_column: "id" }
output:
object_store:
url: "s3://my-bucket/orders"
format: parquet
export AWS_ACCESS_KEY_ID=AKIA... AWS_SECRET_ACCESS_KEY=... AWS_REGION=eu-central-1
mqb copy --drain \
--from 'postgres://user:pass@localhost/app?table=orders&cursor_column=id' \
--to 's3://my-bucket/orders?format=parquet'
Credentials
Credentials and store options are read from the environment of the mq-bridge process. The
endpoint has no fields for them, and they cannot be put into the url. This is the way the
cloud SDKs and command lines work, so a machine, container or CI job that is set up for
aws, gcloud or az usually needs nothing more.
Every option of the store can be set this way, not only the credentials. The rule for the variable name:
- take the option’s name, for example
region,endpointortimeout; - write it in upper or lower case, it does not matter;
- put the store’s prefix in front:
AWS_,GOOGLE_orAZURE_. The name without the prefix is read as well.
So AWS_REGION, aws_region and REGION all set the region of an S3 store. Besides the
credentials in the sections below, these are the options most often needed:
| Variable | Effect | Example |
|---|---|---|
AWS_ENDPOINT_URL | Another S3 endpoint (MinIO, R2, LocalStack) | http://localhost:9000 |
AWS_ALLOW_HTTP / AZURE_ALLOW_HTTP | Allow an endpoint without TLS | true |
AWS_VIRTUAL_HOSTED_STYLE_REQUEST | bucket.host instead of host/bucket | true |
AWS_SERVER_SIDE_ENCRYPTION, AWS_SSE_KMS_KEY_ID | Encrypt new objects with SSE-S3 or a KMS key | aws:kms |
AWS_REQUEST_PAYER | Read a requester-pays bucket | true |
AWS_SKIP_SIGNATURE | Read a public bucket without credentials | true |
TIMEOUT, CONNECT_TIMEOUT | Longest request, longest connect | 60s |
PROXY_URL | Send requests through an HTTP proxy | http://proxy.internal:3128 |
ALLOW_INVALID_CERTIFICATES | Do not verify the TLS certificate. For tests only | true |
What to know about it:
- The bucket or container always comes from the
url, never from a variable. - All stores of one kind share the variables. Every
s3://endpoint and everys3://checkpoint_storeof a process uses the same account, region and endpoint. Two S3 accounts, or AWS S3 and MinIO, in one process are not possible; run two processes. Different clouds (S3 and GCS, say) work side by side. - A short name can already be in use.
REGION,ENDPOINT,TOKEN,TIMEOUTorPROXY_URLset for another program are read as store options too. When the short and the prefixed name are both set, to different values, it is not defined which one is used. If a store behaves oddly, look for such a variable. - A variable that is not an option is ignored, and so is an option of another store.
- The bucket or container must exist. mq-bridge does not create it.
Amazon S3
s3://<bucket>/<prefix>
s3a:// is accepted as well.
| Variable | Purpose |
|---|---|
AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY | Access key. |
AWS_SESSION_TOKEN | Needed with temporary credentials (SSO, an assumed role). |
AWS_REGION | Region of the bucket. Defaults to us-east-1, so set it for every other region. |
AWS_WEB_IDENTITY_TOKEN_FILE, AWS_ROLE_ARN | IAM role for a Kubernetes service account (IRSA). EKS sets both. |
Without an access key the endpoint uses, in this order: the web identity token, the ECS task
role, EKS Pod Identity, the EC2 instance profile. On EC2, ECS and EKS nothing has to be set
besides AWS_REGION.
~/.aws/credentials, ~/.aws/config and AWS_PROFILE are not read. To use a profile or an
SSO login, export it into the environment first:
eval "$(aws configure export-credentials --profile my-profile --format env)"
export AWS_REGION=eu-central-1
The IAM permissions needed are s3:PutObject for a sink, and s3:ListBucket and s3:GetObject
for a source. A sink that names objects by source position (see Writing) lists
the prefix as well.
Server-side encryption with a KMS key is set with AWS_SERVER_SIDE_ENCRYPTION=aws:kms and
AWS_SSE_KMS_KEY_ID.
S3-compatible stores (MinIO, Cloudflare R2)
The URL stays s3://bucket/prefix; AWS_ENDPOINT_URL points it at the other server.
MinIO (or LocalStack) on plain HTTP:
export AWS_ENDPOINT_URL=http://localhost:9000
export AWS_ALLOW_HTTP=true
export AWS_ACCESS_KEY_ID=minioadmin AWS_SECRET_ACCESS_KEY=minioadmin
export AWS_REGION=us-east-1
mqb copy --drain --from 'file://orders.csv?format=csv' --to 's3://lake/orders?format=parquet'
AWS_ALLOW_HTTP=true is required for an http:// endpoint. Requests are path-style
(http://host/bucket/key), which is what MinIO expects; set
AWS_VIRTUAL_HOSTED_STYLE_REQUEST=true for a server that wants the bucket in the host name.
Cloudflare R2:
export AWS_ENDPOINT_URL=https://<account-id>.r2.cloudflarestorage.com
export AWS_REGION=auto
export AWS_ACCESS_KEY_ID=... AWS_SECRET_ACCESS_KEY=...
Google Cloud Storage
gs://<bucket>/<prefix>
The CLI also accepts gcs:// and rewrites it to gs://.
| Variable | Purpose |
|---|---|
GOOGLE_SERVICE_ACCOUNT | Path to a service-account key file (JSON). |
GOOGLE_SERVICE_ACCOUNT_KEY | The key file’s JSON content itself, for when there is no file to mount. |
GOOGLE_APPLICATION_CREDENTIALS | Path to an application-default-credentials file. |
Without any of them the endpoint uses the file that gcloud auth application-default login
writes, then the metadata server. On GCE, GKE (with Workload Identity) and Cloud Run nothing
has to be set.
export GOOGLE_SERVICE_ACCOUNT=/secrets/gcs-writer.json
mqb copy --drain \
--from 'postgres://user:pass@localhost/app?table=orders&cursor_column=id' \
--to 'gs://my-bucket/orders?format=parquet'
The service account needs roles/storage.objectCreator on the bucket for a sink and
roles/storage.objectViewer for a source. A sink that names objects by source position lists
the prefix and needs both.
Azure Blob Storage
az://<container>/<prefix>
The first part of the URL is the container. The storage account is not part of this URL; it
comes from AZURE_STORAGE_ACCOUNT_NAME. azure://, abfs:// and abfss:// work the same way,
and all of them use the Blob endpoint, also on an account with hierarchical namespace
(ADLS Gen2).
| Variable | Purpose |
|---|---|
AZURE_STORAGE_ACCOUNT_NAME | Storage account. Required with the az://container/… form. |
AZURE_STORAGE_ACCOUNT_KEY | Account access key. |
AZURE_STORAGE_SAS_TOKEN | A SAS token instead of the key. |
AZURE_CLIENT_ID, AZURE_CLIENT_SECRET, AZURE_TENANT_ID | Service principal. |
AZURE_CLIENT_ID, AZURE_TENANT_ID, AZURE_FEDERATED_TOKEN_FILE | Workload identity on AKS, which sets all three. |
AZURE_USE_AZURE_CLI=true | Use the login of az login. For a developer machine. |
Without any credential the endpoint uses the managed identity of the VM, App Service or container it runs on. A service principal or identity needs the role Storage Blob Data Contributor (sink) or Storage Blob Data Reader (source) on the container.
export AZURE_STORAGE_ACCOUNT_NAME=mystorageaccount
export AZURE_STORAGE_ACCOUNT_KEY=...
mqb copy --drain \
--from 'postgres://user:pass@localhost/app?table=orders&cursor_column=id' \
--to 'az://lake/orders?format=parquet'
In a config file the account can be named in the URL instead, in the form Spark and Databricks use:
output:
object_store:
url: "abfss://lake@mystorageaccount.dfs.core.windows.net/orders"
format: parquet
For the Azurite emulator set AZURE_STORAGE_USE_EMULATOR=true.
Local directory
In a config file, use file://:
load_orders:
input:
object_store:
url: "file:///var/lib/mqb/incoming"
format: csv
cursor_id: "orders-import"
checkpoint_store: "file:///var/lib/mqb/checkpoints/orders.json"
polling_interval_ms: 1000
output:
sqlx:
url: "postgres://localhost/app"
table: orders
The CLI uses local-store:// to distinguish this endpoint from the single-file connector:
mqb copy \
--from 'local-store:///var/lib/mqb/incoming?format=csv&cursor_id=orders-import&checkpoint_store=file:///var/lib/mqb/checkpoints/orders.json' \
--to 'nats://localhost:4222/orders'
| Connector | Source model | Acknowledgement behavior | Best fit |
|---|---|---|---|
file | One named file | Reads or tails that file | A fixed CSV/JSONL file or append-only log |
dir_spool | One opaque message per chunk file | Acknowledged chunks are deleted by default; nacked chunks are redelivered | A durable filesystem queue between producers and consumers |
local object_store | Many immutable, multi-record files | A checkpoint records the last fully acknowledged path; inputs remain in place | ETL drop zones and replayable local archives |
Writing (sink)
Every batch the route delivers becomes one object. An object is written once and never
appended to or replaced, so the route’s batch_size decides how large the files get: raise it
for an analytics table, where many small files are slow to query.
The object key depends on name_by:
write_time:<prefix>/2026/10/12/<uuidv7>.<ext>. The date is the write time in UTC.date_partition: falseleaves the date folders out, anddate_partition_style: hivewrites them asyear=2026/month=10/day=12/, which Athena, Spark, BigQuery and DuckDB read as partition columns.source_position: the name carries the range of source records the object holds, and the objects lie flat under the prefix. When a run is repeated or resumed after a crash, the sink finds the ranges that are already there and does not write them a second time. This needs an input with a replay position: Kafka, Postgres CDC, an SQL cursor, a MongoDB change stream.auto(default):source_positionwhen the input has a replay position, otherwisewrite_time. With a middleware that drops records (filter,deduplication) it iswrite_timeas well; see object naming under a filter.
The extension follows format, compression and encryption: jsonl, csv, parquet,
jsonl.gz, jsonl.zst.enc. Set extension to override it.
A csv sink gives every file its own header, taken from that batch’s first row, so set
csv.columns when all files must share the same columns.
Reading (source)
New objects are discovered by polling (polling_interval_ms, default 1000). Objects are
processed in lexicographic key order, and the checkpoint advances once every record of an
object has been acknowledged. Objects are not deleted or changed.
- Set
cursor_idandcheckpoint_storeto resume after a restart. Without them every start reads all objects again. - Keep
checkpoint_storeoutside the prefix that is read, otherwise the cursor object would be read as data. For a bucket this means another bucket or a prefix next to it, such ass3://my-bucket/cursorsfor a source ons3://my-bucket/orders. - Name externally produced files with a sortable prefix, such as a timestamp or UUIDv7: an
object added later with a key before the saved checkpoint is not read. Objects written by an
object_storesink sort correctly. - With
exit_on_empty(mqb copy --drain) the run ends when the listing has nothing left after the last object.
An object without records is passed over.
An object that cannot be decoded (damaged, or written with another compression or
encryption than configured) is tried five times. Then:
- If the source has not decoded any object since it started, the route stops with an error that names the settings to check. The checkpoint does not move. If that object really is damaged, remove it.
- Otherwise the object is skipped with a warning and the checkpoint moves past it.
- With
on_decode_error: failthe route stops at every such object and nothing is skipped.
Formats and batching
format applies to every object under the configured prefix; formats are not detected
from file extensions. Use separate prefixes and routes for mixed formats.
csvtreats the first row as headers and emits each following row as a JSON object. Thecsvblock (separator,quote,header,columns) reads other dialects, exactly as for the file connector;separator: autois settled on the first object read.parquetreads every row of a Parquet file as one JSON object, with the file’s column types kept (numbers stay numbers). It reads what theobject_storesink writes and files from other tools, on S3, GCS, Azure and local directories alike, and needs theparquetbuild feature, whichmqbincludes. A Parquet file is decoded whole in memory, somax_object_bytesmatters more here than for the line formats.normalandjsonread mq-bridge message wrappers, one per line, and preserve metadata.- For ordinary third-party JSONL, use
raw: each line becomes one message payload without validating or transforming its JSON. textandrawemit delimited byte records without JSON or CSV parsing. A JSON array is not expanded into records.
One input object can produce many messages, and route batching still applies. The source
fetches each object into memory before splitting it, so set max_object_bytes for untrusted or
large drop zones.
compression (gzip, lz4, zstd) and encryption apply to the whole object and must be
the same on the sink and on the source that reads it back; see
Compression and Encryption at rest.
Troubleshooting
| Symptom | Cause |
|---|---|
The route waits for some seconds, then fails with a metadata or 169.254.169.254 error | No credentials were found in the environment, so the instance metadata service was tried. Check the variable names; on AWS, remember that profiles are not read. |
| S3 rejects the request although the key is valid, or answers with a redirect | AWS_REGION is not the bucket’s region. |
Account must be specified (Azure) | AZURE_STORAGE_ACCOUNT_NAME is not set. |
| Azure writes to a container named like the storage account | The URL was written as az://account/container/…. The first part is the container. |
An http:// endpoint is refused | Set AWS_ALLOW_HTTP=true. |
| A source finds nothing new | The new objects sort before the saved checkpoint, or the prefix differs from the one written to. |
See also
- Every option: the generated object-store reference.
- Warehouses that read these files: DuckDB, Snowflake, BigQuery, Databricks / Spark, Athena / Trino, ClickHouse.
- Checkpoints & resumable copies and Deduplication for repeatable runs.
Object Storage (local / cloud)
Schemes: s3://, s3a://, gs://, gcs://, az://, azure://, abfs://, abfss://, local-store://
Query parameters recognised as config fields for this connector. The object-typed csv is set with a JSON literal, e.g. ?csv={...}. 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 |
|---|---|---|---|---|
checkpoint_store | string | no | — | (Source only) Durable resume store URL recording the last processed object key, e.g. file:///var/lib/mqb/obj.json, s3://bucket/cursors, or postgres://…. Without it every restart re-lists and re-emits all objects. |
compression | none | gzip | lz4 | zstd | no | none | Whole-object compression (none, gzip, lz4, zstd). Requires the compression feature. |
csv | object | no | see below | CSV dialect (separator, quote, header, columns); only read with format: csv. |
cursor_id | string | no | — | (Source only) Cursor id namespacing the checkpoint key; enables durable resume. |
date_partition | boolean | no | null | (Sink only) Prepend a YYYY/MM/DD/ path (write time, UTC) to each object key. Applies to write_time naming only; defaults to on. Purely for readability / lifecycle rules. |
date_partition_style | nested | hive | no | nested | (Sink only) Date folder layout: nested (YYYY/MM/DD/) or hive (year=YYYY/month=MM/day=DD/). |
delimiter | string | no | — | Record delimiter within an object. Defaults to newline (“\n”). Can be a string or a hex sequence (e.g. “0x00”). |
encryption | object | no | null | At-rest AEAD encryption applied after compression. Requires the encryption feature. |
extension | string | no | — | (Sink only) Extension for written objects, without the dot. Defaults to a value derived from format, compression and encryption (e.g. jsonl, csv, bin, jsonl.gz, jsonl.lz4, jsonl.gz.enc); encrypted objects get a trailing .enc since they are ciphertext, not a directly decompressible .gz. |
format | normal | json | text | raw | csv | parquet | no | normal | Record encoding within an object, shared with the file endpoint. Defaults to normal (one JSON CanonicalMessage per line). Parquet needs the parquet feature. |
idempotency | boolean | no | — | Deprecated: use name_by. true = source_position, false = write_time; ignored when name_by is set. |
max_object_bytes | integer | no | — | (Source only) Maximum size in bytes of a single object to fetch into memory. An object larger than this fails the read (surfaced as a consumer error) instead of being buffered whole. Unset means no limit (the whole object is materialized). |
name_by | auto | source_position | write_time | no | auto | (Sink only) auto, write_time (uuidv7 name) or source_position (name carries the source range). |
on_decode_error | skip | fail | no | skip | (Source only) An object that cannot be decoded: skip it (default) or fail the route. skip also stops the route while no object has been decoded yet. |
polling_interval_ms | integer | no | — | (Source only) Idle poll interval in milliseconds when no new objects are found. Defaults to 1000. |
url | string | yes | — | Object-store URL, e.g. file:///var/lib/mqb/incoming, s3://bucket/prefix, gs://bucket/prefix, or az://container/prefix. Credentials are resolved from the environment by the object_store crate (same mechanism as the checkpoint backend); Azure takes the account from AZURE_STORAGE_ACCOUNT_NAME, R2 uses s3:// plus a custom AWS_ENDPOINT_URL. |
Struct-typed fields
csv
CSV dialect for format: csv: field separator, quoting, header and nested values.
| Name | Type | Required | Default | Description |
|---|---|---|---|---|
columns | array of string | no | — | Column names. A source uses them instead of the header’s; a sink writes exactly these, in this order. |
header | boolean | no | — | Whether the first record names the columns. Defaults to true; a source without one needs columns. |
nested | flatten | json | no | flatten | (Sink only) Nested objects: flatten into parent.child columns (default) or json text in one cell. |
on_mismatch | warn | fail | no | — | (Sink only) A record whose keys differ from the columns: warn (default) writes it anyway, fail rejects it. |
quote | string | no | — | Quote character, or none for unquoted fields. Defaults to ". |
separator | string | no | — | Field separator: one character, tab, space, hex (0x1f) or auto (source: guessed from the first record). Defaults to ,. |
Apache Pulsar
Pulsar is an input and an output. The endpoint lives in its own repository,
mq-bridge-pulsar, so the core library carries no
Pulsar dependency. mqb has it built in; the library and the bindings load it as a plugin.
Configure
pulsar_to_file:
input:
custom:
name: pulsar
config:
url: "pulsar://localhost:6650"
topic: "persistent://public/default/orders"
subscription: "order-workers"
initial_position: earliest
output:
file: { path: "orders.jsonl" }
| Field | Applies to | Default | Meaning |
|---|---|---|---|
url | both | required | Broker URL, pulsar://host:6650. |
topic | both | route name | Topic to read or write. |
subscription | input | mq-bridge-<route> | Subscription name. Consumers use a shared subscription. |
initial_position | input | latest | Where a new subscription starts: latest or earliest. |
From a URL:
mqb copy 'pulsar://localhost:6650?topic=orders&subscription=export&initial_position=earliest' \
'file:///tmp/orders.jsonl'
Behaviour to know
initial_positionapplies only when the subscription is created. Withlatest, a topic’s existing backlog is never delivered. An existing subscription resumes from its own cursor, so use a newsubscriptionname to read a topic again from the start.- At-least-once. A message is acknowledged, or negatively acknowledged, only after the route has processed its batch.
- Publishing enqueues the whole batch, flushes the producer once and waits for every receipt.
Outside mqb
brew install marcomq/tap/mq-bridge-pulsar
conda install -c marcomq mq-bridge-pulsar
pip install mq-bridge mq-bridge-pulsar # then: mq_bridge_pulsar.register()
npm install mq-bridge mq-bridge-pulsar # then: import { register } from "mq-bridge-pulsar"
From Rust, add the mq-bridge-pulsar crate and call mq_bridge_pulsar::register()? before any
route starts; building it needs protoc on PATH. Do not pass the library to mqb --plugin:
the name is already registered there, see Native plugins.
Meilisearch
Meilisearch is an output (a document sink for a search index) and an input (a scan of an index’s
documents). From mqb 0.4.18 the built-in meilisearch endpoint is the generic
http_bulk endpoint with the Meilisearch requests filled in. It writes, deletes,
waits for the indexing task and reads an index back.
Some features are only in the separate mq-bridge-meilisearch plugin, which can replace the built-in endpoint; see Built in or plugin. This page marks them with “plugin only”.
On this page:
- Keep an index in sync with a Postgres table
- Make a bulk load fast
- Documents with embeddings (pgvector, Supabase)
- One document from several tables
- One-off copy or rebuild, including a rebuild without downtime
- Built in or plugin
- Options and Limitations
Keep an index in sync with a Postgres table
This is the pipeline a CDC-to-search tool such as Sequin runs: read the rows that already exist, then follow every insert, update and delete. Here it is one route.
movies_to_search:
batch_size: 10000 # see "Make a bulk load fast" below
input:
postgres_cdc:
url: "postgres://user:pass@localhost/app"
publication: "movies_pub"
slot_name: "mqb_meili"
consume: capture_all # backfill first, then stream changes
cursor_id: "movies_backfill"
checkpoint_store: "file:///var/lib/mq-bridge/movies-phase.json"
output:
middlewares:
- retry: { max_attempts: 5 }
meilisearch:
url: "http://localhost:7700"
api_key: "${MEILI_MASTER_KEY}"
index: "movies"
primary_key: "id"
operation: "${metadata:postgres.operation}"
Apply the index settings once before the first run; see Apply index settings before the first document.
| A Sequin Meilisearch sink has | Here |
|---|---|
| Endpoint URL, API key | url, api_key |
| Index name | index; a template such as ${metadata:postgres.table} is plugin only |
Primary key (default id) | primary_key; no default, so set it |
| Index action (create or update a document) | Any change that is not a delete replaces the document; method with update or update_existing is plugin only |
| Delete action | operation mapped to postgres.operation; delete_values lists what counts as a delete |
| Function action (Meilisearch function-based edits) | Not supported. Compute the value in a transform before the sink |
| Transform function | transform middleware; the primary key must stay a top-level field |
| Routing function | A switch output, or a templated index (plugin only) |
| Backfill | consume: capture_all on postgres_cdc |
The left column is taken from Sequin’s sink reference. Verify it against the current Sequin documentation before relying on a one-to-one migration.
What to know before running it:
- Give each table its own publication and route. Backfilled rows carry neither
postgres.operationnorpostgres.table. A missing operation is an upsert, which is right, but a templatedindex(plugin only) cannot resolve and the row is dead-lettered. - Delivery is at-least-once. A row changed during the backfill is read twice. Both writes
carry the same
primary_key, so the later one wins. cursor_idandcheckpoint_storemake the backfill resumable. Write the store asfile:///…; a plain path is read as a table name in the source database.capture_allneeds a single-column primary key. For a composite key, build one id field with atransformexpression and backfill through asequenceinput instead.- Writes are confirmed. Meilisearch answers
202 Acceptedand indexes later, so the sink waits for the task before the replication slot advances. - Order is kept at any
concurrency. Two writes to the same document reach the index in source order. - A
truncateis dead-lettered, since it carries no row. - The slot is permanent. It retains WAL on the server until the route consumes it. When you
retire the route, drop it:
SELECT pg_drop_replication_slot('mqb_meili').
Make a bulk load fast
Almost all the time of a bulk load is spent inside Meilisearch, not in the bridge: reading 200,000 rows from Postgres and sending them takes under 2 seconds, and indexing them takes the rest. So a load gets faster by giving Meilisearch less work, in three ways. In order of effect:
1. Apply index settings before the first document
By default Meilisearch makes every field searchable. Its
indexing guide
says to list only the fields that are searched in searchableAttributes, to keep numbers and
booleans in filterableAttributes or sortableAttributes only, and to do this before loading,
because changing any of these later reindexes every document. The same holds for embedders.
The built-in endpoint does not manage settings, so apply them once before the first run:
curl -X POST "$MEILI/indexes" -H "Authorization: Bearer $KEY" -H 'Content-Type: application/json' \
-d '{"uid": "movies", "primaryKey": "id"}'
curl -X PATCH "$MEILI/indexes/movies/settings" -H "Authorization: Bearer $KEY" -H 'Content-Type: application/json' \
-d '{"searchableAttributes": ["title", "overview"], "filterableAttributes": ["genre", "year"]}'
Plugin only: settings on the endpoint holds the same object and is applied when the index is
created, so a route in a config file needs no curl.
2. Use a large batch
Meilisearch indexes in batches, and each indexing batch has a fixed cost that grows with the
index: around 2 seconds per batch once an index holds 100,000 documents, whether the batch
carries 1,000 documents or 30,000. The sink waits for each batch’s task before it sends the next
one, which is what keeps writes in source order and makes an acknowledgement mean “searchable”.
So the number of documents per batch, the route’s batch_size, sets the throughput.
Copying 200,000 rows (six short columns) from Postgres 18 into Meilisearch 1.53 on one laptop:
batch_size | Time until searchable |
|---|---|
1,024 (the mqb copy default) | 162 s |
| 10,000 | 34 s |
| 30,000 | 17 s |
| 50,000 | 13 s |
For comparison, Filament 0.13.1 (which sends 10,000 documents per request and waits once at the end of the run) took 13 s on the same table. Meilisearch’s own guide recommends 50,000 to 100,000 documents per request for a large dataset, and fewer when documents exceed 10 KB.
The cost of a larger batch is memory, because a whole batch is held until Meilisearch confirms it. Rows with a 1,024-dimension vector need about 46 KB each in flight, so a 10,000-row batch of them takes about 500 MB. Size the batch for your rows:
- Short text rows: 30,000 to 50,000.
- Rows with embeddings or long text: 5,000 to 10,000.
From mqb 0.4.18, mqb copy into Meilisearch uses 50,000 when no --batch-size is given, so short rows need no flag. Pass a smaller --batch-size for rows with
embeddings or long text. A route in a config file keeps its own batch_size.
A request larger than max_request_bytes is split automatically, so a large batch does not fail
with payload_too_large; only a single document over the limit does. The built-in endpoint waits
for each part’s task before it sends the next. The plugin queues all parts first, so Meilisearch
indexes them together.
A live CDC stream sends whatever has changed, usually far less than a batch, so batch_size
matters there for the backfill only.
3. Send embeddings as vectors, not as a field
An embedding left in an ordinary field is indexed as a thousand separate numbers per document.
That is slow and cannot be searched by similarity. Put it under _vectors instead, as the next
section shows.
What does not help
concurrency. The sink sends one batch at a time to keep source order, at any setting.compression: gzip. It shrinks the request, which helps on a slow link to a remote instance. Indexing time is unchanged.wait_for_task: false(plugin only). It returns sooner but acknowledges documents that may still fail to index, and the source position advances past them.- Server tuning (
--max-indexing-memory,--max-indexing-threads) helps any client the same way; see Meilisearch’s guide.
Documents with embeddings
Meilisearch takes ready-made embeddings under the reserved _vectors field, for an embedder
declared as userProvided in the index settings. A pgvector column arrives from Postgres as the
text [0.1,0.2,…], in the backfill and in the change stream alike, so the route has two things to
do: move the column to _vectors.<embedder> and decode the text into an array. One transform
does both.
articles_to_search:
batch_size: 5000 # embeddings are large; see the sizes above
input:
postgres_cdc:
url: "postgres://user:pass@localhost/app"
publication: "articles_pub"
slot_name: "mqb_articles"
consume: capture_all
cursor_id: "articles_backfill"
checkpoint_store: "file:///var/lib/mq-bridge/articles-phase.json"
output:
middlewares:
- transform:
mapping: # only the fields listed here reach the index
id: "$.id"
title: "$.title"
body: "$.body"
category: "$.category"
"_vectors.default": "$.embedding"
schema:
type: object
properties:
_vectors:
type: object
properties:
default:
type: ["string", "null"] # null: see below
contentMediaType: application/json # decode "[0.1,…]" into an array
- retry: { max_attempts: 5 }
- dlq:
endpoint: { file: { path: "articles-rejected.jsonl" } }
meilisearch:
url: "http://localhost:7700"
api_key: "${MEILI_MASTER_KEY}"
index: "articles"
primary_key: "id"
operation: "${metadata:postgres.operation}"
Declare the embedder before the first run (with the plugin, the same object goes under
settings):
curl -X PATCH "$MEILI/indexes/articles/settings" -H "Authorization: Bearer $KEY" -H 'Content-Type: application/json' \
-d '{"searchableAttributes": ["title", "body"], "filterableAttributes": ["category"],
"embedders": {"default": {"source": "userProvided", "dimensions": 1024}}}'
dimensionsmust equal the column’s size (vector(1024)here), and the embedder’s name,default, must be the key under_vectors.- Keep
"null"in the type. A delete carries only the key, with every other column null, and a row may have no embedding yet. Without"null"the transform rejects both, so the delete never reaches the index. A null under_vectorstells Meilisearch the document has no embedding. - Keep the
dlq. Without one, a message the transform rejects is logged and dropped. See Dead-letter queues. - Leave the embedding out of
searchableAttributes. Listing the searched fields, as above, already does that.
Check the result: numberOfEmbeddings should equal numberOfDocuments, and a vector search
should return hits.
curl "$MEILI/indexes/articles/stats" -H "Authorization: Bearer $KEY"
curl "$MEILI/indexes/articles/search" -H "Authorization: Bearer $KEY" -H 'Content-Type: application/json' \
-d '{"vector": [0.1, 0.2, …], "hybrid": {"embedder": "default", "semanticRatio": 1}, "limit": 3}'
This recipe was run with the plugin and its settings against Postgres 18 with pgvector 0.8 and
Meilisearch 1.53: backfill, then live inserts, updates, deletes and rows with a null embedding.
On Supabase
Supabase is Postgres with pgvector, so the route above applies unchanged. Three things differ from a local Postgres and otherwise look like bugs:
- Use the direct connection (
db.<ref>.supabase.co:5432), not the pooler. A pooled connection cannot start replication. - The direct connection is IPv6-only unless the project has the IPv4 add-on. Without an IPv6
route the connection times out; check with
ping6first. - Create your own publication (
CREATE PUBLICATION articles_pub FOR TABLE articles). Do not stream fromsupabase_realtime.wal_levelis alreadylogical.
These three points come from Supabase’s documentation and were not run against a hosted project.
One document from several tables
Meilisearch has no joins, so a search document that shows a book with its author’s name has to be assembled before it is indexed. Two tools do that:
-
A foreign key is joined with
lookup. The middleware reads the referenced row and writes it into the document, one query per batch:output: middlewares: - lookup: from: sqlx: url: "postgres://user:pass@localhost/app" table: "authors" lookup_query: "SELECT id, name FROM authors WHERE id IN (${payload:author_id}::int)" into: author meilisearch: { url: "http://localhost:7700", index: "books", primary_key: "id" } -
A table sharing the document’s key is merged with
method: update(plugin only), from a second route writing the same index. Usemethod: update_existingon that route so a late change cannot recreate a deleted document as a stub.
The plugin README has a complete example with both, and the rules for deletes across routes.
Keeping joined fields current
A joined field is copied at the time the book is written. Renaming the author afterwards leaves the old name in every one of their books, because no book row changed. To close this gap, make Postgres report those books as changed. An update that sets a column to its own value is enough: it is written to the change stream like any other, and the route looks the author up again.
CREATE FUNCTION touch_books_of_author() RETURNS trigger AS $$
BEGIN
UPDATE books SET author_id = author_id WHERE author_id = NEW.id;
RETURN NEW;
END $$ LANGUAGE plpgsql;
CREATE TRIGGER authors_reindex_books
AFTER UPDATE OF name ON authors
FOR EACH ROW EXECUTE FUNCTION touch_books_of_author();
Plugin only, from 0.1.3: the sink can do this without touching Postgres: a second route on authors
with update_where: "author.id = ${payload:id}" and update_into: author writes each changed
author into every book that embeds it. It needs author.id in filterableAttributes and
Meilisearch 1.14 or newer; the
plugin README
has the route and its limits. It was not run for this page.
The trigger costs one book write per affected book, in the same transaction as the author change. That a same-value update travels through the route into the index was checked on the setup above; the trigger itself is standard Postgres and was not benchmarked.
Options
| Field | Applies to | Default | Meaning |
|---|---|---|---|
url | both | required | Base URL. meilisearch:// and meilisearchs:// become http(s)://. |
api_key | both | none | Sent as Authorization: Bearer. |
index | both | route name | Index UID. |
primary_key | both | none | The field Meilisearch keys documents by; a delete takes its id from it. |
operation | output | none | Each message’s change operation. |
delete_values | output | ["delete", "d"] | Operation values that remove the document. |
max_request_bytes | output | 90000000 | Split a batch rather than exceed this body size. |
compression | output | none | gzip request bodies. zstd and lz4 are accepted here, but Meilisearch 1.53 answers them with a “payload is malformed” 400. |
connect_timeout_ms | both | 10000 | How long to wait for a connection. |
request_timeout_ms | both | see right | Upper bound for one HTTP request; 0: none. Unset: a request fails after 300 s without a response byte. |
tls | both | none | TLS options as on http_bulk: ca_file, cert_file + key_file, required, accept_invalid_certs. JSON text in a URI. Built-in endpoint only. |
fields | input | all | Comma-separated document fields to read. |
cursor_id, checkpoint_store | input | none | Persist the read position (a file:// store). |
polling_interval_ms | input | 1000 | Wait after an empty page. |
max_polling_interval_ms | input | none | The wait doubles after each empty page up to this. |
Left unset, primary_key is inferred by Meilisearch from the first batch, possibly wrongly and
permanently.
The wait for a task has no upper bound. A slow task logs a warning every minute and is never sent again, and a failed status request is repeated. The batch fails only when Meilisearch reports the task as failed or canceled, so a route stalls with warnings while Meilisearch is down.
Reading pages through the index by offset, which is only stable while nothing is deleted during the read.
Built in or plugin
The built-in endpoint refuses the plugin’s options at startup and names the plugin, instead of ignoring them. These are plugin only:
| Option | What it does |
|---|---|
settings | Applies index settings when the index is created. |
method | update merges top-level fields, update_existing merges only into an existing document. The built-in endpoint always replaces. |
A template in index | Routes each message to the index its metadata names. |
update_where, update_into | Writes a message into every document it matches. |
swap_into | Swaps a rebuilt index into the live one after a complete copy. Not released yet. |
create_index | Creates the index at startup. The built-in endpoint lets the first write create it. |
wait_for_task, task_timeout_ms | Acknowledge on enqueue, and when a slow task is reported. |
To use the plugin in mqb, install it and prefer it over the built-in endpoint:
brew install marcomq/tap/mq-bridge-meilisearch # or: conda install -c marcomq mq-bridge-meilisearch
MQB_PLUGIN_OVERRIDE=meilisearch mqb copy … 'meilisearch://localhost:7700?index=movies'
--plugin does not work for this, see Native plugins. mqb up to
0.4.17 had plugin 0.1.2 compiled in; the cargo feature meilisearch that did the same in
later builds is gone in 1.0.
One-off copy or rebuild
mqb copy --drain --batch-size 30000 \
'postgres://user:pass@localhost/app?table=movies&cursor_column=id' \
'meilisearch://localhost:7700?index=movies&primary_key=id&api_key=KEY'
Pick --batch-size as described in Use a large batch, and apply the index
settings first as shown in
Apply index settings before the first document.
Without operation every message is an upsert, which is all a copy needs. A copy sends rows as
they are, so a table with embeddings needs the route from
Documents with embeddings instead.
Rebuild without downtime
After changing a transform or the index settings, build the new version under another name and
swap it in. Searches keep hitting the old index until the swap, which is atomic.
-
Stop the streaming route. Its replication slot keeps the changes that arrive meanwhile.
-
Create
movies_v2with its settings, using the twocurlcalls from above. -
Load it with the copy command above and
index=movies_v2. -
Swap, then delete the leftover, which now holds the old documents:
curl -X POST "$MEILI/swap-indexes" -H "Authorization: Bearer $KEY" -H 'Content-Type: application/json' \ -d '[{"indexes": ["movies", "movies_v2"]}]' curl -X DELETE "$MEILI/indexes/movies_v2" -H "Authorization: Bearer $KEY" -
Start the streaming route again. It replays the changes from step 1 into the rebuilt index.
Plugin only, in a version not released yet: the sink does step 4 itself. Add
swap_into=movies to the copy in step 3. After a complete copy it swaps movies_v2 into
movies and deletes the leftover. It leaves movies untouched, with a warning in the log, when
the copy was stopped or failed, when any document was refused, or when nothing was written. A
swap that fails is logged as an error but does not change the exit code of mqb copy, so check
the log for “is now live”. swap_into cannot be combined with wait_for_task: false or
update_where. It was not run for this page.
The plugin README also covers composite primary keys and reading an index back out.
Outside mqb
From Rust, the built-in endpoint is part of the http-bulk feature. A route file names it as
above; in code, mq_bridge::endpoints::http_bulk::MeilisearchConfig holds the same fields and
converts into an Endpoint with .into().
The plugin is a separate package for the library and the bindings:
pip install mq-bridge mq-bridge-meilisearch # then: mq_bridge_meilisearch.register()
npm install mq-bridge mq-bridge-meilisearch # then: import { register } from "mq-bridge-meilisearch"
From Rust, add the mq-bridge-meilisearch crate and call mq_bridge_meilisearch::register()?.
The plugin needs mq-bridge 0.4.13 or newer. Register one of the two, not both: the name
meilisearch can be taken once.
Limitations
- The built-in endpoint leaves out what Built in or plugin lists.
update(plugin only) merges top-level fields only; a list replaces the whole field.- A field joined with
lookupgoes stale when the referenced row changes later, unless a trigger reports the change; see Keeping joined fields current. - With plugin 0.1.2, built into
mqb0.4.17, a task that outlivestask_timeout_msis sent again. Set it well above the slowest task inGET /tasks, or upgrade. - There is no replay log: recovering from a bad transform means reading the source again.
HTTP bulk (search engines)
Writes JSON documents in bulk to an HTTP API, and reads them back page by page: one request per batch instead of one per message. It is meant for search engines and similar document stores. The endpoint knows no product by name. As an output you describe three things, and a new target is a few lines of configuration:
- upsert: the request that writes documents, and the body it carries;
- delete: the request that removes documents by id;
- result: how the target reports what happened.
As an input you describe one: read, the request that lists a page of
documents and where the next page starts. See Reading. With
read.stream it reads one response that stays open instead: server-sent events
or NDJSON lines. See An open stream.
For a lookup you describe query: the request that
answers a whole batch of lookups at once. See Lookups.
It needs the http-bulk feature, which full includes.
A complete example
A route that copies a PostgreSQL table to a REST API, 2,000 rows per request. The API in this
example takes POST /v1/products/batch with the body {"items": [ … ]} and a bearer token:
products_to_api:
batch_size: 2000
input:
sqlx:
url: "postgres://app:secret@localhost/shop"
table: "products"
cursor_column: "id"
cursor_id: "products-to-api"
output:
middlewares:
- retry: { max_attempts: 5, initial_interval_ms: 500 }
- dlq: { endpoint: { file: { path: "refused-products.jsonl" } } }
http_bulk:
url: https://api.example.com
headers:
Authorization: "Bearer ${API_TOKEN}"
upsert:
path: /v1/products/batch
format: json_array
envelope: '{"items": {documents}}'
split_on_reject: true
max_request_bytes: 5000000
Save it as products.yaml and run it with mqb --config products.yaml. What happens:
- The input reads up to 2,000 rows; each row is one message with a JSON object as payload.
- The output puts the payloads into one array, wraps it in the
envelopeand sends onePOST. If the body would exceedmax_request_bytes, it sends several. - A 2xx answer means all rows were written, and the input saves its position.
- A
429or5xxanswer is tried again byretry. A400or422is sent again in halves (split_on_reject) until the rows the API refuses stand alone; those go to the file of thedlq, and the rest is written.
Which fields your API needs follows from a few questions:
| Question about the API | Field |
|---|---|
| One JSON document per line, or one JSON array? | upsert.format: ndjson or json_array |
| Is the array inside an object? | upsert.envelope, e.g. '{"items": {documents}}' |
Does every document need a line before it (Elasticsearch _bulk)? | upsert.action |
| Does the response report each document? | upsert.result: lines or items; none if the status says it all |
| Does it answer with a job to wait for? | upsert.result.job |
Authentication
A fixed key or token goes in headers. Take the value from the environment so that it is
not in the file:
output:
http_bulk:
url: https://search.example.com
headers:
X-API-Key: "${SEARCH_API_KEY}"
upsert: { path: /docs/_bulk }
An OAuth2 client (client credentials grant). The token is fetched once, reused, and renewed before it expires:
output:
http_bulk:
url: https://api.example.com
auth:
oauth2:
token_url: https://login.example.com/oauth2/token
client_id: "bridge"
client_secret: "${OAUTH_CLIENT_SECRET}"
scope: "products.write"
upsert:
path: /v1/products/batch
format: json_array
Amazon OpenSearch Service, with requests signed by the AWS credentials of the environment
(service: aoss for OpenSearch Serverless):
output:
http_bulk:
url: https://search-shop-abc123.eu-central-1.es.amazonaws.com
auth:
aws_sigv4: { region: eu-central-1, service: es }
upsert:
path: /products/_bulk
action: '{"index":{"_id":"${payload:id}"}}'
result:
items: { path: /items, error: /index/error/reason }
The OAuth2 and SigV4 examples were checked for their configuration only, not run against those services.
On the command line
The configuration is too nested for query parameters, so the http-bulk: scheme
takes it whole: from a file with config_file, or inline as YAML or JSON with
config. The value holds the fields listed below, with or without the
http_bulk: key around them. url replaces the target, so one file serves
several hosts.
mqb copy 'postgres://app@localhost/shop?table=books' \
'http-bulk:?config_file=meilisearch.yaml&url=http://search:7700'
mqb copy 'file://books.jsonl' \
'http-bulk:?config={url: "http://localhost:8108", upsert: {path: /docs}}'
mqb copy 'http-bulk:?config_file=couchdb-changes.yaml' 'file://changes.jsonl' --drain
Percent-encode a &, # or ? inside an inline config; a file needs no
escaping.
Tested targets
Each recipe was run against the version named. Anything else with a bulk HTTP API is a few lines of configuration away, but has not been tried.
| Target | Version | Upsert | Delete | Failures reported | Read |
|---|---|---|---|---|---|
| Meilisearch | 1.53 | yes | yes | per request (task) | by offset |
| Typesense | 29 | yes | yes | per document | not tried |
| Elasticsearch | 8.19 | yes | yes | per document | not tried |
| PostgREST / Supabase | PostgREST 16.4 | yes | yes | per request | by key |
| Qdrant | 1.19 | yes | yes | per request | full scan |
| CouchDB | 3.5 | insert only | no | per document | change feed |
Recipes
In the recipes operation reads the operation a Postgres change stream sets,
which tells an upsert from a delete. For sending mail in bulk (SendGrid, Amazon
SES) see SMS & email notifications.
Meilisearch
Meilisearch answers at once with a task and indexes later, so the outcome is a polled job. A failed task fails every document of its request.
output:
http_bulk:
url: http://localhost:7700
headers:
Authorization: Bearer <api key>
operation: "${metadata:postgres.operation}"
upsert:
path: /indexes/books/documents?primaryKey=id
result: &task
job:
id: /taskUid
poll: /tasks/{id}
status: /status
succeeded: [succeeded]
failed: [failed, canceled]
error: /error/message
delete:
path: /indexes/books/documents/delete-batch
result: *task
The built-in meilisearch endpoint is this recipe under a
name of its own. Index settings and partial updates are in the separate plugin. Loading 200,000 small
documents took the same time with either (about 4 to 5 s at a batch size of
50,000 on a laptop).
Qdrant
The qdrant endpoint is this recipe under a name.
A point is {id, vector, payload}, and the id is an unsigned integer or a UUID,
so shape the message with a transform first.
wait=true makes Qdrant answer after the points are stored.
output:
http_bulk:
url: http://localhost:6333
operation: "${metadata:postgres.operation}"
upsert:
method: PUT
path: /collections/books/points?wait=true
format: json_array
envelope: '{"points": {documents}}'
delete:
path: /collections/books/points/delete?wait=true
envelope: '{"points": {ids}}'
CouchDB
Inserts only, unless the messages carry the document’s current _rev: CouchDB
updates and deletes a document by it, so without it a document whose _id
exists is reported as a conflict and fails alone. Where a delete message holds
_id and _rev, the line of delete sends them:
output:
http_bulk:
url: http://localhost:5984
headers:
Authorization: Basic <base64 of user:password>
operation: "${payload:op}"
upsert:
path: /books/_bulk_docs
format: json_array
envelope: '{"docs": {documents}}'
result:
items:
error: /reason
delete:
path: /books/_bulk_docs
id_field: _id
envelope: '{"docs": {ids}}'
line: '{"_id": {id}, "_rev": "${payload:_rev}", "_deleted": true}'
result:
items:
error: /reason
Reading
As an input the endpoint asks for one page per batch. {limit} in the request
is the route’s batch_size, and {cursor} is the read position. Each document
of the page becomes one message with the document as its JSON payload. Where the
position after a page comes from is the only thing that differs between APIs:
read.cursor | Next position | Fits |
|---|---|---|
| unset | the number of documents read so far | offset= paging |
item: <pointer> | a field of the last document | id > last paging |
response: <pointer> | a value in the response | change feeds, scroll tokens |
next_url: <pointer> | a URL in the response | "next": "https://…" links |
link: true | the Link header’s rel="next" URL | GitHub-style paging |
A response cursor ends the read when the value is null or an empty string. Some APIs
leave the field out on the last page instead (Google’s nextPageToken): set
cursor.end_when_missing: true for those. It is off by default because a mistyped pointer
would otherwise read one page and report success.
With cursor_id and checkpoint_store in read the position is saved after every
acknowledged batch and a restart continues there. Without them every start
reads from the beginning.
A next-page URL may be relative; it is resolved against the page that named it. One on
another scheme, host or port stops the read, so headers and auth are never sent
elsewhere. The first request uses read.path, which may hold {limit}; the saved
position is the next URL, so a restart asks for that page. The end is saved as well, so a
restart after the last page reads nothing; mqb checkpoint resets it.
Next-page links: GitHub
input:
http_bulk:
url: https://api.github.com
headers:
Authorization: "Bearer ${GITHUB_TOKEN}"
read:
path: /repos/OWNER/REPO/issues?state=all&per_page={limit}
cursor: { link: true }
An API that puts the link into the body, such as {"data": [...], "links": {"next": "..."}},
takes items: /data and cursor: { next_url: /links/next }.
Change feed: CouchDB
A real change stream: updates and deletes arrive too, and the feed never ends.
A deleted document has "deleted": true next to its doc.
input:
http_bulk:
url: http://localhost:5984
headers:
Authorization: Basic <base64 of user:password>
read:
path: /books/_changes?include_docs=true&limit={limit}&since={cursor}
items: /results
cursor:
response: /last_seq
start: 0
cursor_id: books
checkpoint_store: file:///var/lib/mqb/cursors.json
Full scan: Qdrant
Scroll reads every point once. Qdrant answers the last page with a null
offset, which ends the read; the end is saved, so a restart reads nothing. It is
a copy, not a change feed: points written behind the position are missed.
input:
http_bulk:
url: http://localhost:6333
read:
path: /collections/books/points/scroll
body: '{"limit": {limit}, "offset": {cursor}, "with_payload": true, "with_vector": true}'
items: /result/points
cursor:
response: /result/next_page_offset
By offset: Meilisearch
input:
http_bulk:
url: http://localhost:7700
headers:
Authorization: Bearer <api key>
read:
path: /indexes/books/documents?offset={cursor}&limit={limit}
items: /results
An offset is only stable while nothing is deleted or reordered during the read.
By key: PostgREST
The response is the array itself, so items stays empty. New rows with a larger
key are picked up by later polls; updates of rows already read are not.
input:
http_bulk:
url: http://localhost:3000
read:
path: /books?id=gt.{cursor}&order=id&limit={limit}
cursor:
item: /id
start: 0
With direct access to the database, the Postgres connector is the better source: its change stream sees updates and deletes.
An open stream: SSE and NDJSON
With read.stream the endpoint sends one request and reads the response while
it arrives, instead of asking for pages. Every event or line is one message, and
a batch is whatever has arrived, up to the route’s batch_size.
stream | A message is | Read position |
|---|---|---|
sse | the data of one server-sent event | the event’s id, sent back as Last-Event-ID |
ndjson | one line | cursor.item, a field of the line, put where {cursor} stands; none without it |
input:
http_bulk:
url: https://stream.wikimedia.org
headers:
User-Agent: my-bridge/1.0 (ops@example.com)
read:
path: /v2/stream/recentchange
stream: sse
cursor_id: recentchange
checkpoint_store: file:///var/lib/mqb/cursors.json
An event’s id and event fields are the sse_id and sse_event metadata of
its message. The payload is the data as sent; it need not be JSON. For sse,
cursor.item takes the position from a field of the data instead of the id.
An export that is one response is read once by a draining route (--drain on
the mqb command line):
input:
http_bulk:
url: http://localhost:8108
headers:
X-TYPESENSE-API-KEY: ${TYPESENSE_API_KEY}
read:
path: /collections/books/documents/export
stream: ndjson
What differs from reading pages:
- The connection is opened again. When the response ends or is cut, the
input waits
polling_interval_msand connects from the read position. Without a position that reads the same response again, so read a finite export with--drain, or give itcursor.itemand{cursor}. - A draining route ends with the response, or when nothing arrives for
polling_interval_ms. A response that is cut fails the route instead of passing for a complete one. - A failed message is asked for again by connecting from the last
acknowledged position. An
ndjsonline withoutcursor.itemhas no position and is not read again. request_timeout_msis the longest silence, not the length of the response. A server that sends keep-alive comments stays connected.{limit},itemsandcursor.responseare refused: the server decides what it sends, and there is no response around the documents.- An event or line is held in memory until it is complete. One above 64 MiB stops the route.
The Wikimedia stream above was read with this configuration, restart included. The export was run against a stub server, not against Typesense.
Lookups
With query the endpoint writes nothing: it answers the
lookup middleware, one request for a whole batch
of messages instead of one per message. Each message renders request, the
parts are joined into one body, and the answers are matched to the messages by
their position in the array at responses. The endpoint keeps no state and
caches no answers; only an auth.oauth2 token is cached, as described under
Behaviour.
This output looks up each order’s book in Elasticsearch with one _mget per
batch, then writes the enriched orders:
output:
middlewares:
- lookup:
from:
http_bulk:
url: http://localhost:9200
query:
path: /books/_mget
format: json_array
request: '{"_id":"${payload:isbn}"}'
envelope: '{"docs":{requests}}'
responses: /docs
value: /_source
found: /found
into: book
http_bulk:
url: http://localhost:9200
upsert:
path: /orders/_bulk
action: '{"index":{"_id":"${payload:id}"}}'
Two more shapes, as the query section alone:
| Target | query |
|---|---|
Elasticsearch _msearch | path: /books/_msearch, request: two lines, {} and {"query":{"term":{"isbn":"${payload:isbn}"}},"size":1}, responses: /responses, value: /hits/hits/0/_source, error: /error/reason |
| Qdrant batch search | path: /collections/books/points/search/batch, format: json_array, request: '{"vector":${payload:vector | raw},"limit":3}', envelope: '{"searches":{requests}}', responses: /result |
These three shapes were run against a stub server only, not against Elasticsearch or Qdrant.
Fields
| Field | Default | Meaning |
|---|---|---|
url | required | Base URL of the target |
headers | none | Sent with every request; stored as secrets |
auth | none | oauth2 or aws_sigv4 credentials for every request; see below |
upsert | an output needs it or query | The request that writes documents |
query | none | The request that answers lookups; excludes upsert, delete and operation |
read | required for an input | The request that reads a page of documents, or opens a stream |
delete | none | The request that removes documents |
operation | none | Template for a message’s operation; without it every message is an upsert |
delete_values | delete, d | Operation values that mean delete, ignoring case |
max_request_bytes | 10 MiB | A larger batch is split into several requests |
compression | none | gzip, zstd or lz4 for request bodies, named in Content-Encoding |
request_timeout_ms | see right | Timeout of one request; 0: none. Unset: a request fails after 300 s without a response byte. With read.stream it is the longest silence on the open response, and unset means none |
connect_timeout_ms | 10000 | Connection timeout |
tls | none | ca_file and accept_invalid_certs for https://; required: true refuses a url or OAuth2 token_url that is not https:// |
upsert:
| Field | Default | Meaning |
|---|---|---|
path | required | Path and query appended to url |
method | POST | HTTP method |
format | ndjson | ndjson (one document per line) or json_array |
content_type | by format | application/x-ndjson or application/json |
action | none | ndjson only: a line sent before each document, e.g. {"index":{"_id":"${payload:id}"}}. Values taken from the message are JSON-escaped. |
envelope | none | json_array only: the body around the array; {documents} marks where the array goes |
document | none | Template around each document; {document} marks where the payload goes, e.g. {"doc":{document},"doc_as_upsert":true}. Values taken from the message are JSON-escaped. |
result | HTTP status | See below |
split_on_reject | false | Without result: a request the target refuses with 400, 409, 413 or 422 is sent again in halves until the refused documents stand alone |
max_documents | none | Most documents in one request, for an API that limits the count rather than the size |
query:
| Field | Default | Meaning |
|---|---|---|
path | required | Path and query appended to url |
method | POST | HTTP method |
format | ndjson | ndjson (each lookup adds its lines) or json_array (each lookup adds one entry) |
content_type | by format | application/x-ndjson or application/json |
request | required | Template of one lookup’s part of the body. Values taken from the message are JSON-escaped; | raw inserts a JSON value as it is. With ndjson it may hold several lines. {index}, at most once, is the part’s number in its request, from 1 |
envelope | none | json_array only: the body around the array; {requests} marks where the array goes |
responses | the response | JSON pointer to the array with one answer per lookup, in order |
value | the entry | JSON pointer, inside an entry, to what the lookup answers |
found | none | JSON pointer, inside an entry, to a boolean; anything but true answers null |
error | none | JSON pointer, inside an entry, to an error text; set, it fails that lookup |
per_message | false | One request per message; the response itself is the entry. Excludes envelope and responses |
parse_value | false | The value is JSON text: parse it. Text that is not JSON fails its message for good |
concurrency | 8 with per_message, else 1 | Requests in flight for one batch |
max_messages | none | Most messages in one request; a larger batch is split, as it is at max_request_bytes |
responses_text | none | JSON pointer to JSON text in the response that holds the answers; responses then points into that text. For an API that answers inside a string, such as a chat completion |
index | none | JSON pointer, inside an entry, to the {index} of its message. Entries are then matched by that number instead of by position; a number that is missing, repeated or unknown makes the response unusable |
stop_reason | none | JSON pointer to why the response ended, e.g. /choices/0/finish_reason; named in the error when the answers cannot be matched |
split_on_reject | false | A request refused with 400, 409, 413 or 422, or a response whose answers cannot be matched: ask again in halves until the messages at fault stand alone, so only they fail. Without it the batch fails for good |
max_messages, responses_text, index and split_on_reject exclude per_message.
auth takes one of:
| Field | Meaning |
|---|---|
oauth2 | Client credentials grant: token_url, client_id, client_secret and an optional scope. The token is sent as Authorization: Bearer. Use an https:// token_url: the secret is sent to it. With tls.required anything else is refused; without it, plain HTTP to another host logs a warning. |
aws_sigv4 | Signs each request: region and service (es, aoss, …). access_key, secret_key and session_token are optional; without them the AWS default credential chain is used. Needs a build with the aws feature, which full includes. |
read:
| Field | Default | Meaning |
|---|---|---|
path | required | Path and query. {cursor} is replaced by the position, percent-encoded; {limit} by the batch size |
method | GET, or POST with a body | HTTP method |
body | none | JSON request body; {cursor} is replaced by the position as JSON (7, "a", null) and {limit} by the batch size |
items | the response | JSON pointer to the array of documents |
cursor.response | none | JSON pointer to the next position in the response; null or an empty string there ends the read |
cursor.end_when_missing | false | With cursor.response: a response without that field ends the read too. Without it such a response stops the route |
cursor.item | none | JSON pointer to the position in the last document of a page |
cursor.next_url | none | JSON pointer to the URL of the next page, e.g. /links/next; missing, null or empty ends the read |
cursor.link | false | The next page is the rel="next" URL of the Link header; a page without one ends the read |
cursor.start | 0 for a count, else null | Position before the first read |
cursor_id | none | Names the saved read position |
checkpoint_store | none | Where the position is saved: file://, postgres://, mongodb:// or s3:// |
polling_interval_ms | 1000 | Wait after an empty page |
max_polling_interval_ms | none | The wait doubles up to this while pages stay empty |
stream | none | sse or ndjson: read one open response instead of pages; see An open stream |
{cursor} must occur in the path or the body, except with stream, cursor.next_url or
cursor.link, which refuse it. Set at most one of cursor.response, cursor.item,
cursor.next_url and cursor.link.
delete:
| Field | Default | Meaning |
|---|---|---|
path | required | With {ids} the ids go there, comma-separated and percent-encoded; without it they are sent as a JSON array body. An id containing a comma cannot be sent in the URL form without a line that quotes it. |
method | POST | HTTP method |
id_field | id | Top-level payload field holding the id (a string or a number) |
max_ids | 1000 | Most ids in one request |
envelope | none | The body around the id array; {ids} marks where the array goes |
line | none | Template per id; {id} is replaced by the id as JSON (7 or "a"), and values such as ${payload:_rev} are JSON-escaped. Alone it gives one body line per id; with envelope each line is one array entry, and one that is no JSON fails only its message; with {ids} each line, percent-encoded, is one list entry |
result | HTTP status | See below |
result takes at most one of:
| Field | Meaning |
|---|---|
lines | The response has one JSON line per document, in order. success is a JSON pointer to a boolean; error points to the reason. |
items | The response is one JSON document with an array entry per document, in order. path is a JSON pointer to the array (empty when the response is the array); error points to an entry’s error text, and an entry that has it failed. With index, a JSON pointer inside an entry to its document’s position in the request (from 0), the array may list only the failures, e.g. BigQuery’s insertErrors, and is left out or null when none failed; a position outside the request fails it. With success: { path, equals }, an entry whose value at path is not equals failed too, e.g. SES’s /Status other than SUCCESS without an Error. |
job | The response names a job. id points to it, poll is the path asked with GET ({id} is replaced), status points to its state. succeeded and failed list the final states; any other state is polled again. error points to the reason. timeout_ms (default 300000) is how long the job may run. |
Without result, a 2xx status means every document was written.
Behaviour
- Order is kept. A batch is cut into runs of upserts and deletes and sent in
that order. After a request that failed but may be retried, nothing later in
the batch is sent. With
operationset, batches are also sent one at a time, whatever the route’sconcurrency, so a delete cannot overtake an upsert. - Failures are per document where the target says so. A payload that is not
a JSON object, a rejected result line and a delete without an id fail that
message for good. A failed job or a refused request fails every document of
that request for good, and the rest of the batch is still sent. With
upsert.split_on_reject: truea request refused for its content (400, 409, 413, 422) is sent again in halves, so only the refused documents fail. That costs about2 x log2(batch)extra requests per bad document. Turn it on only for a target that applies nothing of a request it refuses; one that applies a part before refusing would get that part twice. - Payloads are checked, not rewritten. A payload that starts like a JSON
object but is not valid JSON fails that message alone (
the payload is not valid JSON: …), and the rest of the batch is sent. - Compression needs a target that reads it. The body is compressed whole
and named in
Content-Encoding.gzipwas run against Elasticsearch 8.19 and Meilisearch 1.53. Both refusezstdandlz4with a 400 that calls the payload malformed and does not mention the encoding; check your target before choosing either. - Redirects are not followed, so credentials in
headersstay with the configured host. - Retries follow the status. 408, 429 and 5xx (except 501 and 505) and
connection errors are retryable; other statuses are not. Add the
retrymiddleware to retry them anddlqto keep what failed for good. - A job has a deadline. The endpoint polls until the job ends, logs a
warning every minute and gives up after
timeout_ms(5 minutes unless set). The documents of that request then fail as retryable, with the job id and the last state or poll error seen. A poll that keeps failing ends the same way. The job may still finish later, so retry only where sending a document twice is safe, as it is for an upsert by id. - Truncate is not applied. A truncate message fails for good.
- A token is fetched once and shared. With
auth.oauth2the token is cached and replaced a minute before it expires (at half its lifetime if that is shorter); concurrent batches wait for one token request. A request answered 401 gets a new token and is sent once more; a second 401 fails as any other status. The client id and secret go in the form body of the token request. A failing token endpoint fails the batch by its status. - Credentials cover every request: upserts, deletes, queries, reads and job
polls.
client_secret,secret_keyandsession_tokenare stored as secrets. A SigV4 signature covers the body as sent, after compression.
As a lookup (query):
- One request per batch, split by
max_request_byteslike an upsert. A refused request fails every lookup in it, retryable by its status. - An entry whose
foundis nottrue, or that has nothing atvalue, isnull. The array must hold one entry per lookup: answers are matched by position, so a shorter or longer one fails the request for good. - An entry that reports an error fails only its message. The other messages keep their answers; nothing is asked again.
- A single lookup is a batch of one: the same request with one part.
- A
json_arraypart must be valid JSON; one that is not fails its message for good and is left out of the request. - With
per_messageevery message is its own request, for an API that takes one question at a time, such as a chat completion. A refused request fails only its message, retryable by its status. Under alookupthe lookup’sconcurrencydecides how many are in flight.
As an input:
- At least once. The position moves when a batch is acknowledged. After a failed message the read continues behind the last acknowledged document; a page whose position comes from the response is read again as a whole.
- Errors follow the status as above. A response that is not JSON, or has no
array at
itemsor no position wherecursorpoints, stops the route. - Documents are not reshaped. The payload is the array entry as it came. Use
a
transformto take CouchDB’sdocout of a change, for example. - A draining route does not wait. With
--drainthe first empty page ends the copy, also when that page names a next position. - A batch that is dropped without an answer is read again, like a failed one.
Set the route’s batch_size to a few thousand or more (--batch-size on the
command line, where the default is 1024): each batch is one request, and search
engines index large requests far faster than small ones. Against Meilisearch,
200,000 documents took 16 s at 1024 and 4 s at 50,000.
A target that is not listed needs three answers from its API documentation:
what the bulk write request looks like, how documents are removed by id, and
whether the response says anything per document. To read from it, it needs one
more: how a request names the page after the last one. If no combination of the
fields above fits, the endpoint cannot be used with it. An export that streams
everything in one response (Typesense) is not a paged listing; read it with
stream: ndjson.
A named endpoint of your own
typesense, elasticsearch, qdrant and the
OpenAI-compatible endpoints are this endpoint with the requests
filled in. From Rust each has a typed configuration, such as QdrantConfig,
that converts into an Endpoint with .into() and needs no registration. A
Rust crate can add one the same way: a function that turns a few fields into an
http_bulk configuration, registered under a name.
use mq_bridge::endpoints::http_bulk::{
preset_base_url, preset_schema, preset_segment, register_preset_with,
};
use serde_json::{json, Value};
fn acme_schema() -> Value {
let own = json!({
"table": {"type": "string", "description": "Table written to.", "x-mqb-uri": "path"}
});
preset_schema("Acme", "acme://localhost:9000", own, &["url", "table"])
}
fn acme(_route: &str, config: &Value) -> anyhow::Result<Value> {
let url = config["url"].as_str().unwrap_or_default();
let table = preset_segment("table", config["table"].as_str().unwrap_or_default())?;
Ok(json!({
"url": preset_base_url("acme", url)?,
"upsert": {"path": format!("/{table}/_bulk")}
}))
}
register_preset_with("acme", acme_schema, acme)?;
Call it before any route starts and address the endpoint as
acme: { url: ..., table: ... }. The value acme
returns is checked as an http_bulk configuration, so every field under
Fields is available. preset_schema adds url, api_key,
operation, compression and request_timeout_ms to the fields you list;
acme decides what to do with them. A unit test registers the same endpoint;
it was not run against a server.
For a typed configuration of your own, derive Serialize on it and build the
endpoint with named_endpoint("acme", &config).
This registers the endpoint in your own program. mqb only knows the endpoints
compiled into it or installed as plugins.
Typesense
typesense is an output that writes documents to a Typesense collection. Its
import API takes one document per line and answers with one result line per
document, so a rejected document fails alone. Tested against Typesense 29.
mqb copy --drain --batch-size 10000 \
'file://books.jsonl?format=raw' \
'typesense://localhost:8108/books?api_key=<api key>'
typesense+https://host/books connects over HTTPS.
On this page:
- Before the first run
- Keep a collection in sync with a Postgres table
- One-off load
- Options
- What to know
Before the first run
Typesense does not create a collection on import. Create it once:
curl -X POST http://localhost:8108/collections \
-H 'X-TYPESENSE-API-KEY: <api key>' \
-H 'Content-Type: application/json' \
-d '{"name": "books", "fields": [
{"name": "title", "type": "string"},
{"name": "year", "type": "int32"}]}'
Every document needs an id, and it must be a string. A numeric id, which is
what an integer primary key gives, is rejected with “Document’s id field
should be a string”. The transform below turns it into one and leaves every
other field as it is.
Keep a collection in sync with a Postgres table
books_to_search:
batch_size: 10000
input:
postgres_cdc:
url: "postgres://user:pass@localhost/app"
publication: "books_pub"
slot_name: "mqb_typesense"
consume: capture_all # backfill first, then stream changes
cursor_id: "books_backfill"
checkpoint_store: "file:///var/lib/mq-bridge/books-phase.json"
output:
middlewares:
- transform:
schema:
type: object
properties:
id: { type: string }
- retry: { max_attempts: 5 }
typesense:
url: "typesense://localhost:8108"
api_key: "<api key>"
collection: books
operation: "${metadata:postgres.operation}"
Inserts and updates become upserts. A delete removes the document whose id
is the deleted row’s primary key, so the table needs a primary key named id. See Postgres CDC
for the publication and the slot.
One-off load
echo '{"type":"object","properties":{"id":{"type":"string"}}}' > id-as-string.json
mqb copy --drain --batch-size 10000 \
'file://books.jsonl?format=raw' \
'typesense://localhost:8108/books?api_key=<api key>|transform?schema_file=id-as-string.json'
Loading 20,000 small documents this way took under a second on a laptop.
Options
| Field | Default | Meaning |
|---|---|---|
url | required | typesense://host:8108, typesense+https://host or an http(s):// URL |
collection | required | Collection to write to; the path of the URI |
api_key | none | Sent as X-TYPESENSE-API-KEY |
operation | none | Template for a message’s operation; delete or d removes the document |
compression | none | gzip, zstd or lz4 request bodies; check that your server reads the codec, a refusal does not name it |
request_timeout_ms | see right | Request timeout; 0: none. Unset: a request fails after 300 s without a response byte |
tls | none | TLS options as on http_bulk: ca_file, cert_file + key_file, required, accept_invalid_certs. JSON text in a URI. |
typesense is the generic http_bulk output with these
requests filled in. For anything it does not offer, such as another import
action or extra headers, write the http_bulk form:
output:
http_bulk:
url: http://localhost:8108
headers:
X-TYPESENSE-API-KEY: <api key>
operation: "${metadata:postgres.operation}"
upsert:
path: /collections/books/documents/import?action=upsert
content_type: text/plain
result:
lines:
success: /success
error: /error
delete:
method: DELETE
path: /collections/books/documents?filter_by=id:[{ids}]
max_ids: 100
What to know
- A document without
idis still written. Typesense invents an id, and a later delete cannot find the document. - A rejected document fails alone, with Typesense’s reason, for example
“Field
yearmust be an int32”. Add adlqto keep it. - Deletes go into the URL, 100 ids per request, and an id containing a comma cannot be deleted this way.
- No collection management. Schema changes, aliases and synonyms stay with the Typesense API.
The behaviour on errors is described on the http_bulk page.
Elasticsearch
elasticsearch is an output that writes documents through the _bulk API: an
action line before each document, and a response with one entry per document,
so a rejected document fails alone. Tested against Elasticsearch 8.19.
OpenSearch has the same _bulk API but has not been tried.
mqb copy --drain --batch-size 5000 \
'file://books.jsonl?format=raw' \
'elasticsearch://localhost:9200/books?api_key=<api key>'
elasticsearch+https://host/books connects over HTTPS, and ?mode=update
merges each payload into the stored document instead of replacing it.
Keep an index in sync with a Postgres table
books_to_search:
batch_size: 5000
input:
postgres_cdc:
url: "postgres://user:pass@localhost/app"
publication: "books_pub"
slot_name: "mqb_elastic"
consume: capture_all # backfill first, then stream changes
cursor_id: "books_backfill"
checkpoint_store: "file:///var/lib/mq-bridge/books-phase.json"
output:
middlewares:
- retry: { max_attempts: 5 }
elasticsearch:
url: "elasticsearch://localhost:9200"
api_key: "<api key>"
index: books
operation: "${metadata:postgres.operation}"
compression: gzip
The document _id is the row’s id column, so an update replaces the document
and a delete finds it. Name another column with id_field.
Every document needs a non-null value there. A string or a number is used as
the _id directly; an object or an array is serialized to its JSON text, and
that text is the _id. Without the field, or with null in it, Elasticsearch
refuses that document with if _id is specified it must not be empty, which
does not name the field.
See Postgres CDC for the publication and the slot.
Options
| Field | Default | Meaning |
|---|---|---|
url | required | elasticsearch://host:9200, elasticsearch+https://host or an http(s):// URL |
index | required | Index to write to; the path of the URI |
api_key | none | Sent as Authorization: ApiKey <api key> |
id_field | id | Top-level payload field that becomes the document _id |
mode | index | index replaces the document; update merges the payload into it and creates it if missing |
auth | none | oauth2 or aws_sigv4, as on http_bulk; in a URI, JSON: auth={"aws_sigv4":{"region":"eu-central-1","service":"es"}} |
operation | none | Template for a message’s operation; delete or d removes the document |
compression | none | gzip request bodies. zstd and lz4 are accepted here, but Elasticsearch 8.19 answers them with a “malformed” 400 |
request_timeout_ms | see right | Request timeout; 0: none. Unset: a request fails after 300 s without a response byte |
tls | none | TLS options as on http_bulk: ca_file, cert_file + key_file, required, accept_invalid_certs. JSON text in a URI. |
elasticsearch is the generic http_bulk output with these
requests filled in. For anything it does not offer, such as basic
authentication or another bulk action, write the http_bulk form:
output:
http_bulk:
url: http://localhost:9200
headers:
Authorization: ApiKey <api key>
operation: "${metadata:postgres.operation}"
compression: gzip
upsert:
path: /books/_bulk
action: '{"index":{"_id":"${payload:id}"}}'
result:
items:
path: /items
error: /index/error/reason
delete:
path: /books/_bulk
line: '{"delete":{"_id":{id}}}'
result:
items:
path: /items
error: /delete/error/reason
What to know
- A rejected document fails alone, with Elasticsearch’s reason, for example
“failed to parse field [year] of type [integer]”. Add a
dlqto keep it. indexreplaces the whole document.mode: updatesends{"update":{"_id":…}}and{"doc":<payload>,"doc_as_upsert":true}instead: fields the payload leaves out keep their stored value. It was run against a stub server only, not against Elasticsearch.authwas run against a stub server only, for OAuth2 and for SigV4 (Amazon OpenSearch Service).- The test ran with security disabled.
api_keyis sent in the form Elasticsearch documents for an API key. - Mappings and index settings are not managed. Create the index first if dynamic mapping is not what you want.
- Another data path already exists for Kafka users: Kafka Connect’s Elasticsearch sink reads a topic that mq-bridge writes. This endpoint is for writing without Kafka in between.
The behaviour on errors is described on the http_bulk page.
PostgREST and Supabase
PostgREST turns a Postgres table into a REST resource, and Supabase’s data API
is PostgREST. The generic http_bulk output writes to it:
an upsert is a POST of a JSON array, a delete is a filter on the primary key.
Tested against PostgREST 16.4. A hosted Supabase project has not been tried.
Use this when only the REST API is reachable. With a database connection, the Postgres output writes the same rows directly and faster.
output:
http_bulk:
url: http://localhost:3000
headers:
Prefer: resolution=merge-duplicates
operation: "${metadata:postgres.operation}"
upsert:
path: /books
format: json_array
split_on_reject: true
delete:
method: DELETE
path: /books?id=in.({ids})
line: "{id}"
For Supabase, the documented form is the project URL with /rest/v1 before the
table, and the key in two headers:
output:
http_bulk:
url: https://your-project.supabase.co
headers:
apikey: <key>
Authorization: Bearer <key>
Prefer: resolution=merge-duplicates
upsert:
path: /rest/v1/books
format: json_array
split_on_reject: true
delete:
method: DELETE
path: /rest/v1/books?id=in.({ids})
line: "{id}"
What to know
- The table needs a primary key.
resolution=merge-duplicatesupdates the row with the same key; without the header a second write of a row fails. - A request is one transaction. One row the table refuses, for example a
text in an integer column, makes PostgREST refuse the whole request with the
database’s message. With
split_on_reject: true, as above, the request is then sent again in halves until the refused row stands alone, and only that row fails. Without it every row of the request fails. - Every row of a request needs the same columns, which is what a change stream of one table gives.
- Text ids are quoted.
line: "{id}"writes each id of theinlist as JSON, so"a,b"stays one value, as PostgREST’s URL grammar asks for. Withoutlinean id holding a comma cannot be deleted. - Row level security applies to the role behind the key.
All fields and the behaviour on errors are on the http_bulk page.
Qdrant
qdrant writes points to a Qdrant collection, removes them by id, and reads a
collection once from start to end. A point is {id, vector, payload}; the
vector comes from an embeddings API, which
openai_embeddings asks once per batch.
mqb copy --drain \
'file://points.jsonl?format=raw' \
'qdrant://localhost:6333/docs?api_key=<api key>'
qdrant+https://host/docs connects over HTTPS.
Upserts and the read were run against Qdrant 1.19. A delete through this
endpoint was run against a stub server only; its request is the one of the
http_bulk recipe, which was run against Qdrant.
Before the first run
Qdrant does not create a collection on write. Create it once, with the vector size of your embedding model:
curl -X PUT http://localhost:6333/collections/docs \
-H 'Content-Type: application/json' \
-d '{"vectors": {"size": 384, "distance": "Cosine"}}'
A point id is an unsigned integer or a UUID. Any other id is refused.
Keep a collection in sync with a Postgres table
Inserts and updates are embedded and upserted; a delete removes the point and calls no model. Output middlewares run bottom-up.
docs_to_qdrant:
input:
postgres_cdc:
url: "postgres://user:pass@localhost/app"
publication: "docs_pub"
slot_name: "docs_to_qdrant"
consume: capture_all # backfill first, then stream changes
checkpoint_store: "file:///var/lib/mq-bridge/docs-phase.json"
output:
switch:
metadata_key: "postgres.operation"
cases:
delete:
qdrant: &qdrant
url: "http://localhost:6333"
collection: docs
operation: "${metadata:postgres.operation}"
default:
middlewares:
# 2. Shape the point: {id, vector, payload}.
- transform:
mapping:
id: "$.id"
vector: "$.vector"
"payload.title": "$.title"
"payload.body": "$.body"
# 1. One embeddings request per batch; the vector lands in `vector`.
- lookup:
from:
openai_embeddings:
url: "http://localhost:11434"
model: all-minilm
input: "${payload:body}"
into: vector
- retry: { max_attempts: 5 }
qdrant: *qdrant
Keep a vector index in sync with Postgres explains the pipeline, its failure behaviour and its limits, with a runnable example.
Copy a collection out
mqb copy --drain 'qdrant://localhost:6333/docs' 'file://points.jsonl?format=raw'
Each message is one point with its payload and vector. It is a copy, not a
change feed: points written behind the read position are missed. Set
cursor_id and checkpoint_store to continue after a restart.
Options
| Field | Default | Meaning |
|---|---|---|
url | required | qdrant://host:6333, qdrant+https://host or an http(s):// URL |
collection | required | Collection to write to or read; the path of the URI |
api_key | none | Sent as api-key |
wait | true | Qdrant answers after the points are stored. false answers when they are queued, so a later failure is not reported |
operation | none | Template for a message’s operation; delete or d removes the point |
compression | none | gzip, zstd or lz4 request bodies; not tried against Qdrant |
request_timeout_ms | see right | Request timeout; 0: none. Unset: a request fails after 300 s without a response byte |
cursor_id | none | Input only: names the saved read position |
checkpoint_store | none | Input only: where the position is saved, e.g. file:///var/lib/mqb/cursors.json |
tls | none | TLS options as on http_bulk: ca_file, cert_file + key_file, required, accept_invalid_certs. JSON text in a URI. |
qdrant is the generic http_bulk endpoint with these
requests filled in:
output:
http_bulk:
url: http://localhost:6333
headers:
api-key: <api key>
operation: "${metadata:postgres.operation}"
upsert:
method: PUT
path: /collections/docs/points?wait=true
format: json_array
envelope: '{"points":{documents}}'
delete:
path: /collections/docs/points/delete?wait=true
envelope: '{"points":{ids}}'
From Rust, mq_bridge::endpoints::http_bulk::QdrantConfig holds the same
fields and converts into an Endpoint with .into().
For a vector search per message, use the
batch search lookup of http_bulk.
What to know
- Qdrant answers for the whole request. One refused point, for example a
vector of the wrong size, fails every point of that request for good. Add a
dlqto keep them. - An upsert replaces the point, payload and vector. A repeated upsert or delete after a restart changes nothing, so at-least-once delivery converges.
- No collection management. Creating collections, indexes and aliases stays with the Qdrant API.
The behaviour on errors is described on the http_bulk page.
OpenAI-compatible APIs (LLMs)
Two endpoints call an API that speaks the OpenAI wire format: OpenAI itself, and the servers that copied it, such as Ollama, vLLM, LM Studio, Groq and Mistral.
| Endpoint | Calls | Requests |
|---|---|---|
openai_chat | /chat/completions | one per message, or one per messages_per_request |
openai_embeddings | /embeddings | one per batch |
Both answer; they write nothing. Use them as the from of a
lookup, which puts the answer into the message, or
as the to of a request endpoint, which
forwards the answer. Recipes are in the LLM cookbook.
A request endpoint sends its messages one by one, so under it both endpoints
make one API request per message: messages_per_request bundles nothing and
openai_embeddings embeds one text per request. Put a
buffer on the to endpoint and the sends
of a batch leave together again, 40 messages as one embeddings request:
output:
request:
to:
openai_embeddings: { model: text-embedding-3-small, input: "${payload:text}" }
middlewares:
- buffer: { max_messages: 500, max_delay_ms: 0 }
forward_to: { file: { path: "vectors.jsonl", format: raw } }
A lookup, or the endpoint as the output itself, batches without it.
mq-bridge is not affiliated with OpenAI; the names say which API the endpoints speak.
openai_chat
Sends one chat completion per message and answers with the text of the first choice.
output:
middlewares:
- lookup:
from:
openai_chat:
url: "http://localhost:11434" # Ollama; leave out for api.openai.com
model: llama3.2
system: "Answer with one word: positive, negative or neutral."
prompt: "${payload:text}"
into: sentiment
kafka: { topic: "reviews.scored", url: "localhost:9092" }
With json: true the endpoint asks for a JSON object and parses it, so the
answer is an object in the message instead of text:
output:
middlewares:
- lookup:
concurrency: 4
from:
openai_chat:
api_key: "${env:OPENAI_API_KEY}"
model: gpt-4o-mini
json: true
prompt: 'Extract {"city": string, "urgent": boolean} as JSON from: ${payload:text}'
options: { temperature: 0 }
into: extracted
kafka: { topic: "tickets.extracted", url: "localhost:9092" }
On the command line the scheme is openai:// or openai-chat://, for example
openai://localhost:11434?model=llama3.2&prompt=….
Several messages in one request
With messages_per_request the endpoint sends that many messages as numbered
inputs of one completion and hands each message its own answer. The system
message, and with it most of the prompt tokens, is sent once per request
instead of once per message:
reviews_scored:
batch_size: 100
input: { kafka: { topic: "reviews", url: "localhost:9092" } }
output:
middlewares:
- lookup:
from:
openai_chat:
api_key: "${env:OPENAI_API_KEY}"
model: gpt-4o-mini
system: "Answer with one word: positive, negative or neutral."
prompt: "${payload:text}"
messages_per_request: 20
options: { temperature: 0 }
into: sentiment
kafka: { topic: "reviews.scored", url: "localhost:9092" }
The request asks for a JSON object {"answers": [{"n": 1, "answer": …}, …]}
with one entry per input. Each answer goes to the message whose number it
carries, so the order the model answers in does not matter. With json: true
each answer is a JSON object, otherwise a string.
On the command line it is one more parameter:
openai://localhost:11434?model=llama3.2&prompt=…&messages_per_request=20.
- Answers that cannot be matched are asked again in halves. A response that is not that object, has more or fewer entries than inputs, or holds a number twice or one no input has, is not used. Its messages are sent again in two requests, and so on, until what cannot be answered stands alone and fails for good. A request the API refuses with 400, 409, 413 or 422, such as one over the context length, is halved the same way, so one oversized message does not fail its neighbours.
- Halving costs requests. One bad message among 20 costs about ten requests instead of one; a model that loses count at every size costs up to twice as many requests as without bundling. Pick a size your model handles; how many inputs a model keeps apart depends on the model and on the length of the texts. Each halving logs a warning with the reason.
- Under a
lookup, a failed message fails alone too. The other messages of the batch keep their answers and are not asked again. - Leave room for the answers. A
max_tokensinoptionsbounds the whole response, andrequest_timeout_msthe whole request. Unset, a request fails when no response byte arrives for 300 s; setrequest_timeout_mshigher for a model that takes longer to answer, or to0for no limit. An answer that was cut off cannot be matched; the warning then says the response ended withlength. - Size is counted in messages and bytes, not tokens.
max_request_bytesbounds the request body. A single message over the model’s context is refused by the API and fails alone. - The route’s
batch_sizeis the upper bound. A bundle is cut from one batch, so a route withbatch_size: 1still sends one message per request. Nothing is held back to fill a bundle. - 429, 408 and 5xx fail the whole batch as retryable and are not halved;
retrysends it again. ARetry-Afterheader is not read: set the backoff inretry. systemis fixed text here. It is sent once per request, so it cannot take values from a message; put those intoprompt. Being the same in every request, it is also what a provider’s prompt cache can reuse.- Put the task into
systemand only the data intoprompt. The model sees all inputs of a request together, and a text that reads like an instruction can change the answers of its neighbours. Do not bundle texts from sources you do not trust with ones whose answers matter, and leavemessages_per_requestout where each message needs a request of its own. - The inputs are sent as text parts of one user message
(
content: [{"type": "text", …}]), and the request setsresponse_format: json_objectunlessoptionssets another, such as a strictjson_schema. OpenAI, Ollama and vLLM take both; a server that does not needs the one-per-message form. - A payload that is not UTF-8:
${payload}is sent with the invalid bytes replaced. A field such as${payload:text}needs a JSON payload, which is UTF-8. - No metrics per bundle. The token
usageof a response is not kept, and an answer carries no mark of the bundle it came from.
| Field | Default | Meaning |
|---|---|---|
url | https://api.openai.com/v1 | Base URL. One without a path gets /v1. openai://host is plain HTTP, openai+https://host is HTTPS |
api_key | none | Sent as Authorization: Bearer |
model | required | Model name |
prompt | required | Template of the user message |
system | none | Template of the system message |
json | false | Asks for response_format: json_object and parses the answer. An answer that is not JSON fails its message for good |
options | none | Further request fields, e.g. temperature, max_tokens, response_format. It cannot set model, messages or stream. JSON text in a URI |
concurrency | 8 | Requests in flight when a batch is sent to the endpoint as an output. Under a lookup, the lookup’s own concurrency (default 16) decides, unless messages_per_request is set |
messages_per_request | none | Messages answered by one request, see above. Unset: one request per message |
max_request_bytes | 10 MiB | With messages_per_request: a bundle over this size is cut earlier |
request_timeout_ms | see right | Timeout of one request; 0: none. Unset: a request fails after 300 s without a response byte |
tls | none | TLS options as on http_bulk. JSON text in a URI |
auth | none | oauth2 or aws_sigv4 credentials as on http_bulk, in place of api_key. JSON text in a URI |
openai_embeddings
Sends the texts of a whole batch in one request and answers each message with its vector.
output:
middlewares:
- lookup:
from:
openai_embeddings:
api_key: "${env:OPENAI_API_KEY}"
model: text-embedding-3-small
input: "${payload:title}: ${payload:body}"
into: vector
qdrant: { url: "http://localhost:6333", collection: docs }
That writes the message with a vector field; shape it into a point first, as
on the Qdrant page. On the command line the scheme is
openai-embeddings://.
| Field | Default | Meaning |
|---|---|---|
url | https://api.openai.com/v1 | Base URL, as above. openai-embeddings://host is plain HTTP |
api_key | none | Sent as Authorization: Bearer |
model | required | Embedding model |
input | required | Template of the text to embed |
dimensions | none | Length of the vectors, for models that can shorten them |
max_request_bytes | 10 MiB | A larger batch is split into several requests |
request_timeout_ms | see right | Timeout of one request; 0: none. Unset: a request fails after 300 s without a response byte |
tls | none | TLS options as on http_bulk. JSON text in a URI |
auth | none | oauth2 or aws_sigv4 credentials as on http_bulk, in place of api_key. JSON text in a URI |
Amazon Bedrock
Bedrock serves the chat completions API at
https://bedrock-runtime.<region>.amazonaws.com/openai/v1. Use a Bedrock API key as
api_key, or sign the requests with the AWS credentials of the environment (needs a build
with the aws feature):
output:
request:
to:
openai_chat:
url: https://bedrock-runtime.us-west-2.amazonaws.com/openai/v1
auth:
aws_sigv4: { region: us-west-2, service: bedrock }
model: openai.gpt-oss-20b-1:0
prompt: "Summarise: ${payload:text}"
forward_to: { file: { path: "summaries.jsonl", format: raw } }
This was checked for its configuration only, not run against Bedrock.
From Rust
OpenAiChatConfig and OpenAiEmbeddingsConfig are the typed forms of the
tables above. Either converts into an Endpoint; it needs the http-bulk
feature and no registration.
use mq_bridge::endpoints::http_bulk::OpenAiChatConfig;
use mq_bridge::models::Endpoint;
let llm: Endpoint = OpenAiChatConfig {
model: "gpt-4o-mini".into(),
prompt: "Summarise: ${payload:text}".into(),
..Default::default()
}
.into();
What to know
- Templates are escaped. Text taken from a message is JSON-escaped, so a quote or a line break in it cannot break the request.
- A temporary error fails only its message. 408, 429 and 5xx are retryable.
List
retryafter thelookup; it repeats only the messages that failed, so answers already paid for are kept. - One request per batch means one fate per batch for embeddings. A request
the API refuses fails every message in it. OpenAI refuses an empty text, so
drop such messages with a
filterfirst. - Vectors are matched by position. The API must answer in input order, which OpenAI documents. A response with another number of vectors fails the batch for good.
- Batch size is the request size. OpenAI takes at most 2048 texts per
embeddings request; keep the route’s
batch_sizeat or below your provider’s limit. - Nothing is cached. A replayed message is sent, and billed, again. Use
deduplicationbefore the lookup if that matters. - No streaming here. For a token stream, use the
httpoutput withstream_response_to; see the LLM cookbook.
Both endpoints are the generic http_bulk query with the
requests filled in. An API with another shape, such as Anthropic’s
/v1/messages, is a query with per_message: true:
output:
http_bulk:
url: https://api.anthropic.com
headers:
x-api-key: <api key>
anthropic-version: "2023-06-01"
query:
path: /v1/messages
format: json_array
per_message: true
request: '{"model":"claude-haiku-4-5","max_tokens":256,"messages":[{"role":"user","content":"${payload:text}"}]}'
value: /content/0/text
error: /error/message
Connect plugin (Redpanda Connect components)
The Connect plugin runs Redpanda Connect
components inside an mq-bridge route. Its inputs and outputs become the connect endpoint, and
its processors become middleware that also works on native endpoints. mq-bridge keeps routing,
batching, retries, DLQ and deduplication.
Every component the plugin links is listed with its fields:
Prefer a native connector where one exists: it needs no plugin and avoids the hop into Go. Reach for Connect for systems mq-bridge has no endpoint for, such as Elasticsearch, OpenSearch, Cassandra, Google Pub/Sub, BigQuery or Azure storage, and for Bloblang mappings.
Install
The plugin is a separate install and is not part of mqb.
brew install marcomq/tap/mq-bridge-connect
conda install -c marcomq mq-bridge-connect
Either puts the libraries where mq-bridge discovers them. For the bindings, install the
language package and call its register() before starting routes:
pip install mq-bridge mq-bridge-connect
npm install mq-bridge mq-bridge-connect
From Rust, cargo add mq-bridge mq-bridge-connect and use ConnectFactory. The
plugin README covers Docker, manual
installs and offline builds.
Configure an endpoint
Name the component with connector; every other field is that component’s own configuration.
mqtt_to_elasticsearch:
input:
custom:
name: connect
config:
connector: mqtt
urls: ["tcp://localhost:1883"]
topics: ["orders"]
output:
middlewares:
- retry: { max_attempts: 3 }
custom:
name: connect
config:
connector: elasticsearch_v8
urls: ["http://localhost:9200"]
index: "orders"
action: "index"
id: '${! json("id") }'
A component often names the same thing differently per direction. Put such fields in an input
or output block inside config; the block matching the endpoint is merged in and the other is
dropped, so one configuration can describe both ends.
From a URL
mqb copy takes the component name after connect+. A _ in the name is written -:
mqb copy 'connect+mqtt://localhost:1883/orders' 'file:///tmp/out.jsonl'
mqb copy 'connect+amqp-0-9://guest:guest@localhost:5672/orders' 'file:///tmp/out.jsonl'
For mqtt, amqp_0_9, amqp_1, nats, nats_jetstream, pulsar, redis_streams,
redis_pubsub and redis_list, the authority is the broker address and the path is the topic,
queue or subject. Every other field is a query parameter under its own name.
A full Redpanda Connect document
If you already have a Redpanda Connect config, pass it as yaml, minus the end mq-bridge owns.
This form also takes a pipeline of processors and resource sections:
output:
custom:
name: connect
config:
yaml: |
pipeline:
processors:
- mapping: 'root = this.without("internal")'
output:
file:
path: /tmp/orders.jsonl
Processors as middleware
Some processors are exported as their own middleware, connect_<processor>, and run on any
endpoint. The processor list marks which.
They need plugin 0.1.1 or newer; 0.1.0 provides the endpoint only.
input:
kafka: { url: "localhost:9092", topic: "orders" }
middlewares:
- connect_mapping: 'root = this.merge({"received_at": now()})'
To run several processors in one call, or to share a cache between them, use the connect
middleware:
middlewares:
- connect:
processors:
- mapping: 'root = this.payload'
- dedupe: { cache: seen, key: '${! json("id") }' }
cache_resources:
- { label: seen, memory: { default_ttl: 5m } }
It accepts any processor that keeps, rewrites or drops each message. One that splits or merges
messages (unarchive, split, archive, group_by) belongs in an endpoint’s pipeline.
The middlewares are registered when the plugin loads. A route that uses them without a connect
endpoint needs the plugin loaded up front, as mqb’s plugins: list does.
Behaviour to know
- At-least-once. A nacked message is redelivered by the source; an uncommitted batch is nacked when the stream closes.
- Ordering. Up to
max_in_flight(default 64) source batches are in flight at once and have no order between them. Setmax_in_flight: 1where order matters. publish_timeout(default30s, outputs) bounds a send. When it elapses the send fails as retryable, soretryanddlqtake over; the timed-out batch may still arrive later.- No replies. Request/reply is not supported through a
connectendpoint. - Secrets. The app’s
balancedconfig security mode moves credentials out of aconnectconfig as it does for native endpoints; see Secrets. It cannot see inside ayamldocument, so reference secrets there from the environment or a file.
Details are in the plugin’s Semantics.
What is not included
Only Redpanda Connect packages that reach no Redpanda Community License code are linked, so the
bundled code is Apache-2.0 and MIT. That excludes, among others, Redpanda Connect’s kafka,
aws, snowflake and redpanda packages. Use mq-bridge’s native Kafka,
AWS and object storage connectors for those, and
Parquet on object storage for Snowflake. See the plugin’s
licensing notes.
Connect plugin: inputs
The 56 Redpanda Connect inputs the Connect plugin links, generated from the component specs of Redpanda Connect v4.110.0. Field descriptions are Redpanda Connect’s own, shortened to one sentence; follow Upstream documentation for the rest. Install and configuration forms are on the plugin page.
Use one as a route input:
input:
custom:
name: connect
config:
connector: mqtt # the component name below
urls: ["tcp://localhost:1883"]
topics: ["orders"]
| Category | Components |
|---|---|
| AWS | aws_cloudwatch_logs, aws_kinesis, aws_s3, aws_sqs |
| Azure | azure_blob_storage, azure_cosmosdb, azure_queue_storage, azure_table_storage |
| GCP | gcp_bigquery_select, gcp_cloud_storage, gcp_pubsub |
| Local | csv, file, parquet, stdin |
| Network | http_client, http_server, nanomsg, sftp, socket, socket_server, websocket |
| Services | amqp_0_9, amqp_1, aws_cloudwatch_logs, aws_kinesis, aws_s3, aws_sqs, azure_blob_storage, azure_queue_storage, azure_table_storage, beanstalkd, cassandra, cockroachdb_changefeed, discord, gcp_bigquery_select, gcp_cloud_storage, gcp_pubsub, git, hdfs, mongodb, mqtt, nats, nats_jetstream, nats_kv, nats_stream, nsq, pulsar, redis_list, redis_pubsub, redis_scan, redis_streams, spicedb_watch, sql_raw, sql_select, timeplus, twitter_search |
| Social | discord, twitter_search |
| SpiceDB | spicedb_watch |
| Utility | batched, broker, dynamic, generate, inproc, read_until, resource, sequence, subprocess |
amqp_0_9
Connects to an AMQP (0.91) queue. AMQP is a messaging protocol used by various message brokers, including RabbitMQ.
connector: amqp_0_9 · URI connect+amqp-0-9://
| Field | Type | Default | Description |
|---|---|---|---|
urls | list of string | required | A list of URLs to connect to. |
queue | string | required | An AMQP queue to consume from. |
consumer_tag | string | "" | A consumer tag. |
prefetch_count | int | 10 | The maximum number of pending messages to have consumed at a time. |
Advanced: queue_declare, bindings_declare, auto_ack, nack_reject_patterns, prefetch_size, tls.
amqp_1
Reads messages from an AMQP (1.0) server.
connector: amqp_1 · URI connect+amqp-1://
| Field | Type | Default | Description |
|---|---|---|---|
urls | list of string | A list of URLs to connect to. | |
source_address | string | required | The source address to consume from. |
Advanced: azure_renew_lock, read_header, credit, tls, sasl.
aws_cloudwatch_logs
Consumes log events from AWS CloudWatch Logs.
connector: aws_cloudwatch_logs · URI connect+aws-cloudwatch-logs://
| Field | Type | Default | Description |
|---|---|---|---|
log_group_name | string | required | The name of the CloudWatch Log Group to consume from. |
log_stream_names | list of string | An optional list of log stream names to consume from. | |
log_stream_prefix | string | An optional log stream name prefix to filter streams. | |
filter_pattern | string | An optional CloudWatch Logs filter pattern to apply when querying log events. | |
start_time | string | The time to start consuming log events from. | |
poll_interval | string | "5s" | The interval at which to poll for new log events. |
auto_replay_nacks | bool | true | Whether messages that are rejected (nacked) at the output level should be automatically replayed indefinitely, eventually resulting in back pressure if the cause of the rejections is persistent. |
Advanced: limit, structured_log, api_timeout, region, endpoint, tcp, credentials.
aws_kinesis
Receive messages from one or more Kinesis streams.
connector: aws_kinesis · URI connect+aws-kinesis://
| Field | Type | Default | Description |
|---|---|---|---|
streams | list of string | required | One or more Kinesis data streams to consume from. |
dynamodb | object | Determines the table used for storing and accessing the latest consumed sequence for shards, and for coordinating balanced consumers of streams. | |
checkpoint_limit | int | 1024 | The maximum gap between the in flight sequence versus the latest acknowledged sequence at a given time. |
auto_replay_nacks | bool | true | Whether messages that are rejected (nacked) at the output level should be automatically replayed indefinitely, eventually resulting in back pressure if the cause of the rejections is persistent. |
commit_period | string | "5s" | The period of time between each update to the checkpoint table. |
steal_grace_period | string | "2s" | Determines how long beyond the next commit period a client will wait when stealing a shard for the current owner to store a checkpoint. |
start_from_oldest | bool | true | Whether to consume from the oldest message when a sequence does not yet exist for the stream. |
batching | object | Allows you to configure a batching policy. |
Advanced: poll_period, enhanced_fan_out, rebalance_period, lease_period, region, endpoint, tcp, credentials.
aws_s3
Downloads objects within an Amazon S3 bucket, optionally filtered by a prefix, either by walking the items in the bucket or by streaming upload notifications in realtime.
connector: aws_s3 · URI connect+aws-s3://
| Field | Type | Default | Description |
|---|---|---|---|
bucket | string | "" | The bucket to consume from. |
prefix | string | "" | An optional path prefix, if set only objects with the prefix are consumed when walking a bucket. |
scanner | scanner | {"to_the_end": {}} | The scanner by which the stream of bytes consumed will be broken out into individual messages. |
sqs | object | Consume SQS messages in order to trigger key downloads. |
Advanced: region, endpoint, tcp, credentials, force_path_style_urls, delete_objects.
aws_sqs
Consume messages from an AWS SQS URL.
connector: aws_sqs · URI connect+aws-sqs://
| Field | Type | Default | Description |
|---|---|---|---|
url | string | required | The SQS URL to consume from. |
max_outstanding_messages | int | 1000 | The maximum number of outstanding pending messages to be consumed at a given time. |
Advanced: delete_message, reset_visibility, max_number_of_messages, wait_time_seconds, message_timeout, region, endpoint, tcp, credentials.
azure_blob_storage
Downloads objects within an Azure Blob Storage container, optionally filtered by a prefix.
connector: azure_blob_storage · URI connect+azure-blob-storage://
| Field | Type | Default | Description |
|---|---|---|---|
storage_account | string | "" | The storage account to access. |
storage_access_key | string | "" | The storage account access key. |
storage_connection_string | string | "" | A storage account connection string. |
storage_sas_token | string | "" | The storage account SAS token. |
container | string | required | The name of the container from which to download blobs. |
prefix | string | "" | An optional path prefix, if set only objects with the prefix are consumed. |
scanner | scanner | {"to_the_end": {}} | The scanner by which the stream of bytes consumed will be broken out into individual messages. |
targets_input | input | EXPERIMENTAL: An optional source of download targets, configured as a regular Redpanda Connect input. |
Advanced: delete_objects.
azure_cosmosdb
Executes a SQL query against Azure CosmosDB and creates a batch of messages from each page of items.
connector: azure_cosmosdb · URI connect+azure-cosmosdb://
| Field | Type | Default | Description |
|---|---|---|---|
endpoint | string | CosmosDB endpoint. | |
account_key | string | Account key. | |
connection_string | string | Connection string. | |
database | string | required | Database. |
container | string | required | Container. |
partition_keys_map | string | required | A Bloblang mapping which should evaluate to a single partition key value or an array of partition key values of type string, integer or boolean. |
query | string | required | The query to execute |
args_mapping | string | A Bloblang mapping that, for each message, creates a list of arguments to use with the query. | |
auto_replay_nacks | bool | true | Whether messages that are rejected (nacked) at the output level should be automatically replayed indefinitely, eventually resulting in back pressure if the cause of the rejections is persistent. |
Advanced: batch_count.
azure_queue_storage
Dequeue objects from an Azure Storage Queue.
connector: azure_queue_storage · URI connect+azure-queue-storage://
| Field | Type | Default | Description |
|---|---|---|---|
storage_account | string | "" | The storage account to access. |
storage_access_key | string | "" | The storage account access key. |
storage_connection_string | string | "" | A storage account connection string. |
queue_name | string | required | The name of the source storage queue. |
Advanced: dequeue_visibility_timeout, max_in_flight, track_properties.
azure_table_storage
Queries an Azure Storage Account Table, optionally with multiple filters.
connector: azure_table_storage · URI connect+azure-table-storage://
| Field | Type | Default | Description |
|---|---|---|---|
storage_account | string | "" | The storage account to access. |
storage_access_key | string | "" | The storage account access key. |
storage_connection_string | string | "" | A storage account connection string. |
storage_sas_token | string | "" | The storage account SAS token. |
table_name | string | required | The table to read messages from. |
Advanced: filter, select, page_size.
batched
Consumes data from a child input and applies a batching policy to the stream.
connector: batched · URI connect+batched://
| Field | Type | Default | Description |
|---|---|---|---|
child | input | required | The child input. |
policy | object | Allows you to configure a batching policy. |
beanstalkd
Reads messages from a Beanstalkd queue.
connector: beanstalkd · URI connect+beanstalkd://
| Field | Type | Default | Description |
|---|---|---|---|
address | string | required | An address to connect to. |
broker
Allows you to combine multiple inputs into a single stream of data, where each input will be read in parallel.
connector: broker · URI connect+broker://
| Field | Type | Default | Description |
|---|---|---|---|
inputs | list of input | required | A list of inputs to create. |
batching | object | Allows you to configure a batching policy. |
Advanced: copies.
cassandra
Executes a find query and creates a message for each row received.
connector: cassandra · URI connect+cassandra://
| Field | Type | Default | Description |
|---|---|---|---|
addresses | list of string | required | A list of Cassandra nodes to connect to. |
timeout | string | "600ms" | The client connection timeout. |
reconnect_interval | string | "60s" | Attempts to reconnect known DOWN nodes in every ReconnectInterval. |
query | string | required | A query to execute. |
auto_replay_nacks | bool | true | Whether messages that are rejected (nacked) at the output level should be automatically replayed indefinitely, eventually resulting in back pressure if the cause of the rejections is persistent. |
Advanced: tls, password_authenticator, disable_initial_host_lookup, max_retries, backoff, host_selection_policy, exponential_reconnection.
cockroachdb_changefeed
Listens to a CockroachDB Core Changefeed and creates a message for each row received.
connector: cockroachdb_changefeed · URI connect+cockroachdb-changefeed://
| Field | Type | Default | Description |
|---|---|---|---|
dsn | string | required | A Data Source Name to identify the target database. |
tables | list of string | required | CSV of tables to be included in the changefeed |
cursor_cache | string | A cache resource to use for storing the current latest cursor that has been successfully delivered, this allows Redpanda Connect to continue from that cursor upon restart, rather than consume the entire state of the table. | |
auto_replay_nacks | bool | true | Whether messages that are rejected (nacked) at the output level should be automatically replayed indefinitely, eventually resulting in back pressure if the cause of the rejections is persistent. |
Advanced: tls, options.
csv
Reads one or more CSV files as structured records following the format described in RFC 4180.
connector: csv · URI connect+csv://
| Field | Type | Default | Description |
|---|---|---|---|
paths | list of string | required | A list of file paths to read from. |
parse_header_row | bool | true | Whether to reference the first row as a header row. |
delimiter | string | "," | The delimiter to use for splitting values in each record. |
lazy_quotes | bool | false | If set to true, a quote may appear in an unquoted field and a non-doubled quote may appear in a quoted field. |
auto_replay_nacks | bool | true | Whether messages that are rejected (nacked) at the output level should be automatically replayed indefinitely, eventually resulting in back pressure if the cause of the rejections is persistent. |
Advanced: delete_on_finish, batch_count.
discord
Consumes messages posted in a Discord channel.
connector: discord · URI connect+discord://
| Field | Type | Default | Description |
|---|---|---|---|
channel_id | string | required | A discord channel ID to consume messages from. |
bot_token | string | required | A bot token used for authentication. |
cache | string | required | A cache resource to use for performing unread message backfills, the ID of the last message received will be stored in this cache and used for subsequent requests. |
auto_replay_nacks | bool | true | Whether messages that are rejected (nacked) at the output level should be automatically replayed indefinitely, eventually resulting in back pressure if the cause of the rejections is persistent. |
Advanced: cache_key.
dynamic
A special broker type where the inputs are identified by unique labels and can be created, changed and removed during runtime via a REST HTTP interface.
connector: dynamic · URI connect+dynamic://
| Field | Type | Default | Description |
|---|---|---|---|
inputs | map of input | {} | A map of inputs to statically create. |
prefix | string | "" | A path prefix for HTTP endpoints that are registered. |
file
Consumes data from files on disk, emitting messages according to a chosen codec.
connector: file · URI connect+file://
| Field | Type | Default | Description |
|---|---|---|---|
paths | list of string | required | A list of paths to consume sequentially. |
scanner | scanner | {"lines": {}} | The scanner by which the stream of bytes consumed will be broken out into individual messages. |
auto_replay_nacks | bool | true | Whether messages that are rejected (nacked) at the output level should be automatically replayed indefinitely, eventually resulting in back pressure if the cause of the rejections is persistent. |
Advanced: delete_on_finish.
gcp_bigquery_select
Executes a SELECT query against BigQuery and creates a message for each row received.
connector: gcp_bigquery_select · URI connect+gcp-bigquery-select://
| Field | Type | Default | Description |
|---|---|---|---|
project | string | required | GCP project where the query job will execute. |
credentials_json | string | "" | An optional field to set Google Service Account Credentials json. |
table | string | required | Fully-qualified BigQuery table name to query. |
columns | list of string | required | A list of columns to query. |
where | string | An optional where clause to add. | |
auto_replay_nacks | bool | true | Whether messages that are rejected (nacked) at the output level should be automatically replayed indefinitely, eventually resulting in back pressure if the cause of the rejections is persistent. |
job_labels | map of string | {} | A list of labels to add to the query job. |
priority | string | "" | The priority with which to schedule the query. |
args_mapping | string | An optional Bloblang mapping which should evaluate to an array of values matching in size to the number of placeholder arguments in the field where. | |
prefix | string | An optional prefix to prepend to the select query (before SELECT). | |
suffix | string | An optional suffix to append to the select query. |
gcp_cloud_storage
Downloads objects within a Google Cloud Storage bucket, optionally filtered by a prefix.
connector: gcp_cloud_storage · URI connect+gcp-cloud-storage://
| Field | Type | Default | Description |
|---|---|---|---|
bucket | string | required | The name of the bucket from which to download objects. |
prefix | string | "" | An optional path prefix, if set only objects with the prefix are consumed. |
credentials_json | string | "" | An optional field to set Google Service Account Credentials json. |
scanner | scanner | {"to_the_end": {}} | The scanner by which the stream of bytes consumed will be broken out into individual messages. |
Advanced: delete_objects.
gcp_pubsub
Consumes messages from a GCP Cloud Pub/Sub subscription.
connector: gcp_pubsub · URI connect+gcp-pubsub://
| Field | Type | Default | Description |
|---|---|---|---|
project | string | required | The project ID of the target subscription. |
credentials_json | string | "" | An optional field to set Google Service Account Credentials json. |
subscription | string | required | The target subscription ID. |
endpoint | string | "" | An optional endpoint to override the default of pubsub.googleapis.com:443. |
sync | bool | false | Enable synchronous pull mode. |
max_outstanding_messages | int | 1000 | The maximum number of outstanding pending messages to be consumed at a given time. |
max_outstanding_bytes | int | 1000000000 | The maximum number of outstanding pending messages to be consumed measured in bytes. |
Advanced: create_subscription.
generate
Generates messages at a given interval using a Bloblang mapping executed without a context. This allows you to generate messages for testing your pipeline configs.
connector: generate · URI connect+generate://
| Field | Type | Default | Description |
|---|---|---|---|
mapping | string | required | A Bloblang mapping to use for generating messages. |
interval | string | "1s" | The time interval at which messages should be generated, expressed either as a duration string or as a cron expression. |
count | int | 0 | An optional number of messages to generate, if set above 0 the specified number of messages is generated and then the input will shut down. |
batch_size | int | 1 | The number of generated messages that should be accumulated into each batch flushed at the specified interval. |
auto_replay_nacks | bool | true | Whether messages that are rejected (nacked) at the output level should be automatically replayed indefinitely, eventually resulting in back pressure if the cause of the rejections is persistent. |
git
A Git input that clones (or pulls) a repository and reads the repository contents.
connector: git · URI connect+git://
| Field | Type | Default | Description |
|---|---|---|---|
repository_url | string | required | The URL of the Git repository to clone. |
branch | string | "main" | The branch to check out. |
poll_interval | string | "10s" | Duration between polling attempts |
include_patterns | list of string | [] | A list of file patterns to include (e.g., ‘**/.md’, ‘configs/.yaml’). |
exclude_patterns | list of string | [] | A list of file patterns to exclude (e.g., ‘.git/’, ‘/*.png’). |
max_file_size | int | 10485760 | The maximum size of files to include in bytes. |
checkpoint_cache | string | A cache resource to store the last processed commit hash, allowing the input to resume from where it left off after a restart. | |
checkpoint_key | string | "git_last_commit" | The key to use when storing the last processed commit hash in the cache. |
auth | object | Authentication options for the Git repository | |
auto_replay_nacks | bool | true | Whether messages that are rejected (nacked) at the output level should be automatically replayed indefinitely, eventually resulting in back pressure if the cause of the rejections is persistent. |
hdfs
Reads files from a HDFS directory, where each discrete file will be consumed as a single message payload.
connector: hdfs · URI connect+hdfs://
| Field | Type | Default | Description |
|---|---|---|---|
hosts | list of string | required | A list of target host addresses to connect to. |
user | string | "" | A user ID to connect as. |
directory | string | required | The directory to consume from. |
http_client
Connects to a server and continuously performs requests for a single message.
connector: http_client · URI connect+http-client://
| Field | Type | Default | Description |
|---|---|---|---|
url | string | required | The URL to connect to. |
verb | string | "GET" | A verb to connect with |
headers | map of string | {} | A map of headers to add to the request. |
rate_limit | string | An optional rate limit to throttle requests by. | |
timeout | string | "5s" | A static timeout to apply to requests. |
payload | string | An optional payload to deliver for each request. | |
stream | object | Allows you to set streaming mode, where requests are kept open and messages are processed line-by-line. | |
auto_replay_nacks | bool | true | Whether messages that are rejected (nacked) at the output level should be automatically replayed indefinitely, eventually resulting in back pressure if the cause of the rejections is persistent. |
Advanced: metadata, dump_request_log_level, oauth, oauth2, basic_auth, jwt, tls, extract_headers, retry_period, max_retry_backoff, retries, follow_redirects, backoff_on, drop_on, successful_on, proxy_url, disable_http2, drop_empty_bodies.
http_server
Receive messages POSTed over HTTP(S). HTTP 2.0 is supported when using TLS, which is enabled when key and cert files are specified.
connector: http_server · URI connect+http-server://
| Field | Type | Default | Description |
|---|---|---|---|
address | string | "" | An alternative address to host from. |
path | string | "/post" | The endpoint path to listen for POST requests. |
ws_path | string | "/post/ws" | The endpoint path to create websocket connections from. |
allowed_verbs | list of string | ["POST"] | An array of verbs that are allowed for the path endpoint. |
timeout | string | "5s" | Timeout for requests. |
rate_limit | string | "" | An optional rate limit to throttle requests by. |
Advanced: ws_welcome_message, ws_rate_limit_message, cert_file, key_file, cors, sync_response, tcp.
inproc
Directly connect to an output within a Redpanda Connect process by referencing it by a chosen ID.
connector: inproc · URI connect+inproc://
Takes a single string value, not a map of fields.
mongodb
Executes a query and creates a message for each document received.
connector: mongodb · URI connect+mongodb://
| Field | Type | Default | Description |
|---|---|---|---|
url | string | required | The URL of the target MongoDB server. |
database | string | required | The name of the target MongoDB database. |
username | string | "" | The username to connect to the database. |
password | string | "" | The password to connect to the database. |
collection | string | required | The collection to select from. |
query | string | required | Bloblang expression describing MongoDB query. |
auto_replay_nacks | bool | true | Whether messages that are rejected (nacked) at the output level should be automatically replayed indefinitely, eventually resulting in back pressure if the cause of the rejections is persistent. |
batch_size | int | A explicit number of documents to batch up before flushing them for processing. | |
sort | map of int | An object specifying fields to sort by, and the respective sort order (1 ascending, -1 descending). | |
limit | int | An explicit maximum number of documents to return. |
Advanced: app_name, aws, operation, json_marshal_mode.
mqtt
Subscribe to topics on MQTT brokers.
connector: mqtt · URI connect+mqtt://
| Field | Type | Default | Description |
|---|---|---|---|
urls | list of string | required | A list of URLs to connect to. |
client_id | string | "" | An identifier for the client connection. |
connect_timeout | string | "30s" | The maximum amount of time to wait in order to establish a connection before the attempt is abandoned. |
topics | list of string | required | A list of topics to consume from. |
auto_replay_nacks | bool | true | Whether messages that are rejected (nacked) at the output level should be automatically replayed indefinitely, eventually resulting in back pressure if the cause of the rejections is persistent. |
Advanced: dynamic_client_id_suffix, will, user, password, keepalive, tls, qos, clean_session.
nanomsg
Consumes messages via Nanomsg sockets (scalability protocols).
connector: nanomsg · URI connect+nanomsg://
| Field | Type | Default | Description |
|---|---|---|---|
urls | list of string | required | A list of URLs to connect to (or as). |
bind | bool | true | Whether the URLs provided should be connected to, or bound as. |
socket_type | string | "PULL" | The socket type to use. |
auto_replay_nacks | bool | true | Whether messages that are rejected (nacked) at the output level should be automatically replayed indefinitely, eventually resulting in back pressure if the cause of the rejections is persistent. |
sub_filters | list of string | [] | A list of subscription topic filters to use when consuming from a SUB socket. |
Advanced: poll_timeout.
nats
Subscribe to a NATS subject.
connector: nats · URI connect+nats://
| Field | Type | Default | Description |
|---|---|---|---|
urls | list of string | required | A list of URLs to connect to. |
subject | string | required | A subject to consume from. |
queue | string | An optional queue group to consume as. | |
auto_replay_nacks | bool | true | Whether messages that are rejected (nacked) at the output level should be automatically replayed indefinitely, eventually resulting in back pressure if the cause of the rejections is persistent. |
send_ack | bool | true | Control whether ACKS are sent as a reply to each message. |
Advanced: max_reconnects, nak_delay, prefetch_count, tls, tls_handshake_first, auth, extract_tracing_map.
nats_jetstream
Reads messages from NATS JetStream subjects.
connector: nats_jetstream · URI connect+nats-jetstream://
| Field | Type | Default | Description |
|---|---|---|---|
urls | list of string | required | A list of URLs to connect to. |
queue | string | An optional queue group to consume as. | |
subject | string | A subject to consume from. | |
durable | string | Preserve the state of your consumer under a durable name. | |
stream | string | A stream to consume from. | |
bind | bool | Indicates that the subscription should use an existing consumer. | |
deliver | string | "all" | Determines which messages to deliver when consuming without a durable subscriber. |
Advanced: max_reconnects, create_stream, ack_wait, max_ack_pending, tls, tls_handshake_first, auth, extract_tracing_map.
nats_kv
Watches for updates in a NATS key-value bucket.
connector: nats_kv · URI connect+nats-kv://
| Field | Type | Default | Description |
|---|---|---|---|
urls | list of string | required | A list of URLs to connect to. |
bucket | string | required | The name of the KV bucket. |
key | string | ">" | Key to watch for updates, can include wildcards. |
auto_replay_nacks | bool | true | Whether messages that are rejected (nacked) at the output level should be automatically replayed indefinitely, eventually resulting in back pressure if the cause of the rejections is persistent. |
Advanced: max_reconnects, ignore_deletes, include_history, meta_only, tls, tls_handshake_first, auth.
nats_stream
Subscribe to a NATS Stream subject. Joining a queue is optional and allows multiple clients of a subject to consume using queue semantics.
connector: nats_stream · URI connect+nats-stream://
| Field | Type | Default | Description |
|---|---|---|---|
urls | list of string | required | A list of URLs to connect to. |
cluster_id | string | required | The ID of the cluster to consume from. |
client_id | string | "" | A client ID to connect as. |
queue | string | "" | The queue to consume from. |
subject | string | "" | A subject to consume from. |
durable_name | string | "" | Preserve the state of your consumer under a durable name. |
unsubscribe_on_close | bool | false | Whether the subscription should be destroyed when this client disconnects. |
Advanced: max_reconnects, start_from_oldest, max_inflight, ack_wait, tls, tls_handshake_first, auth, extract_tracing_map.
nsq
Subscribe to an NSQ instance topic and channel.
connector: nsq · URI connect+nsq://
| Field | Type | Default | Description |
|---|---|---|---|
nsqd_tcp_addresses | list of string | required | A list of nsqd addresses to connect to. |
lookupd_http_addresses | list of string | required | A list of nsqlookupd addresses to connect to. |
topic | string | required | The topic to consume from. |
channel | string | required | The channel to consume from. |
user_agent | string | A user agent to assume when connecting. | |
max_in_flight | int | 100 | The maximum number of pending messages to consume at any given time. |
max_attempts | int | 5 | The maximum number of attempts to successfully consume a messages. |
Advanced: tls.
parquet
Reads and decodes Parquet files into a stream of structured messages.
connector: parquet · URI connect+parquet://
| Field | Type | Default | Description |
|---|---|---|---|
paths | list of string | required | A list of file paths to read from. |
auto_replay_nacks | bool | true | Whether messages that are rejected (nacked) at the output level should be automatically replayed indefinitely, eventually resulting in back pressure if the cause of the rejections is persistent. |
Advanced: batch_count.
pulsar
Reads messages from an Apache Pulsar server.
connector: pulsar · URI connect+pulsar://
| Field | Type | Default | Description |
|---|---|---|---|
url | string | required | A URL to connect to. |
topics | list of string | A list of topics to subscribe to. | |
topics_pattern | string | A regular expression matching the topics to subscribe to. | |
subscription_name | string | required | Specify the subscription name for this consumer. |
subscription_type | string | "shared" | Specify the subscription type for this consumer. |
subscription_initial_position | string | "latest" | Specify the subscription initial position for this consumer. |
tls | object | Specify the path to a custom CA certificate to trust broker TLS service. |
Advanced: auth.
read_until
Reads messages from a child input until a consumed message passes a Bloblang query, at which point the input closes. It is also possible to configure a timeout after which the input is closed if no new messages arrive in that period.
connector: read_until · URI connect+read-until://
| Field | Type | Default | Description |
|---|---|---|---|
input | input | required | The child input to consume from. |
check | string | A Bloblang query that should return a boolean value indicating whether the input should now be closed. | |
idle_timeout | string | The maximum amount of time without receiving new messages after which the input is closed. | |
restart_input | bool | false | Whether the input should be reopened if it closes itself before the condition has resolved to true. |
redis_list
Pops messages from the beginning of a Redis list using the BLPop command.
connector: redis_list · URI connect+redis-list://
| Field | Type | Default | Description |
|---|---|---|---|
url | string | required | The URL of the target Redis server. |
key | string | required | The key of a list to read from. |
auto_replay_nacks | bool | true | Whether messages that are rejected (nacked) at the output level should be automatically replayed indefinitely, eventually resulting in back pressure if the cause of the rejections is persistent. |
Advanced: kind, master, client_name, tls, max_in_flight, timeout, command.
redis_pubsub
Consume from a Redis publish/subscribe channel using either the SUBSCRIBE or PSUBSCRIBE commands.
connector: redis_pubsub · URI connect+redis-pubsub://
| Field | Type | Default | Description |
|---|---|---|---|
url | string | required | The URL of the target Redis server. |
channels | list of string | required | A list of channels to consume from. |
use_patterns | bool | false | Whether to use the PSUBSCRIBE command, allowing for glob-style patterns within target channel names. |
auto_replay_nacks | bool | true | Whether messages that are rejected (nacked) at the output level should be automatically replayed indefinitely, eventually resulting in back pressure if the cause of the rejections is persistent. |
Advanced: kind, master, client_name, tls.
redis_scan
Scans the set of keys in the current selected database and gets their values, using the Scan and Get commands.
connector: redis_scan · URI connect+redis-scan://
| Field | Type | Default | Description |
|---|---|---|---|
url | string | required | The URL of the target Redis server. |
auto_replay_nacks | bool | true | Whether messages that are rejected (nacked) at the output level should be automatically replayed indefinitely, eventually resulting in back pressure if the cause of the rejections is persistent. |
match | string | "" | Iterates only elements matching the optional glob-style pattern. |
Advanced: kind, master, client_name, tls.
redis_streams
Pulls messages from Redis (v5.0+) streams with the XREADGROUP command. The client_id should be unique for each consumer of a group.
connector: redis_streams · URI connect+redis-streams://
| Field | Type | Default | Description |
|---|---|---|---|
url | string | required | The URL of the target Redis server. |
body_key | string | "body" | The field key to extract the raw message from. |
streams | list of string | required | A list of streams to consume from. |
auto_replay_nacks | bool | true | Whether messages that are rejected (nacked) at the output level should be automatically replayed indefinitely, eventually resulting in back pressure if the cause of the rejections is persistent. |
limit | int | 10 | The maximum number of messages to consume from a single request. |
client_id | string | "" | An identifier for the client connection. |
consumer_group | string | "" | An identifier for the consumer group of the stream. |
Advanced: kind, master, client_name, tls, create_streams, start_from_oldest, commit_period, timeout.
resource
Resource is an input type that channels messages from a resource input, identified by its name.
connector: resource · URI connect+resource://
Takes a single string value, not a map of fields.
sequence
Reads messages from a sequence of child inputs, starting with the first and once that input gracefully terminates starts consuming from the next, and so on.
connector: sequence · URI connect+sequence://
| Field | Type | Default | Description |
|---|---|---|---|
inputs | list of input | required | An array of inputs to read from sequentially. |
Advanced: sharded_join.
sftp
Consumes files from an SFTP server.
connector: sftp · URI connect+sftp://
| Field | Type | Default | Description |
|---|---|---|---|
address | string | required | The address of the server to connect to. |
credentials | object | The credentials to use to log into the target server. | |
paths | list of string | required | A list of paths to consume sequentially. |
auto_replay_nacks | bool | true | Whether messages that are rejected (nacked) at the output level should be automatically replayed indefinitely, eventually resulting in back pressure if the cause of the rejections is persistent. |
scanner | scanner | {"to_the_end": {}} | The scanner by which the stream of bytes consumed will be broken out into individual messages. |
watcher | object | An experimental mode whereby the input will periodically scan the target paths for new files and consume them, when all files are consumed the input will continue polling for new files. |
Advanced: connection_timeout, max_sftp_sessions, delete_on_finish.
socket
Connects to a tcp or unix socket and consumes a continuous stream of messages.
connector: socket · URI connect+socket://
| Field | Type | Default | Description |
|---|---|---|---|
network | string | required | A network type to assume (unix|tcp). |
address | string | required | The address to connect to. |
auto_replay_nacks | bool | true | Whether messages that are rejected (nacked) at the output level should be automatically replayed indefinitely, eventually resulting in back pressure if the cause of the rejections is persistent. |
open_message_mapping | string | An optional Bloblang mapping which should evaluate to a string which will be sent upstream before the downstream data flow starts. | |
scanner | scanner | {"lines": {}} | The scanner by which the stream of bytes consumed will be broken out into individual messages. |
Advanced: tls.
socket_server
Creates a server that receives a stream of messages over a TCP, UDP or Unix socket.
connector: socket_server · URI connect+socket-server://
| Field | Type | Default | Description |
|---|---|---|---|
network | string | required | A network type to accept. |
address | string | required | The address to listen from. |
address_cache | string | An optional cache within which this input should write it’s bound address once known. | |
tls | object | TLS specific configuration, valid when the network is set to tls. | |
auto_replay_nacks | bool | true | Whether messages that are rejected (nacked) at the output level should be automatically replayed indefinitely, eventually resulting in back pressure if the cause of the rejections is persistent. |
scanner | scanner | {"lines": {}} | The scanner by which the stream of bytes consumed will be broken out into individual messages. |
Advanced: tcp.
spicedb_watch
Consume messages from the Watch API from SpiceDB.
connector: spicedb_watch · URI connect+spicedb-watch://
| Field | Type | Default | Description |
|---|---|---|---|
endpoint | string | required | The SpiceDB endpoint. |
bearer_token | string | "" | The SpiceDB Bearer token used to authenticate against the SpiceDB instance. |
cache | string | required | A cache resource to use for performing unread message backfills, the ID of the last message received will be stored in this cache and used for subsequent requests. |
Advanced: max_receive_message_bytes, cache_key, tls.
sql_raw
Executes a select query and creates a message for each row received.
connector: sql_raw · URI connect+sql-raw://
| Field | Type | Default | Description |
|---|---|---|---|
driver | string | required | A database driver to use. |
dsn | string | required | A Data Source Name to identify the target database. |
query | string | required | The query to execute. |
args_mapping | string | An optional Bloblang mapping which should evaluate to an array of values matching in size to the number of placeholder arguments in the field query. | |
auto_replay_nacks | bool | true | Whether messages that are rejected (nacked) at the output level should be automatically replayed indefinitely, eventually resulting in back pressure if the cause of the rejections is persistent. |
Advanced: init_files, init_statement, conn_max_idle_time, conn_max_life_time, conn_max_idle, conn_max_open.
sql_select
Executes a select query and creates a message for each row received.
connector: sql_select · URI connect+sql-select://
| Field | Type | Default | Description |
|---|---|---|---|
driver | string | required | A database driver to use. |
dsn | string | required | A Data Source Name to identify the target database. |
table | string | required | The table to select from. |
columns | list of string | required | A list of columns to select. |
where | string | An optional where clause to add. | |
args_mapping | string | An optional Bloblang mapping which should evaluate to an array of values matching in size to the number of placeholder arguments in the field where. | |
auto_replay_nacks | bool | true | Whether messages that are rejected (nacked) at the output level should be automatically replayed indefinitely, eventually resulting in back pressure if the cause of the rejections is persistent. |
Advanced: prefix, suffix, init_files, init_statement, conn_max_idle_time, conn_max_life_time, conn_max_idle, conn_max_open.
stdin
Consumes data piped to stdin, chopping it into individual messages according to the specified scanner.
connector: stdin · URI connect+stdin://
| Field | Type | Default | Description |
|---|---|---|---|
scanner | scanner | {"lines": {}} | The scanner by which the stream of bytes consumed will be broken out into individual messages. |
auto_replay_nacks | bool | true | Whether messages that are rejected (nacked) at the output level should be automatically replayed indefinitely, eventually resulting in back pressure if the cause of the rejections is persistent. |
subprocess
Beta. Executes a command, runs it as a subprocess, and consumes messages from it over stdout.
connector: subprocess · URI connect+subprocess://
| Field | Type | Default | Description |
|---|---|---|---|
name | string | required | The command to execute as a subprocess. |
args | list of string | [] | A list of arguments to provide the command. |
codec | string | "lines" | The way in which messages should be consumed from the subprocess. |
restart_on_exit | bool | false | Whether the command should be re-executed each time the subprocess ends. |
Advanced: max_buffer.
timeplus
Executes a query on Timeplus Enterprise and creates a message from each row received
connector: timeplus · URI connect+timeplus://
| Field | Type | Default | Description |
|---|---|---|---|
query | string | required | The query to run |
url | string | "tcp://localhost:8463" | The url should always include schema and host. |
workspace | string | ID of the workspace. | |
apikey | string | The API key. | |
username | string | The username. | |
password | string | The password. |
twitter_search
Experimental. Consumes tweets matching a given search using the Twitter recent search V2 API.
connector: twitter_search · URI connect+twitter-search://
| Field | Type | Default | Description |
|---|---|---|---|
query | string | required | A search expression to use. |
tweet_fields | list of string | [] | An optional list of additional fields to obtain for each tweet, by default only the fields id and text are returned. |
poll_period | string | "1m" | The length of time (as a duration string) to wait between each search request. |
backfill_period | string | "5m" | A duration string indicating the maximum age of tweets to acquire when starting a search. |
cache | string | required | A cache resource to use for request pagination. |
api_key | string | required | An API key for OAuth 2.0 authentication. |
api_secret | string | required | An API secret for OAuth 2.0 authentication. |
Advanced: cache_key, rate_limit.
websocket
Connects to a websocket server and continuously receives messages.
connector: websocket · URI connect+websocket://
| Field | Type | Default | Description |
|---|---|---|---|
url | string | required | The URL to connect to. |
auto_replay_nacks | bool | true | Whether messages that are rejected (nacked) at the output level should be automatically replayed indefinitely, eventually resulting in back pressure if the cause of the rejections is persistent. |
Advanced: proxy_url, open_message, open_message_type, max_message_size, tls, connection, oauth, basic_auth, jwt.
Connect plugin: outputs
The 68 Redpanda Connect outputs the Connect plugin links, generated from the component specs of Redpanda Connect v4.110.0. Field descriptions are Redpanda Connect’s own, shortened to one sentence; follow Upstream documentation for the rest. Install and configuration forms are on the plugin page.
Use one as a route output:
output:
custom:
name: connect
config:
connector: mqtt # the component name below
urls: ["tcp://localhost:1883"]
topic: "orders"
amqp_0_9
Sends messages to an AMQP (0.91) exchange. AMQP is a messaging protocol used by various message brokers, including RabbitMQ.
connector: amqp_0_9 · URI connect+amqp-0-9://
| Field | Type | Default | Description |
|---|---|---|---|
urls | list of string | required | A list of URLs to connect to. |
exchange | string | required | An AMQP exchange to publish to. |
key | string | "" | The binding key to set for each message. |
type | string | "" | The type property to set for each message. |
metadata | object | Specify criteria for which metadata values are attached to messages as headers. | |
max_in_flight | int | 64 | The maximum number of messages to have in flight at a given time. |
Advanced: exchange_declare, content_type, content_encoding, correlation_id, reply_to, expiration, message_id, user_id, app_id, priority, persistent, mandatory, immediate, timeout, tls.
amqp_1
Sends messages to an AMQP (1.0) server.
connector: amqp_1 · URI connect+amqp-1://
| Field | Type | Default | Description |
|---|---|---|---|
urls | list of string | A list of URLs to connect to. | |
target_address | string | "" | The target address to write to. |
max_in_flight | int | 64 | The maximum number of messages to have in flight at a given time. |
metadata | object | Specify criteria for which metadata values are attached to messages as headers. |
Advanced: tls, application_properties_map, sasl, content_type, persistent, target_capabilities, message_properties_to, message_properties_message_id, message_properties_correlation_id, message_properties_subject, message_properties_reply_to, message_properties_group_id, message_properties_group_sequence, message_properties_reply_to_group_id, message_properties_user_id, message_properties_content_type, message_properties_content_encoding.
arc
Writes data to an Arc database via the msgpack ingestion endpoint.
connector: arc · URI connect+arc://
| Field | Type | Default | Description |
|---|---|---|---|
base_url | string | required | Base URL of the target service (e.g., https://api.example.com). |
timeout | string | "5s" | HTTP request timeout. |
token | string | Bearer token for authentication. | |
database | string | "default" | The target database name. |
measurement | string | required | The measurement (table) name. |
format | string | "columnar" | The payload format. |
tags_mapping | string | An optional Bloblang mapping to extract tags from each message. | |
compression | string | "zstd" | Compression algorithm for the request body. |
max_in_flight | int | 64 | The maximum number of messages to have in flight at a given time. |
batching | object | Allows you to configure a batching policy. |
Advanced: tls, proxy_url, disable_http2, tps_limit, tps_burst, backoff, tcp, http, access_log_level, access_log_body_limit, timestamp_field, timestamp_unit.
aws_kinesis
Sends messages to a Kinesis stream.
connector: aws_kinesis · URI connect+aws-kinesis://
| Field | Type | Default | Description |
|---|---|---|---|
stream | string | required | The stream to publish messages to. |
partition_key | string | required | A required key for partitioning messages. |
max_in_flight | int | 64 | The maximum number of parallel message batches to have in flight at any given time. |
batching | object | Allows you to configure a batching policy. |
Advanced: hash_key, region, endpoint, tcp, credentials, max_retries, backoff.
aws_kinesis_firehose
Sends messages to a Kinesis Firehose delivery stream.
connector: aws_kinesis_firehose · URI connect+aws-kinesis-firehose://
| Field | Type | Default | Description |
|---|---|---|---|
stream | string | required | The stream to publish messages to. |
max_in_flight | int | 64 | The maximum number of messages to have in flight at a given time. |
batching | object | Allows you to configure a batching policy. |
Advanced: region, endpoint, tcp, credentials, max_retries, backoff.
aws_s3
Sends message parts as objects to an Amazon S3 bucket. Each object is uploaded with the path specified with the path field.
connector: aws_s3 · URI connect+aws-s3://
| Field | Type | Default | Description |
|---|---|---|---|
bucket | string | required | The bucket to upload messages to. |
path | string | "${!counter()}-${!timestamp_unix_nano… | The path of each message to upload. |
tags | map of string | {} | Key/value pairs to store with the object as tags. |
content_type | string | "application/octet-stream" | The content type to set for each object. |
metadata | object | Specify criteria for which metadata values are attached to objects as headers. | |
max_in_flight | int | 64 | The maximum number of messages to have in flight at a given time. |
batching | object | Allows you to configure a batching policy. |
Advanced: content_encoding, cache_control, content_disposition, content_language, website_redirect_location, storage_class, kms_key_id, checksum_algorithm, server_side_encryption, force_path_style_urls, timeout, object_canned_acl, region, endpoint, tcp, credentials.
aws_sns
Sends messages to an AWS SNS topic.
connector: aws_sns · URI connect+aws-sns://
| Field | Type | Default | Description |
|---|---|---|---|
topic_arn | string | required | The topic to publish to. |
message_group_id | string | An optional group ID to set for messages. | |
message_deduplication_id | string | An optional deduplication ID to set for messages. | |
subject | string | An optional subject to set for messages. | |
max_in_flight | int | 64 | The maximum number of messages to have in flight at a given time. |
metadata | object | Specify criteria for which metadata values are sent as headers. |
Advanced: timeout, region, endpoint, tcp, credentials.
aws_sqs
Sends messages to an SQS queue.
connector: aws_sqs · URI connect+aws-sqs://
| Field | Type | Default | Description |
|---|---|---|---|
url | string | required | The URL of the target SQS queue. |
message_group_id | string | An optional group ID to set for messages. | |
message_deduplication_id | string | An optional deduplication ID to set for messages. | |
delay_seconds | string | An optional delay time in seconds for message. | |
max_in_flight | int | 64 | The maximum number of parallel message batches to have in flight at any given time. |
metadata | object | Specify criteria for which metadata values are sent as headers. | |
batching | object | Allows you to configure a batching policy. |
Advanced: max_records_per_request, region, endpoint, tcp, credentials, max_retries, backoff.
azure_blob_storage
Sends message parts as objects to an Azure Blob Storage Account container. Each object is uploaded with the filename specified with the container field.
connector: azure_blob_storage · URI connect+azure-blob-storage://
| Field | Type | Default | Description |
|---|---|---|---|
storage_account | string | "" | The storage account to access. |
storage_access_key | string | "" | The storage account access key. |
storage_connection_string | string | "" | A storage account connection string. |
storage_sas_token | string | "" | The storage account SAS token. |
container | string | required | The container for uploading the messages to. |
path | string | "${!counter()}-${!timestamp_unix_nano… | The path of each message to upload. |
max_in_flight | int | 64 | The maximum number of messages to have in flight at a given time. |
Advanced: blob_type, public_access_level.
azure_cosmosdb
Creates or updates messages as JSON documents in Azure CosmosDB.
connector: azure_cosmosdb · URI connect+azure-cosmosdb://
| Field | Type | Default | Description |
|---|---|---|---|
endpoint | string | CosmosDB endpoint. | |
account_key | string | Account key. | |
connection_string | string | Connection string. | |
database | string | required | Database. |
container | string | required | Container. |
partition_keys_map | string | required | A Bloblang mapping which should evaluate to a single partition key value or an array of partition key values of type string, integer or boolean. |
operation | string | "Create" | Operation. |
item_id | string | ID of item to replace or delete. | |
batching | object | Allows you to configure a batching policy. | |
max_in_flight | int | 64 | The maximum number of messages to have in flight at a given time. |
Advanced: patch_operations, patch_condition, auto_id.
azure_data_lake_gen2
Sends message parts as files to an Azure Data Lake Gen2 filesystem. Each file is uploaded with the filename specified with the path field.
connector: azure_data_lake_gen2 · URI connect+azure-data-lake-gen2://
| Field | Type | Default | Description |
|---|---|---|---|
storage_account | string | "" | The storage account to access. |
storage_access_key | string | "" | The storage account access key. |
storage_connection_string | string | "" | A storage account connection string. |
storage_sas_token | string | "" | The storage account SAS token. |
filesystem | string | required | The data lake storage filesystem name for uploading the messages to. |
path | string | "${!counter()}-${!timestamp_unix_nano… | The path of each message to upload within the filesystem. |
max_in_flight | int | 64 | The maximum number of messages to have in flight at a given time. |
azure_queue_storage
Sends messages to an Azure Storage Queue.
connector: azure_queue_storage · URI connect+azure-queue-storage://
| Field | Type | Default | Description |
|---|---|---|---|
storage_account | string | "" | The storage account to access. |
storage_access_key | string | "" | The storage account access key. |
storage_connection_string | string | "" | A storage account connection string. |
storage_sas_token | string | "" | The storage account SAS token. |
queue_name | string | required | The name of the target Queue Storage queue. |
max_in_flight | int | 64 | The maximum number of parallel message batches to have in flight at any given time. |
batching | object | Allows you to configure a batching policy. |
Advanced: ttl.
azure_table_storage
Stores messages in an Azure Table Storage table.
connector: azure_table_storage · URI connect+azure-table-storage://
| Field | Type | Default | Description |
|---|---|---|---|
storage_account | string | "" | The storage account to access. |
storage_access_key | string | "" | The storage account access key. |
storage_connection_string | string | "" | A storage account connection string. |
storage_sas_token | string | "" | The storage account SAS token. |
table_name | string | required | The table to store messages into. |
partition_key | string | "" | The partition key. |
row_key | string | "" | The row key. |
properties | map of string | {} | A map of properties to store into the table. |
max_in_flight | int | 64 | The maximum number of parallel message batches to have in flight at any given time. |
batching | object | Allows you to configure a batching policy. |
Advanced: transaction_type, timeout.
beanstalkd
Write messages to a Beanstalkd queue.
connector: beanstalkd · URI connect+beanstalkd://
| Field | Type | Default | Description |
|---|---|---|---|
address | string | required | An address to connect to. |
max_in_flight | int | 64 | The maximum number of messages to have in flight at a given time. |
broker
Allows you to route messages to multiple child outputs using a range of brokering patterns.
connector: broker · URI connect+broker://
| Field | Type | Default | Description |
|---|---|---|---|
pattern | string | "fan_out" | The brokering pattern to use. |
outputs | list of output | required | A list of child outputs to broker. |
batching | object | Allows you to configure a batching policy. |
Advanced: copies.
cache
Stores each message in a cache.
connector: cache · URI connect+cache://
| Field | Type | Default | Description |
|---|---|---|---|
target | string | required | The target cache to store messages in. |
key | string | "${!count(\"items\")}-${!timestamp_un… | The key to store messages by, function interpolation should be used in order to derive a unique key for each message. |
max_in_flight | int | 64 | The maximum number of messages to have in flight at a given time. |
Advanced: ttl.
cassandra
Runs a query against a Cassandra database for each message in order to insert data.
connector: cassandra · URI connect+cassandra://
| Field | Type | Default | Description |
|---|---|---|---|
addresses | list of string | required | A list of Cassandra nodes to connect to. |
timeout | string | "600ms" | The client connection timeout. |
reconnect_interval | string | "60s" | Attempts to reconnect known DOWN nodes in every ReconnectInterval. |
query | string | required | A query to execute for each message. |
args_mapping | string | A Bloblang mapping that can be used to provide arguments to Cassandra queries. | |
max_in_flight | int | 64 | The maximum number of messages to have in flight at a given time. |
batching | object | Allows you to configure a batching policy. |
Advanced: tls, password_authenticator, disable_initial_host_lookup, max_retries, backoff, host_selection_policy, exponential_reconnection, consistency, logged_batch.
couchbase
Performs operations against Couchbase for each message, allowing you to store or delete data.
connector: couchbase · URI connect+couchbase://
| Field | Type | Default | Description |
|---|---|---|---|
url | string | required | Couchbase connection string. |
username | string | Username to connect to the cluster. | |
password | string | Password to connect to the cluster. | |
bucket | string | required | Couchbase bucket. |
id | string | required | Document id. |
content | string | Document content. | |
operation | string | "upsert" | Couchbase operation to perform. |
max_in_flight | int | 64 | The maximum number of messages to have in flight at a given time. |
batching | object | Allows you to configure a batching policy. |
Advanced: collection, scope, transcoder, timeout, ttl.
cyborgdb
Inserts items into a CyborgDB encrypted vector index.
connector: cyborgdb · URI connect+cyborgdb://
| Field | Type | Default | Description |
|---|---|---|---|
max_in_flight | int | 64 | The maximum number of messages to have in flight at a given time. |
batching | object | Allows you to configure a batching policy. | |
host | string | required | The host for the CyborgDB instance. |
api_key | string | required | The CyborgDB API key for authentication. |
index_name | string | "redpanda-vectors" | The name of the index to write to. |
index_key | string | required | The base64-encoded encryption key for the index. |
operation | string | "upsert" | The operation to perform against the CyborgDB index. |
id | string | required | The ID for the vector entry in CyborgDB. |
vector_mapping | string | The mapping to extract out the vector from the document. | |
metadata_mapping | string | An optional mapping of message to metadata for the vector entry. |
Advanced: create_if_missing.
cypher
The cypher output type writes a batch of messages to any graph database that supports the Neo4j or Bolt protocols.
connector: cypher · URI connect+cypher://
| Field | Type | Default | Description |
|---|---|---|---|
uri | string | required | The connection URI to connect to. |
cypher | string | required | The cypher expression to execute against the graph database. |
database_name | string | "" | Set the target database for which expressions are evaluated against. |
args_mapping | string | The mapping from the message to the data that is passed in as parameters to the cypher expression. | |
basic_auth | object | Allows you to specify basic authentication. | |
batching | object | Allows you to configure a batching policy. | |
max_in_flight | int | 64 | The maximum number of messages to have in flight at a given time. |
Advanced: tls.
discord
Writes messages to a Discord channel.
connector: discord · URI connect+discord://
| Field | Type | Default | Description |
|---|---|---|---|
channel_id | string | required | A discord channel ID to write messages to. |
bot_token | string | required | A bot token used for authentication. |
doris_stream_load
Beta. Writes batches of messages into Apache Doris using Stream Load.
connector: doris_stream_load · URI connect+doris-stream-load://
| Field | Type | Default | Description |
|---|---|---|---|
fe_urls | list of string | [] | A list of Doris FE HTTP URLs. |
database | string | required | Target Doris database. |
table | string | required | Target Doris table. |
username | string | required | Doris username. |
password | string | required | Doris password. |
format | string | "json" | Body format sent to Doris Stream Load. |
read_json_by_line | bool | true | Encode a JSON batch as newline-delimited JSON and set the Doris header read_json_by_line=true. |
strip_outer_array | bool | false | Encode a JSON batch as a single JSON array and set the Doris header strip_outer_array=true. |
columns | list of string | [] | Optional Doris columns header. |
label_prefix | string | "redpanda_connect" | Prefix used when generating Doris labels. |
timeout | string | "30s" | Timeout for each Doris HTTP request. |
max_in_flight | int | 8 | Maximum number of parallel in-flight Doris Stream Load requests. |
batching | object | Allows you to configure a batching policy. |
Advanced: url, query_port, jsonpaths, json_root, where, column_separator, line_delimiter, group_commit, max_filter_ratio, partitions, temporary_partitions, skip_lines, empty_field_as_null, trim_double_quotes, num_as_string, fuzzy_parse, headers, strict_mode.
drop
Drops all messages.
connector: drop · URI connect+drop://
drop_on
Attempts to write messages to a child output and if the write fails for one of a list of configurable reasons the message is dropped (acked) instead of being reattempted (or nacked).
connector: drop_on · URI connect+drop-on://
| Field | Type | Default | Description |
|---|---|---|---|
error | bool | false | Whether messages should be dropped when the child output returns an error of any type. |
error_patterns | list of string | A list of regular expressions (re2) where if the child output returns an error that matches any part of any of these patterns the message will be dropped. | |
back_pressure | string | An optional duration string that determines the maximum length of time to wait for a given message to be accepted by the child output before the message should be dropped instead. | |
output | output | required | A child output to wrap with this drop mechanism. |
dynamic
A special broker type where the outputs are identified by unique labels and can be created, changed and removed during runtime via a REST API.
connector: dynamic · URI connect+dynamic://
| Field | Type | Default | Description |
|---|---|---|---|
outputs | map of output | {} | A map of outputs to statically create. |
prefix | string | "" | A path prefix for HTTP endpoints that are registered. |
elasticsearch_v8
Publishes messages into an Elasticsearch index. If the index does not exist then it is created with a dynamic mapping.
connector: elasticsearch_v8 · URI connect+elasticsearch-v8://
| Field | Type | Default | Description |
|---|---|---|---|
urls | list of string | required | A list of URLs to connect to. |
index | string | required | The index to place messages. |
action | string | required | The action to take on the document. |
id | string | required | The ID for indexed messages. |
max_in_flight | int | 64 | The maximum number of messages to have in flight at a given time. |
api_key | string | "" | An API key to authenticate with. |
batching | object | Allows you to configure a batching policy. |
Advanced: pipeline, routing, retry_on_conflict, tls, basic_auth.
elasticsearch_v9
Publishes messages into an Elasticsearch index. If the index does not exist then it is created with a dynamic mapping.
connector: elasticsearch_v9 · URI connect+elasticsearch-v9://
| Field | Type | Default | Description |
|---|---|---|---|
urls | list of string | required | A list of URLs to connect to. |
index | string | required | The index to place messages. |
action | string | required | The action to take on the document. |
id | string | required | The ID for indexed messages. |
max_in_flight | int | 64 | The maximum number of messages to have in flight at a given time. |
api_key | string | "" | An API key to authenticate with. |
batching | object | Allows you to configure a batching policy. |
Advanced: pipeline, routing, retry_on_conflict, tls, basic_auth.
fallback
Attempts to send each message to a child output, starting from the first output on the list. If an output attempt fails then the next output in the list is attempted, and so on.
connector: fallback · URI connect+fallback://
Takes a single list of output value, not a map of fields.
file
Writes messages to files on disk based on a chosen codec.
connector: file · URI connect+file://
| Field | Type | Default | Description |
|---|---|---|---|
path | string | required | The file to write to, if the file does not yet exist it will be created. |
codec | string | "lines" | The way in which the bytes of messages should be written out into the output data stream. |
gcp_bigquery
Sends messages as new rows to a Google Cloud BigQuery table.
connector: gcp_bigquery · URI connect+gcp-bigquery://
| Field | Type | Default | Description |
|---|---|---|---|
project | string | "" | The project ID of the dataset to insert data to. |
job_project | string | "" | The project ID in which jobs will be executed. |
dataset | string | required | The BigQuery Dataset ID. |
table | string | required | The table to insert messages to. |
format | string | "NEWLINE_DELIMITED_JSON" | The format of each incoming message. |
max_in_flight | int | 64 | The maximum number of message batches to have in flight at a given time. |
job_labels | map of string | {} | A list of labels to add to the load job. |
credentials_json | string | "" | An optional field to set Google Service Account Credentials json. |
csv | object | Specify how CSV data should be interpreted. | |
batching | object | Allows you to configure a batching policy. |
Advanced: write_disposition, create_disposition, ignore_unknown_values, max_bad_records, auto_detect.
gcp_cloud_storage
Sends message parts as objects to a Google Cloud Storage bucket. Each object is uploaded with the path specified with the path field.
connector: gcp_cloud_storage · URI connect+gcp-cloud-storage://
| Field | Type | Default | Description |
|---|---|---|---|
bucket | string | required | The bucket to upload messages to. |
path | string | "${!counter()}-${!timestamp_unix_nano… | The path of each message to upload. |
content_type | string | "application/octet-stream" | The content type to set for each object. |
collision_mode | string | "overwrite" | Determines how file path collisions should be dealt with. |
timeout | string | "3s" | The maximum period to wait on an upload before abandoning it and reattempting. |
credentials_json | string | "" | An optional field to set Google Service Account Credentials json. |
max_in_flight | int | 64 | The maximum number of message batches to have in flight at a given time. |
batching | object | Allows you to configure a batching policy. |
Advanced: content_encoding, chunk_size.
gcp_pubsub
Sends messages to a GCP Cloud Pub/Sub topic. Metadata from messages are sent as attributes.
connector: gcp_pubsub · URI connect+gcp-pubsub://
| Field | Type | Default | Description |
|---|---|---|---|
project | string | required | The project ID of the topic to publish to. |
credentials_json | string | "" | An optional field to set Google Service Account Credentials json. |
topic | string | required | The topic to publish to. |
endpoint | string | "" | An optional endpoint to override the default of pubsub.googleapis.com:443. |
max_in_flight | int | 64 | The maximum number of messages to have in flight at a given time. |
count_threshold | int | 100 | Publish a pubsub buffer when it has this many messages |
delay_threshold | string | "10ms" | Publish a non-empty pubsub buffer after this delay has passed. |
byte_threshold | int | 1000000 | Publish a batch when its size in bytes reaches this value. |
metadata | object | Specify criteria for which metadata values are sent as attributes, all are sent by default. | |
batching | object | Configures a batching policy on this output. |
Advanced: ordering_key, publish_timeout, validate_topic, flow_control.
hdfs
Sends message parts as files to a HDFS directory.
connector: hdfs · URI connect+hdfs://
| Field | Type | Default | Description |
|---|---|---|---|
hosts | list of string | required | A list of target host addresses to connect to. |
user | string | "" | A user ID to connect as. |
directory | string | required | A directory to store message files within. |
path | string | "${!counter()}-${!timestamp_unix_nano… | The path to upload messages as, interpolation functions should be used in order to generate unique file paths. |
max_in_flight | int | 64 | The maximum number of messages to have in flight at a given time. |
batching | object | Allows you to configure a batching policy. |
http_client
Sends messages to an HTTP server.
connector: http_client · URI connect+http-client://
| Field | Type | Default | Description |
|---|---|---|---|
url | string | required | The URL to connect to. |
verb | string | "POST" | A verb to connect with |
headers | map of string | {} | A map of headers to add to the request. |
rate_limit | string | An optional rate limit to throttle requests by. | |
timeout | string | "5s" | A static timeout to apply to requests. |
max_in_flight | int | 64 | The maximum number of parallel message batches to have in flight at any given time. |
batching | object | Allows you to configure a batching policy. |
Advanced: metadata, dump_request_log_level, oauth, oauth2, basic_auth, jwt, tls, extract_headers, retry_period, max_retry_backoff, retries, follow_redirects, backoff_on, drop_on, successful_on, proxy_url, disable_http2, batch_as_multipart, propagate_response, multipart.
http_server
Sets up an HTTP server that will send messages over HTTP(S) GET requests. HTTP 2.0 is supported when using TLS, which is enabled when key and cert files are specified.
connector: http_server · URI connect+http-server://
| Field | Type | Default | Description |
|---|---|---|---|
address | string | "" | An alternative address to host from. |
path | string | "/get" | The path from which discrete messages can be consumed. |
stream_path | string | "/get/stream" | The path from which a continuous stream of messages can be consumed. |
ws_path | string | "/get/ws" | The path from which websocket connections can be established. |
allowed_verbs | list of string | ["GET"] | An array of verbs that are allowed for the path and stream_path HTTP endpoint. |
Advanced: timeout, cert_file, key_file, cors.
inproc
Sends data directly to Redpanda Connect inputs by connecting to a unique ID.
connector: inproc · URI connect+inproc://
Takes a single string value, not a map of fields.
mongodb
Inserts items into a MongoDB collection.
connector: mongodb · URI connect+mongodb://
| Field | Type | Default | Description |
|---|---|---|---|
url | string | required | The URL of the target MongoDB server. |
database | string | required | The name of the target MongoDB database. |
username | string | "" | The username to connect to the database. |
password | string | "" | The password to connect to the database. |
collection | string | required | The name of the target collection. |
operation | string | "update-one" | The mongodb operation to perform. |
write_concern | object | The write concern settings for the mongo connection. | |
document_map | string | "" | A bloblang map representing a document to store within MongoDB, expressed as extended JSON in canonical form. |
filter_map | string | "" | A bloblang map representing a filter for a MongoDB command, expressed as extended JSON in canonical form. |
hint_map | string | "" | A bloblang map representing the hint for the MongoDB command, expressed as extended JSON in canonical form. |
upsert | bool | false | The upsert setting is optional and only applies for update-one and replace-one operations. |
max_in_flight | int | 64 | The maximum number of messages to have in flight at a given time. |
batching | object | Allows you to configure a batching policy. |
Advanced: app_name, aws.
mqtt
Pushes messages to an MQTT broker.
connector: mqtt · URI connect+mqtt://
| Field | Type | Default | Description |
|---|---|---|---|
urls | list of string | required | A list of URLs to connect to. |
client_id | string | "" | An identifier for the client connection. |
connect_timeout | string | "30s" | The maximum amount of time to wait in order to establish a connection before the attempt is abandoned. |
topic | string | required | The topic to publish messages to. |
qos | int | 1 | The QoS value to set for each message. |
write_timeout | string | "3s" | The maximum amount of time to wait to write data before the attempt is abandoned. |
retained | bool | false | Set message as retained on the topic. |
max_in_flight | int | 64 | The maximum number of messages to have in flight at a given time. |
Advanced: dynamic_client_id_suffix, will, user, password, keepalive, tls, retained_interpolated.
nanomsg
Send messages over a Nanomsg socket.
connector: nanomsg · URI connect+nanomsg://
| Field | Type | Default | Description |
|---|---|---|---|
urls | list of string | required | A list of URLs to connect to. |
bind | bool | false | Whether the URLs listed should be bind (otherwise they are connected to). |
socket_type | string | "PUSH" | The socket type to send with. |
poll_timeout | string | "5s" | The maximum period of time to wait for a message to send before the request is abandoned and reattempted. |
max_in_flight | int | 64 | The maximum number of messages to have in flight at a given time. |
nats
Publish to an NATS subject.
connector: nats · URI connect+nats://
| Field | Type | Default | Description |
|---|---|---|---|
urls | list of string | required | A list of URLs to connect to. |
subject | string | required | The subject to publish to. |
headers | map of string | {} | Explicit message headers to add to messages. |
metadata | object | Determine which (if any) metadata values should be added to messages as headers. | |
max_in_flight | int | 64 | The maximum number of messages to have in flight at a given time. |
Advanced: max_reconnects, tls, tls_handshake_first, auth, inject_tracing_map.
nats_jetstream
Write messages to a NATS JetStream subject.
connector: nats_jetstream · URI connect+nats-jetstream://
| Field | Type | Default | Description |
|---|---|---|---|
urls | list of string | required | A list of URLs to connect to. |
subject | string | required | A subject to write to. |
headers | map of string | {} | Explicit message headers to add to messages. |
metadata | object | Determine which (if any) metadata values should be added to messages as headers. | |
max_in_flight | int | 1024 | The maximum number of messages to have in flight at a given time. |
Advanced: max_reconnects, tls, tls_handshake_first, auth, inject_tracing_map.
nats_kv
Put messages in a NATS key-value bucket.
connector: nats_kv · URI connect+nats-kv://
| Field | Type | Default | Description |
|---|---|---|---|
urls | list of string | required | A list of URLs to connect to. |
bucket | string | required | The name of the KV bucket. |
key | string | required | The key for each message. |
max_in_flight | int | 1024 | The maximum number of messages to have in flight at a given time. |
Advanced: max_reconnects, tls, tls_handshake_first, auth.
nats_stream
Publish to a NATS Stream subject.
connector: nats_stream · URI connect+nats-stream://
| Field | Type | Default | Description |
|---|---|---|---|
urls | list of string | required | A list of URLs to connect to. |
cluster_id | string | required | The cluster ID to publish to. |
subject | string | required | The subject to publish to. |
client_id | string | "" | The client ID to connect with. |
max_in_flight | int | 64 | The maximum number of messages to have in flight at a given time. |
Advanced: max_reconnects, tls, tls_handshake_first, auth, inject_tracing_map.
nsq
Publish to an NSQ topic.
connector: nsq · URI connect+nsq://
| Field | Type | Default | Description |
|---|---|---|---|
nsqd_tcp_address | string | required | The address of the target NSQD server. |
topic | string | required | The topic to publish to. |
user_agent | string | A user agent to assume when connecting. | |
max_in_flight | int | 64 | The maximum number of messages to have in flight at a given time. |
Advanced: tls.
opensearch
Publishes messages into an Elasticsearch index. If the index does not exist then it is created with a dynamic mapping.
connector: opensearch · URI connect+opensearch://
| Field | Type | Default | Description |
|---|---|---|---|
urls | list of string | required | A list of URLs to connect to. |
index | string | required | The index to place messages. |
action | string | required | The action to take on the document. |
id | string | required | The ID for indexed messages. |
max_in_flight | int | 64 | The maximum number of messages to have in flight at a given time. |
batching | object | Allows you to configure a batching policy. |
Advanced: pipeline, routing, tls, basic_auth, aws.
pinecone
Inserts items into a Pinecone index.
connector: pinecone · URI connect+pinecone://
| Field | Type | Default | Description |
|---|---|---|---|
max_in_flight | int | 64 | The maximum number of messages to have in flight at a given time. |
batching | object | Allows you to configure a batching policy. | |
host | string | required | The host for the Pinecone index. |
api_key | string | required | The Pinecone api key. |
operation | string | "upsert-vectors" | The operation to perform against the Pinecone index. |
id | string | required | The ID for the index entry in Pinecone. |
vector_mapping | string | The mapping to extract out the vector from the document. | |
metadata_mapping | string | An optional mapping of message to metadata in the Pinecone index entry. |
Advanced: namespace.
pulsar
Write messages to an Apache Pulsar server.
connector: pulsar · URI connect+pulsar://
| Field | Type | Default | Description |
|---|---|---|---|
url | string | required | A URL to connect to. |
topic | string | required | The topic to publish to. |
tls | object | Specify the path to a custom CA certificate to trust broker TLS service. | |
key | string | "" | The key to publish messages with. |
ordering_key | string | "" | The ordering key to publish messages with. |
max_in_flight | int | 64 | The maximum number of messages to have in flight at a given time. |
Advanced: auth.
pusher
Output for publishing messages to Pusher API (https://pusher.com)
connector: pusher · URI connect+pusher://
| Field | Type | Default | Description |
|---|---|---|---|
batching | object | maximum batch size is 10 (limit of the pusher library) | |
channel | string | required | Pusher channel to publish to. |
event | string | required | Event to publish to |
appId | string | required | Pusher app id |
key | string | required | Pusher key |
secret | string | required | Pusher secret |
cluster | string | required | Pusher cluster |
secure | bool | true | Enable SSL encryption |
max_in_flight | int | 1 | The maximum number of parallel message batches to have in flight at any given time. |
qdrant
Adds items to a Qdrant collection
connector: qdrant · URI connect+qdrant://
| Field | Type | Default | Description |
|---|---|---|---|
max_in_flight | int | 64 | The maximum number of messages to have in flight at a given time. |
batching | object | Allows you to configure a batching policy. | |
grpc_host | string | required | The gRPC host of the Qdrant server. |
api_token | string | "" | The Qdrant API token for authentication. |
collection_name | string | required | The name of the collection in Qdrant. |
id | string | required | The ID of the point to insert. |
vector_mapping | string | required | The mapping to extract the vector from the document. |
payload_mapping | string | "root = {}" | An optional mapping of message to payload associated with the point. |
Advanced: tls.
questdb
Pushes messages to a QuestDB table
connector: questdb · URI connect+questdb://
| Field | Type | Default | Description |
|---|---|---|---|
max_in_flight | int | 64 | The maximum number of messages to have in flight at a given time. |
batching | object | Allows you to configure a batching policy. | |
address | string | required | Address of the QuestDB server’s HTTP port (excluding protocol) |
username | string | Username for HTTP basic auth | |
password | string | Password for HTTP basic auth | |
token | string | Bearer token for HTTP auth (takes precedence over basic auth username & password) | |
table | string | required | Destination table |
designated_timestamp_field | string | Name of the designated timestamp field | |
designated_timestamp_unit | string | "auto" | Designated timestamp field units |
timestamp_string_fields | list of string | String fields with textual timestamps | |
timestamp_string_format | string | "Jan _2 15:04:05.000000Z0700" | Timestamp format, used when parsing timestamp string fields. |
symbols | list of string | Columns that should be the SYMBOL type (string values default to STRING) | |
doubles | list of string | Columns that should be double type, (int is default) | |
error_on_empty_messages | bool | false | Mark a message as errored if it is empty after field validation |
Advanced: tls, retry_timeout, request_timeout, request_min_throughput.
redis_hash
Sets Redis hash objects using the HSET command.
connector: redis_hash · URI connect+redis-hash://
| Field | Type | Default | Description |
|---|---|---|---|
url | string | required | The URL of the target Redis server. |
key | string | required | The key for each message, function interpolations should be used to create a unique key per message. |
walk_metadata | bool | false | Whether all metadata fields of messages should be walked and added to the list of hash fields to set. |
walk_json_object | bool | false | Whether to walk each message as a JSON object and add each key/value pair to the list of hash fields to set. |
fields | map of string | {} | A map of key/value pairs to set as hash fields. |
max_in_flight | int | 64 | The maximum number of messages to have in flight at a given time. |
Advanced: kind, master, client_name, tls.
redis_list
Pushes messages onto the end of a Redis list (which is created if it doesn’t already exist) using the RPUSH command.
connector: redis_list · URI connect+redis-list://
| Field | Type | Default | Description |
|---|---|---|---|
url | string | required | The URL of the target Redis server. |
key | string | required | The key for each message, function interpolations can be optionally used to create a unique key per message. |
max_in_flight | int | 64 | The maximum number of messages to have in flight at a given time. |
batching | object | Allows you to configure a batching policy. |
Advanced: kind, master, client_name, tls, command.
redis_pubsub
Publishes messages through the Redis PubSub model. It is not possible to guarantee that messages have been received.
connector: redis_pubsub · URI connect+redis-pubsub://
| Field | Type | Default | Description |
|---|---|---|---|
url | string | required | The URL of the target Redis server. |
channel | string | required | The channel to publish messages to. |
max_in_flight | int | 64 | The maximum number of messages to have in flight at a given time. |
batching | object | Allows you to configure a batching policy. |
Advanced: kind, master, client_name, tls.
redis_streams
Pushes messages to a Redis (v5.0+) Stream (which is created if it doesn’t already exist) using the XADD command.
connector: redis_streams · URI connect+redis-streams://
| Field | Type | Default | Description |
|---|---|---|---|
url | string | required | The URL of the target Redis server. |
stream | string | required | The stream to add messages to. |
id | string | "*" | The entry ID for the stream message. |
body_key | string | "body" | A key to set the raw body of the message to. |
max_length | int | 0 | When greater than zero enforces a rough cap on the length of the target stream. |
max_in_flight | int | 64 | The maximum number of messages to have in flight at a given time. |
metadata | object | Specify criteria for which metadata values are included in the message body. | |
batching | object | Allows you to configure a batching policy. |
Advanced: kind, master, client_name, tls.
reject
Rejects all messages, treating them as though the output destination failed to publish them.
connector: reject · URI connect+reject://
Takes a single string value, not a map of fields.
reject_errored
Rejects messages that have failed their processing steps, resulting in nack behavior at the input level, otherwise sends them to a child output.
connector: reject_errored · URI connect+reject-errored://
Takes a single output value, not a map of fields.
resource
Resource is an output type that channels messages to a resource output, identified by its name.
connector: resource · URI connect+resource://
Takes a single string value, not a map of fields.
retry
Attempts to write messages to a child output and if the write fails for any reason the message is retried either until success or, if the retries or max elapsed time fields are non-zero, either is reached.
connector: retry · URI connect+retry://
| Field | Type | Default | Description |
|---|---|---|---|
output | output | required | A child output. |
Advanced: max_retries, backoff.
sftp
Writes files to an SFTP server.
connector: sftp · URI connect+sftp://
| Field | Type | Default | Description |
|---|---|---|---|
address | string | required | The address of the server to connect to. |
credentials | object | The credentials to use to log into the target server. | |
path | string | required | The file to save the messages to on the server. |
codec | string | "all-bytes" | The way in which the bytes of messages should be written out into the output data stream. |
max_in_flight | int | 64 | The maximum number of messages to have in flight at a given time. |
Advanced: connection_timeout.
socket
Connects to a (tcp/udp/unix) server and sends a continuous stream of data, dividing messages according to the specified codec.
connector: socket · URI connect+socket://
| Field | Type | Default | Description |
|---|---|---|---|
network | string | required | A network type to connect as. |
address | string | required | The address to connect to. |
codec | string | "lines" | The way in which the bytes of messages should be written out into the output data stream. |
Advanced: tls.
sql
Deprecated. Executes an arbitrary SQL query for each message.
connector: sql · URI connect+sql://
| Field | Type | Default | Description |
|---|---|---|---|
driver | string | required | A database driver to use. |
data_source_name | string | required | Data source name. |
query | string | required | The query to execute. |
args_mapping | string | An optional Bloblang mapping which should evaluate to an array of values matching in size to the number of placeholder arguments in the field query. | |
max_in_flight | int | 64 | The maximum number of inserts to run in parallel. |
batching | object | Allows you to configure a batching policy. |
sql_insert
Inserts a row into an SQL database for each message.
connector: sql_insert · URI connect+sql-insert://
| Field | Type | Default | Description |
|---|---|---|---|
driver | string | required | A database driver to use. |
dsn | string | required | A Data Source Name to identify the target database. |
table | string | required | The table to insert to. |
columns | list of string | required | A list of columns to insert. |
args_mapping | string | required | A Bloblang mapping which should evaluate to an array of values matching in size to the number of columns specified. |
max_in_flight | int | 64 | The maximum number of inserts to run in parallel. |
batching | object | Allows you to configure a batching policy. |
Advanced: prefix, suffix, options, init_files, init_statement, conn_max_idle_time, conn_max_life_time, conn_max_idle, conn_max_open.
sql_raw
Executes an arbitrary SQL query for each message.
connector: sql_raw · URI connect+sql-raw://
| Field | Type | Default | Description |
|---|---|---|---|
driver | string | required | A database driver to use. |
dsn | string | required | A Data Source Name to identify the target database. |
query | string | The query to execute. | |
args_mapping | string | An optional Bloblang mapping which should evaluate to an array of values matching in size to the number of placeholder arguments in the field query. | |
queries | list of object | A list of query statements. | |
max_in_flight | int | 64 | The maximum number of batches to be sending in parallel at any given time. |
batching | object | Allows you to configure a batching policy. |
Advanced: unsafe_dynamic_query, init_files, init_statement, conn_max_idle_time, conn_max_life_time, conn_max_idle, conn_max_open.
stdout
Prints messages to stdout as a continuous stream of data.
connector: stdout · URI connect+stdout://
| Field | Type | Default | Description |
|---|---|---|---|
codec | string | "lines" | The way in which the bytes of messages should be written out into the output data stream. |
subprocess
Beta. Executes a command, runs it as a subprocess, and writes messages to it over stdin.
connector: subprocess · URI connect+subprocess://
| Field | Type | Default | Description |
|---|---|---|---|
name | string | required | The command to execute as a subprocess. |
args | list of string | [] | A list of arguments to provide the command. |
codec | string | "lines" | The way in which messages should be written to the subprocess. |
switch
The switch output type allows you to route messages to different outputs based on their contents.
connector: switch · URI connect+switch://
| Field | Type | Default | Description |
|---|---|---|---|
retry_until_success | bool | false | If a selected output fails to send a message this field determines whether it is reattempted indefinitely. |
cases | list of object | A list of switch cases, outlining outputs that can be routed to. |
Advanced: strict_mode.
sync_response
Returns the final message payload back to the input origin of the message, where it is dealt with according to that specific input type.
connector: sync_response · URI connect+sync-response://
websocket
Sends messages to an HTTP server via a websocket connection.
connector: websocket · URI connect+websocket://
| Field | Type | Default | Description |
|---|---|---|---|
url | string | required | The URL to connect to. |
Advanced: proxy_url, tls, oauth, basic_auth, jwt.
Connect plugin: processors
The 86 Redpanda Connect processors the Connect plugin links, generated from the component specs of Redpanda Connect v4.110.0. Field descriptions are Redpanda Connect’s own, shortened to one sentence; follow Upstream documentation for the rest. Install and configuration forms are on the plugin page.
16 of them are also exported as their own connect_<name> middleware and run on any endpoint, native ones included. Every other processor that keeps, rewrites or drops each message runs inside a connect middleware; one that splits or merges messages belongs in a connect endpoint’s pipeline. Middleware needs plugin 0.1.1 or newer.
Own middleware: connect_avro, connect_bloblang, connect_branch, connect_cached, connect_dedupe, connect_grok, connect_http, connect_javascript, connect_jmespath, connect_jq, connect_json_schema, connect_log, connect_mapping, connect_msgpack, connect_mutation, connect_parse_log.
input:
kafka: { url: localhost:9092, topic: orders }
middlewares:
- connect_mapping: 'root = this.merge({"received_at": now()})'
Processors in bold are also their own middleware.
archive
Archives all the messages of a batch into a single message according to the selected archive format.
Use it inside a connect middleware or a connect endpoint’s pipeline.
| Field | Type | Default | Description |
|---|---|---|---|
format | string | required | The archiving format to apply. |
path | string | "" | The path to set for each message in the archive (when applicable). |
avro
Performs Avro based operations on messages based on a schema.
Middleware: connect_avro, or inside a connect middleware.
| Field | Type | Default | Description |
|---|---|---|---|
operator | string | required | The operator to execute |
encoding | string | "textual" | An Avro encoding format to use for conversions to and from a schema. |
schema | string | "" | A full Avro schema to use. |
schema_path | string | "" | The path of a schema document to apply. |
awk
Executes an AWK program on messages. This processor is very powerful as it offers a range of custom functions for querying and mutating message contents and metadata.
Use it inside a connect middleware or a connect endpoint’s pipeline.
| Field | Type | Default | Description |
|---|---|---|---|
codec | string | required | A codec defines how messages should be inserted into the AWK program as variables. |
program | string | required | An AWK program to execute |
aws_bedrock_chat
Generates responses to messages in a chat conversation, using the AWS Bedrock API.
Use it inside a connect middleware or a connect endpoint’s pipeline.
| Field | Type | Default | Description |
|---|---|---|---|
model | string | required | The model ID to use. |
prompt | string | The prompt you want to generate a response for. | |
system_prompt | string | The system prompt to submit to the AWS Bedrock LLM. | |
max_tokens | int | The maximum number of tokens to allow in the generated response. | |
temperature | float | The likelihood of the model selecting higher-probability options while generating a response. |
Advanced: region, endpoint, tcp, credentials, stop, top_p.
aws_bedrock_embeddings
Computes vector embeddings on text, using the AWS Bedrock API.
Use it inside a connect middleware or a connect endpoint’s pipeline.
| Field | Type | Default | Description |
|---|---|---|---|
model | string | required | The model ID to use. |
text | string | The text you want to compute vector embeddings for. | |
input_type | string | Specifies the type of input passed to the model. |
Advanced: region, endpoint, tcp, credentials.
aws_lambda
Invokes an AWS lambda for each message. The contents of the message is the payload of the request, and the result of the invocation will become the new contents of the message.
Use it inside a connect middleware or a connect endpoint’s pipeline.
| Field | Type | Default | Description |
|---|---|---|---|
parallel | bool | false | Whether messages of a batch should be dispatched in parallel. |
function | string | required | The function to invoke. |
Advanced: rate_limit, region, endpoint, tcp, credentials, timeout, retries.
azure_cosmosdb
Creates or updates messages as JSON documents in Azure CosmosDB.
Use it inside a connect middleware or a connect endpoint’s pipeline.
| Field | Type | Default | Description |
|---|---|---|---|
endpoint | string | CosmosDB endpoint. | |
account_key | string | Account key. | |
connection_string | string | Connection string. | |
database | string | required | Database. |
container | string | required | Container. |
partition_keys_map | string | required | A Bloblang mapping which should evaluate to a single partition key value or an array of partition key values of type string, integer or boolean. |
operation | string | "Create" | Operation. |
item_id | string | ID of item to replace or delete. |
Advanced: patch_operations, patch_condition, auto_id, enable_content_response_on_write.
benchmark
Logs basic throughput statistics of messages that pass through this processor.
Use it inside a connect middleware or a connect endpoint’s pipeline.
| Field | Type | Default | Description |
|---|---|---|---|
interval | string | "5s" | How often to emit rolling statistics. |
count_bytes | bool | true | Whether or not to measure the number of bytes per second of throughput. |
bloblang
Executes a Bloblang mapping on messages.
Middleware: connect_bloblang, or inside a connect middleware.
Takes a single string value, not a map of fields.
bounds_check
Removes messages (and batches) that do not fit within certain size boundaries.
Use it inside a connect middleware or a connect endpoint’s pipeline.
| Field | Type | Default | Description |
|---|---|---|---|
max_part_size | int | 1073741824 | The maximum size of a message to allow (in bytes) |
min_part_size | int | 1 | The minimum size of a message to allow (in bytes) |
Advanced: max_parts, min_parts.
branch
The branch processor allows you to create a new request message via a Bloblang mapping, execute a list of processors on the request messages, and, finally, map the result back into the source message using another mapping.
Middleware: connect_branch, or inside a connect middleware.
| Field | Type | Default | Description |
|---|---|---|---|
request_map | string | "" | A Bloblang mapping that describes how to create a request payload suitable for the child processors of this branch. |
processors | list of processor | required | A list of processors to apply to mapped requests. |
result_map | string | "" | A Bloblang mapping that describes how the resulting messages from branched processing should be mapped back into the original payload. |
cache
Performs operations against a cache resource for each message, allowing you to store or retrieve data within message payloads.
Use it inside a connect middleware or a connect endpoint’s pipeline.
| Field | Type | Default | Description |
|---|---|---|---|
resource | string | required | The cache resource to target with this processor. |
operator | string | required | The operation to perform with the cache. |
key | string | required | A key to use with the cache. |
value | string | A value to use with the cache (when applicable). |
Advanced: ttl.
cached
Cache the result of applying one or more processors to messages identified by a key. If the key already exists within the cache the contents of the message will be replaced with the cached result instead of applying the processors. This component is therefore useful in situations where an expensive set of processors need only be executed periodically.
Middleware: connect_cached, or inside a connect middleware.
| Field | Type | Default | Description |
|---|---|---|---|
cache | string | required | The cache resource to read and write processor results from. |
skip_on | string | A condition that can be used to skip caching the results from the processors. | |
key | string | required | A key to be resolved for each message, if the key already exists in the cache then the cached result is used, otherwise the processors are applied and the result is cached under this key. |
ttl | string | An optional expiry period to set for each cache entry. | |
processors | list of processor | required | The list of processors whose result will be cached. |
catch
Applies a list of child processors only when a previous processing step has failed.
Use it inside a connect middleware or a connect endpoint’s pipeline.
Takes a single list of processor value, not a map of fields.
cohere_chat
Generates responses to messages in a chat conversation, using the Cohere API.
Use it inside a connect middleware or a connect endpoint’s pipeline.
| Field | Type | Default | Description |
|---|---|---|---|
base_url | string | "https://api.cohere.com" | The base URL to use for API requests. |
api_key | string | required | The API key for the Cohere API. |
model | string | required | The name of the Cohere model to use. |
prompt | string | The user prompt you want to generate a response for. | |
system_prompt | string | The system prompt to submit along with the user prompt. | |
max_tokens | int | The maximum number of tokens that can be generated in the chat completion. | |
temperature | float | What sampling temperature to use, between 0 and 2. | |
response_format | string | "text" | Specify the model’s output format. |
json_schema | string | The JSON schema to use when responding in json_schema format. | |
max_tool_calls | int | 10 | Maximum number of tool calls the model can do. |
tools | list of object | [] | The tools to allow the LLM to invoke. |
Advanced: schema_registry, top_p, frequency_penalty, presence_penalty, seed, stop.
cohere_embeddings
Generates vector embeddings to represent input text, using the Cohere API.
Use it inside a connect middleware or a connect endpoint’s pipeline.
| Field | Type | Default | Description |
|---|---|---|---|
base_url | string | "https://api.cohere.com" | The base URL to use for API requests. |
api_key | string | required | The API key for the Cohere API. |
model | string | required | The name of the Cohere model to use. |
text_mapping | string | The text you want to generate a vector embedding for. | |
input_type | string | "search_document" | Specifies the type of input passed to the model. |
dimensions | int | The number of dimensions of the output embedding. |
cohere_rerank
Ranks a list of documents by their relevance to a query, using the Cohere API.
Use it inside a connect middleware or a connect endpoint’s pipeline.
| Field | Type | Default | Description |
|---|---|---|---|
base_url | string | "https://api.cohere.com" | The base URL to use for API requests. |
api_key | string | required | The API key for the Cohere API. |
model | string | required | The name of the Cohere model to use. |
query | string | required | The search query |
documents | string | required | A list of texts that will be compared to the query. |
top_n | string | "0" | The number of documents to return, if 0 all documents are returned. |
max_tokens_per_doc | int | 4096 | Long documents will be automatically truncated to the specified number of tokens. |
command
Executes a command for each message.
Use it inside a connect middleware or a connect endpoint’s pipeline.
| Field | Type | Default | Description |
|---|---|---|---|
name | string | required | The name of the command to execute. |
args_mapping | string | An optional Bloblang mapping that, when specified, should resolve into an array of arguments to pass to the command. |
compress
Compresses messages according to the selected algorithm. Supported compression algorithms are: [flate gzip lz4 pgzip snappy zlib]
Use it inside a connect middleware or a connect endpoint’s pipeline.
| Field | Type | Default | Description |
|---|---|---|---|
algorithm | string | required | The compression algorithm to use. |
level | int | -1 | The level of compression to use. |
couchbase
Performs operations against Couchbase for each message, allowing you to store or retrieve data within message payloads.
Use it inside a connect middleware or a connect endpoint’s pipeline.
| Field | Type | Default | Description |
|---|---|---|---|
url | string | required | Couchbase connection string. |
username | string | Username to connect to the cluster. | |
password | string | Password to connect to the cluster. | |
bucket | string | required | Couchbase bucket. |
id | string | required | Document id. |
content | string | Document content. | |
operation | string | "get" | Couchbase operation to perform. |
Advanced: collection, scope, transcoder, timeout, ttl.
crash
Beta. Crashes the process using a fatal log message. The log message can be set using function interpolations described in Bloblang queries which allows you to log the contents and metadata of messages.
Use it inside a connect middleware or a connect endpoint’s pipeline.
Takes a single string value, not a map of fields.
decompress
Decompresses messages according to the selected algorithm. Supported decompression algorithms are: [bzip2 flate gzip lz4 pgzip snappy zlib]
Use it inside a connect middleware or a connect endpoint’s pipeline.
| Field | Type | Default | Description |
|---|---|---|---|
algorithm | string | required | The decompression algorithm to use. |
dedupe
Deduplicates messages by storing a key value in a cache using the add operator. If the key already exists within the cache it is dropped.
Middleware: connect_dedupe, or inside a connect middleware.
| Field | Type | Default | Description |
|---|---|---|---|
cache | string | required | The cache resource to target with this processor. |
key | string | required | An interpolated string yielding the key to deduplicate by for each message. |
drop_on_err | bool | true | Whether messages should be dropped when the cache returns a general error such as a network issue. |
for_each
A processor that applies a list of child processors to messages of a batch as though they were each a batch of one message.
Use it inside a connect middleware or a connect endpoint’s pipeline.
Takes a single list of processor value, not a map of fields.
gcp_bigquery_select
Executes a SELECT query against BigQuery and replaces messages with the rows returned.
Use it inside a connect middleware or a connect endpoint’s pipeline.
| Field | Type | Default | Description |
|---|---|---|---|
project | string | required | GCP project where the query job will execute. |
credentials_json | string | "" | An optional field to set Google Service Account Credentials json. |
table | string | required | Fully-qualified BigQuery table name to query. |
columns | list of string | required | A list of columns to query. |
where | string | An optional where clause to add. | |
job_labels | map of string | {} | A list of labels to add to the query job. |
args_mapping | string | An optional Bloblang mapping which should evaluate to an array of values matching in size to the number of placeholder arguments in the field where. | |
prefix | string | An optional prefix to prepend to the select query (before SELECT). | |
suffix | string | An optional suffix to append to the select query. |
gcp_vertex_ai_chat
Generates responses to messages in a chat conversation, using the Vertex AI API.
Use it inside a connect middleware or a connect endpoint’s pipeline.
| Field | Type | Default | Description |
|---|---|---|---|
project | string | required | GCP project ID to use |
credentials_json | string | An optional field to set google Service Account Credentials json. | |
location | string | required | The location of the model if using a fined tune model. |
model | string | required | The name of the LLM to use. |
prompt | string | The prompt you want to generate a response for. | |
history | string | Historical messages to include in the chat request. | |
attachment | string | Additional data like an image to send with the prompt to the model. | |
temperature | float | Controls the randomness of predications. | |
max_tokens | int | The maximum number of output tokens to generate per message. | |
response_format | string | "text" | The response format of generated type, the model must also be prompted to output the appropriate response type. |
tools | list of object | [] | The tools to allow the LLM to invoke. |
Advanced: system_prompt, top_p, top_k, stop, presence_penalty, frequency_penalty, max_tool_calls.
gcp_vertex_ai_embeddings
Generates vector embeddings to represent input text, using the Vertex AI API.
Use it inside a connect middleware or a connect endpoint’s pipeline.
| Field | Type | Default | Description |
|---|---|---|---|
project | string | required | GCP project ID to use |
credentials_json | string | An optional field to set google Service Account Credentials json. | |
location | string | "us-central1" | The location of the model. |
model | string | required | The name of the LLM to use. |
task_type | string | "RETRIEVAL_DOCUMENT" | The way to optimize embeddings that the model generates for specific use cases. |
text | string | The text you want to compute vector embeddings for. | |
output_dimensions | int | The maximum length for the output embedding size. |
grok
Parses messages into a structured format by attempting to apply a list of Grok expressions, the first expression to result in at least one value replaces the original message with a JSON object containing the values.
Middleware: connect_grok, or inside a connect middleware.
| Field | Type | Default | Description |
|---|---|---|---|
expressions | list of string | required | One or more Grok expressions to attempt against incoming messages. |
pattern_definitions | map of string | {} | A map of pattern definitions that can be referenced within patterns. |
pattern_paths | list of string | [] | A list of paths to load Grok patterns from. |
Advanced: named_captures_only, use_default_patterns, remove_empty_values.
group_by
Splits a batch of messages into N batches, where each resulting batch contains a group of messages determined by a Bloblang query.
Use it inside a connect middleware or a connect endpoint’s pipeline.
| Field | Type | Default | Description |
|---|---|---|---|
check | string | required | A Bloblang query that should return a boolean value indicating whether a message belongs to a given group. |
processors | list of processor | [] | A list of processors to execute on the newly formed group. |
group_by_value
Splits a batch of messages into N batches, where each resulting batch contains a group of messages determined by a function interpolated string evaluated per message.
Use it inside a connect middleware or a connect endpoint’s pipeline.
| Field | Type | Default | Description |
|---|---|---|---|
value | string | required | The interpolated string to group based on. |
http
Performs an HTTP request using a message batch as the request body, and replaces the original message parts with the body of the response.
Middleware: connect_http, or inside a connect middleware.
| Field | Type | Default | Description |
|---|---|---|---|
url | string | required | The URL to connect to. |
verb | string | "POST" | A verb to connect with |
headers | map of string | {} | A map of headers to add to the request. |
rate_limit | string | An optional rate limit to throttle requests by. | |
timeout | string | "5s" | A static timeout to apply to requests. |
parallel | bool | false | When processing batched messages, whether to send messages of the batch in parallel, otherwise they are sent serially. |
Advanced: metadata, dump_request_log_level, oauth, oauth2, basic_auth, jwt, tls, extract_headers, retry_period, max_retry_backoff, retries, follow_redirects, backoff_on, drop_on, successful_on, proxy_url, disable_http2, batch_as_multipart.
insert_part
Insert a new message into a batch at an index. If the specified index is greater than the length of the existing batch it will be appended to the end.
Use it inside a connect middleware or a connect endpoint’s pipeline.
| Field | Type | Default | Description |
|---|---|---|---|
index | int | -1 | The index within the batch to insert the message at. |
content | string | "" | The content of the message being inserted. |
javascript
Executes a provided JavaScript code block or file for each message.
Middleware: connect_javascript, or inside a connect middleware.
| Field | Type | Default | Description |
|---|---|---|---|
code | string | An inline JavaScript program to run. | |
file | string | A file containing a JavaScript program to run. | |
global_folders | list of string | [] | List of folders that will be used to load modules from if the requested JS module is not found elsewhere. |
jmespath
Executes a JMESPath query on JSON documents and replaces the message with the resulting document.
Middleware: connect_jmespath, or inside a connect middleware.
| Field | Type | Default | Description |
|---|---|---|---|
query | string | required | The JMESPath query to apply to messages. |
jq
Transforms and filters messages using jq queries.
Middleware: connect_jq, or inside a connect middleware.
| Field | Type | Default | Description |
|---|---|---|---|
query | string | required | The jq query to filter and transform messages with. |
Advanced: raw, output_raw.
json_schema
Checks messages against a provided JSONSchema definition but does not change the payload under any circumstances. If a message does not match the schema it can be caught using error handling methods.
Middleware: connect_json_schema, or inside a connect middleware.
| Field | Type | Default | Description |
|---|---|---|---|
schema | string | A schema to apply. | |
schema_path | string | The path of a schema document to apply. |
log
Prints a log event for each message. Messages always remain unchanged. The log message can be set using function interpolations described in Bloblang queries which allows you to log the contents and metadata of messages.
Middleware: connect_log, or inside a connect middleware.
| Field | Type | Default | Description |
|---|---|---|---|
level | string | "INFO" | The log level to use. |
fields_mapping | string | An optional Bloblang mapping that can be used to specify extra fields to add to the log. | |
message | string | "" | The message to print. |
mapping
Executes a Bloblang mapping on messages, creating a new document that replaces (or filters) the original message.
Middleware: connect_mapping, or inside a connect middleware.
Takes a single string value, not a map of fields.
metric
Emit custom metrics by extracting values from messages.
Use it inside a connect middleware or a connect endpoint’s pipeline.
| Field | Type | Default | Description |
|---|---|---|---|
type | string | required | The metric type to create. |
name | string | required | The name of the metric to create, this must be unique across all Redpanda Connect components otherwise it will overwrite those other metrics. |
labels | map of string | A map of label names and values that can be used to enrich metrics. | |
value | string | "" | For some metric types specifies a value to set, increment. |
mongodb
Performs operations against MongoDB for each message, allowing you to store or retrieve data within message payloads.
Use it inside a connect middleware or a connect endpoint’s pipeline.
| Field | Type | Default | Description |
|---|---|---|---|
url | string | required | The URL of the target MongoDB server. |
database | string | required | The name of the target MongoDB database. |
username | string | "" | The username to connect to the database. |
password | string | "" | The password to connect to the database. |
collection | string | required | The name of the target collection. |
operation | string | "insert-one" | The mongodb operation to perform. |
write_concern | object | The write concern settings for the mongo connection. | |
document_map | string | "" | A bloblang map representing a document to store within MongoDB, expressed as extended JSON in canonical form. |
filter_map | string | "" | A bloblang map representing a filter for a MongoDB command, expressed as extended JSON in canonical form. |
hint_map | string | "" | A bloblang map representing the hint for the MongoDB command, expressed as extended JSON in canonical form. |
upsert | bool | false | The upsert setting is optional and only applies for update-one and replace-one operations. |
Advanced: app_name, aws, json_marshal_mode.
msgpack
Converts messages to or from the MessagePack format.
Middleware: connect_msgpack, or inside a connect middleware.
| Field | Type | Default | Description |
|---|---|---|---|
operator | string | required | The operation to perform on messages. |
mutation
Executes a Bloblang mapping and directly transforms the contents of messages, mutating (or deleting) them.
Middleware: connect_mutation, or inside a connect middleware.
Takes a single string value, not a map of fields.
nats_kv
Perform operations on a NATS key-value bucket.
Use it inside a connect middleware or a connect endpoint’s pipeline.
| Field | Type | Default | Description |
|---|---|---|---|
urls | list of string | required | A list of URLs to connect to. |
bucket | string | required | The name of the KV bucket. |
operation | string | required | The operation to perform on the KV bucket. |
key | string | required | The key for each message. |
Advanced: max_reconnects, revision, timeout, tls, tls_handshake_first, auth.
nats_request_reply
Sends a message to a NATS subject and expects a reply, from a NATS subscriber acting as a responder, back.
Use it inside a connect middleware or a connect endpoint’s pipeline.
| Field | Type | Default | Description |
|---|---|---|---|
urls | list of string | required | A list of URLs to connect to. |
subject | string | required | A subject to write to. |
headers | map of string | {} | Explicit message headers to add to messages. |
metadata | object | Determine which (if any) metadata values should be added to messages as headers. | |
timeout | string | "3s" | A duration string is a possibly signed sequence of decimal numbers, each with optional fraction and a unit suffix, such as 300ms, -1.5h or 2h45m. |
Advanced: max_reconnects, inbox_prefix, tls, tls_handshake_first, auth.
noop
Noop is a processor that does nothing, the message passes through unchanged. Why? Sometimes doing nothing is the braver option.
Use it inside a connect middleware or a connect endpoint’s pipeline.
ollama_chat
Generates responses to messages in a chat conversation, using the Ollama API.
Use it inside a connect middleware or a connect endpoint’s pipeline.
| Field | Type | Default | Description |
|---|---|---|---|
model | string | required | The name of the Ollama LLM to use. |
prompt | string | The prompt you want to generate a response for. | |
image | string | The image to submit along with the prompt to the model. | |
response_format | string | "text" | The format of the response that the Ollama model generates. |
max_tokens | int | The maximum number of tokens to predict and output. | |
temperature | int | The temperature of the model. | |
save_prompt_metadata | bool | false | If enabled the prompt is saved as @prompt metadata on the output message. |
history | string | Historical messages to include in the chat request. | |
tools | list of object | [] | The tools to allow the LLM to invoke. |
runner | object | Options for the model runner that are used when the model is first loaded into memory. | |
server_address | string | The address of the Ollama server to use. |
Advanced: system_prompt, num_keep, seed, top_k, top_p, repeat_penalty, presence_penalty, frequency_penalty, stop, max_tool_calls, cache_directory, download_url.
ollama_embeddings
Generates vector embeddings from text, using the Ollama API.
Use it inside a connect middleware or a connect endpoint’s pipeline.
| Field | Type | Default | Description |
|---|---|---|---|
model | string | required | The name of the Ollama LLM to use. |
text | string | The text you want to create vector embeddings for. | |
runner | object | Options for the model runner that are used when the model is first loaded into memory. | |
server_address | string | The address of the Ollama server to use. |
Advanced: cache_directory, download_url.
ollama_moderation
Classifies an LLM response as safe or unsafe, using the Ollama API.
Use it inside a connect middleware or a connect endpoint’s pipeline.
| Field | Type | Default | Description |
|---|---|---|---|
model | string | required | The name of the Ollama LLM to use. |
prompt | string | required | The input prompt that was used with the LLM. |
response | string | required | The LLM’s response to classify if it contains safe or unsafe content. |
runner | object | Options for the model runner that are used when the model is first loaded into memory. | |
server_address | string | The address of the Ollama server to use. |
Advanced: cache_directory, download_url.
openai_chat_completion
Generates responses to messages in a chat conversation, using the OpenAI API.
Use it inside a connect middleware or a connect endpoint’s pipeline.
| Field | Type | Default | Description |
|---|---|---|---|
server_address | string | "https://api.openai.com/v1" | The Open API endpoint that the processor sends requests to. |
api_key | string | required | The API key for OpenAI API. |
model | string | required | The name of the OpenAI model to use. |
prompt | string | The user prompt you want to generate a response for. | |
system_prompt | string | The system prompt to submit along with the user prompt. | |
history | string | The history of the prior conversation. | |
image | string | An image to send along with the prompt. | |
max_tokens | int | The maximum number of tokens that can be generated in the chat completion. | |
temperature | float | What sampling temperature to use, between 0 and 2. | |
user | string | A unique identifier representing your end-user, which can help OpenAI to monitor and detect abuse. | |
response_format | string | "text" | Specify the model’s output format. |
json_schema | object | The JSON schema to use when responding in json_schema format. | |
tools | list of object | The tools to allow the LLM to invoke. |
Advanced: schema_registry, top_p, frequency_penalty, presence_penalty, seed, stop.
openai_embeddings
Generates vector embeddings to represent input text, using the OpenAI API.
Use it inside a connect middleware or a connect endpoint’s pipeline.
| Field | Type | Default | Description |
|---|---|---|---|
server_address | string | "https://api.openai.com/v1" | The Open API endpoint that the processor sends requests to. |
api_key | string | required | The API key for OpenAI API. |
model | string | required | The name of the OpenAI model to use. |
text_mapping | string | The text you want to generate a vector embedding for. | |
dimensions | int | The number of dimensions the resulting output embeddings should have. |
openai_image_generation
Generates an image from a text description and other attributes, using OpenAI API.
Use it inside a connect middleware or a connect endpoint’s pipeline.
| Field | Type | Default | Description |
|---|---|---|---|
server_address | string | "https://api.openai.com/v1" | The Open API endpoint that the processor sends requests to. |
api_key | string | required | The API key for OpenAI API. |
model | string | required | The name of the OpenAI model to use. |
prompt | string | A text description of the image you want to generate. |
Advanced: quality, size, style.
openai_speech
Generates audio from a text description and other attributes, using OpenAI API.
Use it inside a connect middleware or a connect endpoint’s pipeline.
| Field | Type | Default | Description |
|---|---|---|---|
server_address | string | "https://api.openai.com/v1" | The Open API endpoint that the processor sends requests to. |
api_key | string | required | The API key for OpenAI API. |
model | string | required | The name of the OpenAI model to use. |
input | string | A text description of the audio you want to generate. | |
voice | string | required | The type of voice to use when generating the audio. |
Advanced: response_format.
openai_transcription
Generates a transcription of spoken audio in the input language, using the OpenAI API.
Use it inside a connect middleware or a connect endpoint’s pipeline.
| Field | Type | Default | Description |
|---|---|---|---|
server_address | string | "https://api.openai.com/v1" | The Open API endpoint that the processor sends requests to. |
api_key | string | required | The API key for OpenAI API. |
model | string | required | The name of the OpenAI model to use. |
file | string | required | The audio file object (not file name) to transcribe, in one of the following formats: flac, mp3, mp4, mpeg, mpga, m4a, ogg, wav, or webm. |
Advanced: language, prompt.
openai_translation
Translates spoken audio into English, using the OpenAI API.
Use it inside a connect middleware or a connect endpoint’s pipeline.
| Field | Type | Default | Description |
|---|---|---|---|
server_address | string | "https://api.openai.com/v1" | The Open API endpoint that the processor sends requests to. |
api_key | string | required | The API key for OpenAI API. |
model | string | required | The name of the OpenAI model to use. |
file | string | The audio file object (not file name) to translate, in one of the following formats: flac, mp3, mp4, mpeg, mpga, m4a, ogg, wav, or webm. |
Advanced: prompt.
parallel
A processor that applies a list of child processors to messages of a batch as though they were each a batch of one message (similar to the for_each processor), but where each message is processed in parallel.
Use it inside a connect middleware or a connect endpoint’s pipeline.
| Field | Type | Default | Description |
|---|---|---|---|
cap | int | 0 | The maximum number of messages to have processing at a given time. |
processors | list of processor | required | A list of child processors to apply. |
parquet
Deprecated. Converts batches of documents to or from Parquet files.
Use it inside a connect middleware or a connect endpoint’s pipeline.
| Field | Type | Default | Description |
|---|---|---|---|
operator | string | required | Determines whether the processor converts messages into a parquet file or expands parquet files into messages. |
compression | string | "snappy" | The type of compression to use when writing parquet files, this field is ignored when consuming parquet files. |
schema_file | string | A file path containing a schema used to describe the parquet files being generated or consumed, the format of the schema is a JSON document detailing the tag and fields of documents. | |
schema | string | A schema used to describe the parquet files being generated or consumed, the format of the schema is a JSON document detailing the tag and fields of documents. |
parquet_decode
Decodes Parquet files into a batch of structured messages.
Use it inside a connect middleware or a connect endpoint’s pipeline.
| Field | Type | Default | Description |
|---|---|---|---|
handle_logical_types | string | "v1" | Whether to be smart about decoding logical types. |
parquet_encode
Encodes Parquet files from a batch of structured messages.
Use it inside a connect middleware or a connect endpoint’s pipeline.
| Field | Type | Default | Description |
|---|---|---|---|
schema | list of object | Parquet schema. | |
schema_metadata | string | "" | Optionally specify a metadata field containing a schema definition to use for encoding instead of a statically defined schema. |
default_compression | string | "uncompressed" | The default compression type to use for fields. |
Advanced: default_encoding, default_timestamp_unit.
parse_log
Parses common log formats into structured data. This is easier and often much faster than grok.
Middleware: connect_parse_log, or inside a connect middleware.
| Field | Type | Default | Description |
|---|---|---|---|
format | string | required | A common log format to parse. |
Advanced: best_effort, allow_rfc3339, default_year, default_timezone.
processors
A processor grouping several sub-processors.
Use it inside a connect middleware or a connect endpoint’s pipeline.
Takes a single list of processor value, not a map of fields.
qdrant
Query items within a Qdrant collection.
Use it inside a connect middleware or a connect endpoint’s pipeline.
| Field | Type | Default | Description |
|---|---|---|---|
grpc_host | string | required | The gRPC host of the Qdrant server. |
api_token | string | "" | The Qdrant API token for authentication. |
collection_name | string | required | The name of the collection in Qdrant. |
vector_mapping | string | required | The mapping to extract the search vector from the document. |
filter | string | Additional filtering to perform on the results. | |
payload_fields | list of string | [] | The fields to include or exclude in returned result based on the payload_filter. |
payload_filter | string | "include" | The way the fields in payload_fields are filtered in the result. |
limit | int | 10 | The maximum number of points to return. |
Advanced: tls.
rate_limit
Throttles the throughput of a pipeline according to a specified rate_limit resource. Rate limits are shared across components and therefore apply globally to all processing pipelines.
Use it inside a connect middleware or a connect endpoint’s pipeline.
| Field | Type | Default | Description |
|---|---|---|---|
resource | string | required | The target rate_limit resource. |
redis
Performs actions against Redis that aren’t possible using a cache processor. Actions are performed for each message and the message contents are replaced with the result. In order to merge the result into the original message compose this processor within a branch processor.
Use it inside a connect middleware or a connect endpoint’s pipeline.
| Field | Type | Default | Description |
|---|---|---|---|
url | string | required | The URL of the target Redis server. |
command | string | The command to execute. | |
args_mapping | string | A Bloblang mapping which should evaluate to an array of values matching in size to the number of arguments required for the specified Redis command. |
Advanced: kind, master, client_name, tls, retries, retry_period.
redis_script
Performs actions against Redis using LUA scripts.
Use it inside a connect middleware or a connect endpoint’s pipeline.
| Field | Type | Default | Description |
|---|---|---|---|
url | string | required | The URL of the target Redis server. |
script | string | required | A script to use for the target operator. |
args_mapping | string | required | A Bloblang mapping which should evaluate to an array of values matching in size to the number of arguments required for the specified Redis script. |
keys_mapping | string | required | A Bloblang mapping which should evaluate to an array of keys matching in size to the number of arguments required for the specified Redis script. |
Advanced: kind, master, client_name, tls, retries, retry_period.
resource
Resource is a processor type that runs a processor resource identified by its label.
Use it inside a connect middleware or a connect endpoint’s pipeline.
Takes a single string value, not a map of fields.
retry
Beta. Attempts to execute a series of child processors until success.
Use it inside a connect middleware or a connect endpoint’s pipeline.
| Field | Type | Default | Description |
|---|---|---|---|
backoff | object | Determine time intervals and cut offs for retry attempts. | |
processors | list of processor | required | A list of processors to execute on each message. |
parallel | bool | false | When processing batches of messages these batches are ignored and the processors apply to each message sequentially. |
max_retries | int | 0 | The maximum number of retry attempts before the request is aborted. |
select_parts
Cherry pick a set of messages from a batch by their index. Indexes larger than the number of messages are simply ignored.
Use it inside a connect middleware or a connect endpoint’s pipeline.
| Field | Type | Default | Description |
|---|---|---|---|
parts | list of int | [] | An array of message indexes of a batch. |
sentry_capture
Captures log events from messages and submits them to Sentry.
Use it inside a connect middleware or a connect endpoint’s pipeline.
| Field | Type | Default | Description |
|---|---|---|---|
dsn | string | "" | The DSN address to send sentry events to. |
message | string | required | A message to set on the sentry event |
context | string | A mapping that must evaluate to an object-of-objects or deleted(). | |
extras | string | A mapping that must evaluate to an object. | |
tags | map of string | Sets key/value string tags on an event. | |
environment | string | "" | The environment to be sent with events. |
release | string | "" | The version of the code deployed to an environment. |
level | string | "INFO" | Sets the level on sentry events similar to logging levels. |
transport_mode | string | "async" | Determines how events are sent. |
flush_timeout | string | "5s" | The duration to wait when closing the processor to flush any remaining enqueued events. |
sampling_rate | float | 1 | The rate at which events are sent to the server. |
sleep
Sleep for a period of time specified as a duration string for each message. This processor will interpolate functions within the duration field, you can find a list of functions here.
Use it inside a connect middleware or a connect endpoint’s pipeline.
| Field | Type | Default | Description |
|---|---|---|---|
duration | string | required | The duration of time to sleep for each execution. |
split
Breaks message batches (synonymous with multiple part messages) into smaller batches. The size of the resulting batches are determined either by a discrete size or, if the field byte_size is non-zero, then by total size in bytes (which ever limit is reached first).
Use it inside a connect middleware or a connect endpoint’s pipeline.
| Field | Type | Default | Description |
|---|---|---|---|
size | int | 1 | The target number of messages. |
byte_size | int | 0 | An optional target of total message bytes. |
sql
Deprecated. Runs an arbitrary SQL query against a database and (optionally) returns the result as an array of objects, one for each row returned.
Use it inside a connect middleware or a connect endpoint’s pipeline.
| Field | Type | Default | Description |
|---|---|---|---|
driver | string | required | A database driver to use. |
data_source_name | string | required | Data source name. |
query | string | required | The query to execute. |
args_mapping | string | An optional Bloblang mapping which should evaluate to an array of values matching in size to the number of placeholder arguments in the field query. | |
result_codec | string | "none" | Result codec. |
Advanced: unsafe_dynamic_query.
sql_insert
Inserts rows into an SQL database for each message, and leaves the message unchanged.
Use it inside a connect middleware or a connect endpoint’s pipeline.
| Field | Type | Default | Description |
|---|---|---|---|
driver | string | required | A database driver to use. |
dsn | string | required | A Data Source Name to identify the target database. |
table | string | required | The table to insert to. |
columns | list of string | required | A list of columns to insert. |
args_mapping | string | required | A Bloblang mapping which should evaluate to an array of values matching in size to the number of columns specified. |
Advanced: prefix, suffix, options, init_files, init_statement, conn_max_idle_time, conn_max_life_time, conn_max_idle, conn_max_open.
sql_raw
Runs an arbitrary SQL query against a database and (optionally) returns the result as an array of objects, one for each row returned.
Use it inside a connect middleware or a connect endpoint’s pipeline.
| Field | Type | Default | Description |
|---|---|---|---|
driver | string | required | A database driver to use. |
dsn | string | required | A Data Source Name to identify the target database. |
query | string | The query to execute. | |
args_mapping | string | An optional Bloblang mapping which should evaluate to an array of values matching in size to the number of placeholder arguments in the field query. | |
exec_only | bool | Whether the query result should be discarded. | |
queries | list of object | A list of statements to run in addition to query. |
Advanced: unsafe_dynamic_query, init_files, init_statement, conn_max_idle_time, conn_max_life_time, conn_max_idle, conn_max_open.
sql_select
Runs an SQL select query against a database and returns the result as an array of objects, one for each row returned, containing a key for each column queried and its value.
Use it inside a connect middleware or a connect endpoint’s pipeline.
| Field | Type | Default | Description |
|---|---|---|---|
driver | string | required | A database driver to use. |
dsn | string | required | A Data Source Name to identify the target database. |
table | string | required | The table to query. |
columns | list of string | required | A list of columns to query. |
where | string | An optional where clause to add. | |
args_mapping | string | An optional Bloblang mapping which should evaluate to an array of values matching in size to the number of placeholder arguments in the field where. |
Advanced: prefix, suffix, init_files, init_statement, conn_max_idle_time, conn_max_life_time, conn_max_idle, conn_max_open.
string_split
Splits a string by a delimiter into an array. Generally, using bloblang’s split method is preferred. In some high performance use cases this processor can be faster than the equivalent bloblang if there is no additional logic.
Use it inside a connect middleware or a connect endpoint’s pipeline.
| Field | Type | Default | Description |
|---|---|---|---|
delimiter | string | "\n" | The delimiter to split the string by. |
empty_as_null | bool | false | When true, empty strings resulting from the split are converted to null. |
Advanced: emit_bytes.
subprocess
Executes a command as a subprocess and, for each message, will pipe its contents to the stdin stream of the process followed by a newline.
Use it inside a connect middleware or a connect endpoint’s pipeline.
| Field | Type | Default | Description |
|---|---|---|---|
name | string | required | The command to execute as a subprocess. |
args | list of string | [] | A list of arguments to provide the command. |
Advanced: max_buffer, codec_send, codec_recv.
switch
Conditionally processes messages based on their contents.
Use it inside a connect middleware or a connect endpoint’s pipeline.
| Field | Type | Default | Description |
|---|---|---|---|
check | string | "" | A Bloblang query that should return a boolean value indicating whether a message should have the processors of this case executed on it. |
processors | list of processor | [] | A list of processors to execute on a message. |
Advanced: fallthrough, continue.
sync_response
Adds the payload in its current state as a synchronous response to the input source, where it is dealt with according to that specific input type.
Use it inside a connect middleware or a connect endpoint’s pipeline.
text_chunker
A processor that allows chunking and splitting text based on some strategy. Usually used for creating vector embeddings of large documents.
Use it inside a connect middleware or a connect endpoint’s pipeline.
| Field | Type | Default | Description |
|---|---|---|---|
strategy | string | required | |
chunk_size | int | 512 | The maximum size of each chunk. |
chunk_overlap | int | 100 | The number of characters to overlap between chunks. |
separators | list of string | ["\n\n", "\n", " ", ""] | A list of strings that should be considered as separators between chunks. |
length_measure | string | "runes" | The method for measuring the length of a string. |
include_code_blocks | bool | false | Whether to include code blocks in the output. |
keep_reference_links | bool | false | Whether to keep reference links in the output. |
Advanced: token_encoding, allowed_special, disallowed_special.
try
Executes a list of child processors on messages only if no prior processors have failed (or the errors have been cleared).
Use it inside a connect middleware or a connect endpoint’s pipeline.
Takes a single list of processor value, not a map of fields.
try_catch
Beta. Executes a list of child processors on each message and, if any of them fail, executes a separate list of catch processors to recover from or react to the error.
Use it inside a connect middleware or a connect endpoint’s pipeline.
| Field | Type | Default | Description |
|---|---|---|---|
processors | list of processor | [] | A list of processors to execute on each message. |
catch | list of processor | [] | A list of processors to execute on each message that failed one of the processors above. |
error_metadata | string | "error" | The metadata key under which the caught error is stored, as an object with a what field (the error message) plus name, label and path fields describing the component that failed, before the catch processors are executed. |
unarchive
Unarchives messages according to the selected archive format into multiple messages within a batch.
Use it inside a connect middleware or a connect endpoint’s pipeline.
| Field | Type | Default | Description |
|---|---|---|---|
format | string | required | The unarchiving format to apply. |
wasm
Executes a function exported by a WASM module for each message.
Use it inside a connect middleware or a connect endpoint’s pipeline.
| Field | Type | Default | Description |
|---|---|---|---|
module_path | string | required | The path of the target WASM module to execute. |
function | string | "process" | The name of the function exported by the target WASM module to run for each message. |
while
A processor that checks a Bloblang query against each batch of messages and executes child processors on them for as long as the query resolves to true.
Use it inside a connect middleware or a connect endpoint’s pipeline.
| Field | Type | Default | Description |
|---|---|---|---|
at_least_once | bool | false | Whether to always run the child processors at least one time. |
check | string | "" | A Bloblang query that should return a boolean value indicating whether the while loop should execute again. |
processors | list of processor | required | A list of child processors to execute on each loop. |
Advanced: max_loops.
workflow
Executes a topology of branch processors, performing them in parallel where possible.
Use it inside a connect middleware or a connect endpoint’s pipeline.
| Field | Type | Default | Description |
|---|---|---|---|
meta_path | string | "meta.workflow" | A dot path indicating where to store and reference structured metadata about the workflow execution. |
order | string | [] | An explicit declaration of branch ordered tiers, which describes the order in which parallel tiers of branches should be executed. |
branches | map of object | {} | An object of named branch processors that make up the workflow. |
Advanced: branch_resources.
xml
Parses messages as an XML document, performs a mutation on the data, and then overwrites the previous contents with the new value.
Use it inside a connect middleware or a connect endpoint’s pipeline.
| Field | Type | Default | Description |
|---|---|---|---|
operator | string | "" | An XML operation to apply to messages. |
cast | bool | false | Whether to try to cast values that are numbers and booleans to the right type. |
URL Parameter Reference
Auto-generated from the same JSON Schemas mqb copy uses to parse --from/--to query parameters (see crates/core/src/endpoint_uri.rs::endpoint_from_uri). For workflow examples, see the connector pages.
- Kafka — schemes:
kafka:// - NATS — schemes:
nats:// - RabbitMQ (AMQP) — schemes:
amqp://,amqps://,rabbitmq://,rabbitmqs:// - MQTT — schemes:
mqtt://,mqtts:// - Redis Streams — schemes:
redis://,rediss://,redis+unix://,valkey://,valkeys://,valkey+unix:// - IBM MQ — schemes:
ibmmq://,ibm-mq:// - AWS SQS / SNS — schemes:
aws://,aws-sqs:// - ZeroMQ — schemes:
zeromq://,zmq:// - PostgreSQL / MySQL / MariaDB / SQLite — schemes:
postgres://,postgresql://,mysql://,mariadb://,sqlite:// - PostgreSQL CDC (logical replication) — schemes:
postgres-cdc://,pgcdc:// - MongoDB — schemes:
mongodb://,mongodb+srv:// - ClickHouse — schemes:
clickhouse://,clickhouses:// - File (CSV / JSON / JSONL) — schemes:
file:// - Directory spool — schemes:
spool://,dir-spool://,dirspool:// - Object Storage (local / cloud) — schemes:
s3://,s3a://,gs://,gcs://,az://,azure://,abfs://,abfss://,local-store:// - HTTP — schemes:
http://,https:// - WebSocket — schemes:
ws://,wss:// - gRPC — schemes:
grpc://,grpcs://
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
output:
mongodb:
url: "mongodb://localhost:27017"
database: "app_db"
collection: "webhooks"
format: "json" # a bit slower, but better readability
middlewares:
- retry:
max_attempts: 3
initial_interval_ms: 500
# 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. retry and dlq belong on the output;
on an input the route fails at start.
input:
middlewares:
- deduplication:
sled_path: "/var/data/mq-bridge/dedup_db"
ttl_seconds: 3600 # 1 hour
kafka:
# ... kafka config
output:
middlewares:
- retry:
max_attempts: 5
initial_interval_ms: 200
- dlq:
endpoint:
nats:
subject: "my-dlq-subject"
url: "nats://localhost:4222"
nats:
# ... nats 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
Which endpoint has a tls block, and how each one authenticates, is in the last table of
CAPABILITIES.md.
TLS through the URL. For several endpoints the URL can ask for TLS on its own, and the
tls block is a shorter way to write the same thing and a place for secrets such as
cert_password. Use one of the two for a given setting, not both:
| Endpoint | In the URL | Notes |
|---|---|---|
mongodb | mongodb://host/?tls=true&tlsCAFile=/ca.pem&tlsCertificateKeyFile=/client.pem, or mongodb+srv:// | Every driver option works. With tls.required the tls block replaces the TLS options of the URL. tls.key_file is ignored: the key belongs in cert_file. |
sqlx, postgres_cdc (Postgres) | ?sslmode=verify-full&sslrootcert=/ca.pem | tls.required adds sslmode=verify-full, or require with accept_invalid_certs. The default sslmode=prefer falls back to plain text. postgres_cdc sends a client certificate from tls.cert_file and tls.key_file, or from sslcert and sslkey. |
sqlx (MySQL, MariaDB) | ?ssl-mode=VERIFY_IDENTITY&ssl-ca=/ca.pem&ssl-cert=/client.pem&ssl-key=/client.key | tls.required adds ssl-mode=VERIFY_IDENTITY (chain and host name), or REQUIRED with accept_invalid_certs. An ssl-mode in the URL wins over the block, so a server reached by IP address or by a name its certificate lacks can set ssl-mode=VERIFY_CA there. tls.cert_file and tls.key_file are passed as ssl-cert and ssl-key. cert_password is ignored by every sqlx driver: the key file must be unencrypted. |
redis_streams | rediss://host:6379 | Same as tls.required: true. The server certificate is checked against the public roots, or against tls.ca_file. tls.cert_file and tls.key_file add a client certificate; an encrypted key (cert_password) is not supported. accept_invalid_certs is ignored. |
amqp | amqps://host:5671 | With tls.required: true the tls block adds a private CA (ca_file) and a client identity: cert_file and key_file as PEM, or cert_file alone as a PKCS#12 bundle with cert_password. accept_invalid_certs is ignored. |
mqtt | mqtts://host:8883 or ssl://host:8883 | Same as tls.required: true. |
nats | tls://host:4222 | |
http, http_bulk, clickhouse | https:// | tls.required: true with an http:// URL is refused by http and http_bulk. |
grpc | – | TLS is set up by the tls block only: use tls.required: true. |
websocket | wss:// | A listener takes its certificate from the tls block. |
What is logged as a warning at start:
- A password or token that would cross the network without TLS, for
amqp,mqtt,nats,mongodb,redis_streams,sqlx,http,http_bulkandclickhouse. A server onlocalhostor a loopback address is exempt. Forsqlxonly an enforcing mode in the URL counts as TLS (sslmode=requireor stricter,ssl-mode=REQUIREDor stricter). tls.accept_invalid_certs: true, on every endpoint that honours it.amqpwarns that it ignores the setting; agrpcclient refuses it.
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.
- Listeners.
httpandwebsockethavebasic_authand mutual TLS (tls.ca_file). Agrpcserver has mutual TLS only; without it, bind it to a trusted network or put a proxy in front. Thewebsocketlistener has no limit on the number of connections unlessmax_connectionsis set. Thegrpcserver always registers the reflection service, so its service list is public to whoever can connect. - Memory bounds. An
httplistener accepts up tomax_body_bytesper request (default 256 MB); with many connections that is the bound on memory, so lower it for a public listener. Responses thathttp_bulkandclickhouseread from their configured server are not limited in size. Debugoutput of a config hides its secrets.{:?}of an endpoint config prints passwords, tokens, keys and sensitive headers as***and blanks the password of a URL, as the engine’s own log lines and errors do. Acustomendpoint’s config is printed as it is.zeromqhas no encryption and no authentication. Use it on a trusted network only.
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"
Directory spool (dir_spool)
The dir_spool endpoint is a crash-safe FIFO queue whose backing store is a directory. Each
message becomes a chunk: a payload file holding the raw CanonicalMessage payload bytes,
plus an optional JSON sidecar holding its metadata. Chunks are written to a .tmp name,
fsynced, and renamed into place, so a reader listing the directory never sees a partial
write; on the reading side a chunk is deleted once its message is acknowledged.
Use it to decouple a fast producer from a slower consumer across a process or language
boundary — a Rust edge engine feeding a Python data-science script, say — with no broker to
run and no shared memory to size. The producer can finish and exit while the consumer is
still draining, and a crash on either side leaves a directory you can inspect with ls.
Prefer the file endpoint instead when the data is a stream of
delimited records appended to one file. dir_spool is for a queue of arbitrarily large
opaque blobs, where the delimiter framing and the single append point both get in the way.
| Field | Side | Default | Meaning |
|---|---|---|---|
path | both | — | Spool directory. Created if missing. |
naming_pattern | sink | {seq:09} | Chunk name without extension. Supports {seq}, {seq:06} / {seq:06d}, {timestamp} (unix millis) and {message_id}. |
shard_depth | both | 0 | Levels of shard subdirectory to spread chunks over. 0 writes every chunk straight into path. |
shard_width | both | 3 | Characters of the sequence number each shard level consumes. Ignored when shard_depth is 0. |
payload_extension | both | bin | Extension of the payload file, with or without the leading dot. |
metadata_extension | both | json | Extension of the metadata sidecar. Empty string writes and expects payload files only. |
atomic | sink | true | Write to .tmp and rename on completion. |
fsync | both | chunk | Durability: chunk fsyncs every chunk file and the directory once per batch, off fsyncs nothing and lets the OS flush. |
done_file | both | DONE | Sentinel that marks production finished. |
emit_done | sink | never | When to create done_file: success (the route reached the end of its input and every chunk was written), end (whenever the producer closes, however the pass ended), never. Set it on the last producer only. |
producer_file | both | PRODUCER | File holding the producer lock, which keeps a second producer out. |
consumer_file | both | CONSUMER | File holding the consumer lock, which keeps a second draining consumer out. |
drain_on_read | source | true | Delete each chunk once its message is acknowledged. |
stop_on_done | source | false | End the stream once the queue is empty and done_file is present. |
poll_interval_ms | source | 100 | Idle poll interval when the directory holds no new chunks. |
source_metadata | source | false | Stamp mqb.src.spool_path and mqb.src.spool_chunk. |
claim | both | exclusive | Lock this side of the spool: exclusive refuses to start when another instance holds the same role, warn logs and runs anyway, off takes no lock at all. |
Lexical order is queue order, so naming_pattern must start with the sequence — a sink
whose pattern begins with anything else is rejected. The front of a chunk name is
load-bearing twice over: the listing is ordered by it, and a publisher opening an existing
spool reads its next sequence number back out of it, resuming past the highest chunk already
there rather than overwriting the head of the queue. {timestamp}_{seq} would resume from a
13-digit timestamp, and a literal prefix would resume from zero and overwrite. Padding is
not enforced, but an unpadded {seq} warns: chunk 10 sorts before chunk 2. The sequence
must be followed by something that is not a digit: {seq:09}_{timestamp} is accepted,
{seq:09}{timestamp} is rejected, because the two numbers could not be told apart.
The sequence is read from the chunks on disk, so a producer that opens a fully drained spool starts at 0 again and chunk names repeat. Two setups are affected:
- A reader with
drain_on_read: falseremembers the names it has read and skips a new chunk with an old name. That chunk is never delivered to it. mqb.src.spool_chunk(withsource_metadata: true) repeats across producer runs, so adeduplicationkeyed on it takes new messages for duplicates.
For both, add {timestamp} to the pattern, as in {seq:09}_{timestamp}: names are then
unique across runs. The default consumer (drain on read, no source_metadata) is not affected.
payload_extension and metadata_extension must also differ, on both sides — a consumer
whose two extensions match would read every sidecar as a payload.
Cardinality, and how production ends
One spool directory takes one producer and one consumer at a time, each of which may be
internally concurrent — concurrency > 1 on either side is fine, since the sequence counter
and the in-flight set are shared within an instance. A second instance in the same role
corrupts the queue rather than sharing it: two producers seed from the same highest sequence
number and overwrite each other’s chunks, and two draining consumers each deliver every
chunk they win the race to read.
So each side takes a pid lock on its own file in the spool — producer_file and
consumer_file — created when the endpoint opens and removed when it closes, and claim: exclusive (the default) refuses to start when its role is already held, naming the holder.
The two roles never conflict with each other, so a producer and a consumer sharing a
directory — the point of the endpoint — needs no configuration. Every instance touching one
spool must of course agree on the two names; that agreement is what makes them exclude each
other.
At a time is the operative phrase: the locks say “someone is running”, not “production is
finished”. A spool may be filled by several producers in turn, so the end of the stream is a
separate signal — the done_file sentinel:
stop_on_doneends the stream once the queue is empty anddone_fileis present, so a producer that finished long ago still has its backlog drained first, and the gap between two producers does not cut the stream short.- Only the last producer should set
emit_done, and its value says what “finished” has to mean. The sentinel is written as that producer closes, before its lock is released, so a hand-off to another producer cannot lose it.successis the strict reading: the route reached the natural end of its input (an exhausted source, or a--drainthat emptied it) and every chunk the producer accepted reached the disk. A route that is shut down, that fails, or that reconnects writes nothing, so astop_on_doneconsumer keeps waiting for the production that did not finish. A chunk write that failed counts as written once the same message (same id and payload) is written later, as aretrymiddleware does; a message that went to adlqor was dropped keeps the sentinel back. Up to 10,000 failed messages are tracked; beyond that, and after a failed directory sync, the sentinel is not written. A continuously running producer never reaches a natural end, sosuccesson one of those means “never” in practice — useendthere.endis the loose reading: nothing more is coming from here, whatever the reason. It says nothing about whether everything worked, so a consumer will treat a truncated stream as the whole of it — which is the right trade when the alternative is waiting forever on a producer that may have died.
- A producer opening the spool deletes an existing sentinel: it is producing again, and
a stale marker would tell a
stop_on_doneconsumer to exit the moment its queue first ran dry. - A producer that crashes never writes the sentinel, so a
stop_on_doneconsumer keeps waiting — correct, because production did not finish. Its lock, on the other hand, is broken automatically, so the restarted producer can pick up where it left off.
Two things to know about the locks themselves:
- A lock is broken only when its owner is provably gone. The check is by process id, so it works for a crash on the machine that wrote the lock. A spool shared between hosts or containers cannot be judged that way: clear such a lock by deleting the file, and be aware that a pid check across a pid-namespace boundary can also break a lock whose owner is alive but invisible.
- The three control files must not collide with each other or with a chunk. A name that
ends in
payload_extensionwould be delivered as a message, one ending inmetadata_extensionwould be read as a chunk’s sidecar, one ending in.tmplooks like a chunk mid-write, and two roles under one name would have each release deleting the other’s lock. All of these are rejected at startup (and by route validation), compared case-insensitively so thatDONEanddonecount as one file on Windows and macOS.
Genuinely shared spools are still possible with claim: warn or claim: off, at a price on
each side. Several simultaneous producers need {message_id} in naming_pattern to keep
names from colliding. Several draining consumers get duplicate delivery: a chunk is
claimed only in the reading process’s memory, and deleted only once its message is
acknowledged, so two drainers can both read one before either deletes it and both will
deliver it. There is no on-disk claim — no rename-to-claim handshake — so competing
consumers do not partition a spool between themselves. The lock is what makes that
arrangement hard to enter by accident; if you switch it off, one drainer per spool is still
the rule.
drain_on_read: false is the one exception to the cardinality rule: such a reader deletes
nothing, so several of them over one spool each see every chunk once and none of them takes
a lock — though each will warn if a draining consumer holds the directory, because that one
deletes chunks out from under it. It remembers what it has read by name, in memory, for as
long as the run lasts: a chunk may appear with a name below one already delivered, so a
position alone could not tell the two apart. Budget for that on a spool of millions of
chunks that is never drained, and restart the reader to reset it — a fresh run re-reads
the directory from the beginning.
Throughput and durability
The endpoint’s dominant cost is fsync, not mq-bridge. The default fsync: chunk is two
fsyncs per message when a sidecar is written — the payload and its sidecar — plus one
directory fsync per batch. Measured on one machine (btrfs on NVMe, 64 KiB payloads, one
message per send_batch), single sink, no route:
fsync: chunk | fsync: off | |
|---|---|---|
| with sidecar | ~210 msg/s | ~2100 msg/s |
metadata_extension: "" | ~360 msg/s | ~3900 msg/s |
On tmpfs the same run gives ~3500 and ~9200 msg/s respectively, which is the number to use
if the spool lives in /dev/shm — and a caution against sizing from a tmpfs measurement for
a spool that will land on a disk. Treat these as an order of magnitude, not a specification:
a spinning disk is far slower and a battery-backed controller far faster.
Two ways to buy headroom before reaching for fsync: off: drop the sidecar when the payload
is self-describing (metadata_extension: "", which nearly doubles the rate and halves the
file count), and batch — the per-batch directory fsync is amortized over the batch, so
batch_size: 32 pays it once rather than 32 times. fsync: off trades the endpoint’s
crash-safety claim, not just its tail: a power loss can leave a chunk present but truncated,
which a consumer would deliver as a short message.
Sharding
A flat spool is one directory per stream, and most filesystems degrade long before mq-bridge
does — 30fps with sidecars is over 200,000 files an hour. shard_depth spreads chunks over
subdirectories named from the leading digits of the sequence number, so with the default
{seq:09}, shard_depth: 2 and shard_width: 3, chunk 1 is written as 000/000/001.bin
(its sidecar beside it) and no directory ever holds more than 1000 entries.
Queue order is unaffected: lexical order over the whole relative path is still sequence
order, and a chunk’s recorded identity (mqb.src.spool_chunk) becomes that path. Sharding
also makes a large backlog cheaper to read, not just to store — the consumer walks shards in
order and stops as soon as its listing is full, so it reads the leading shards rather than
the whole tree, and a drained shard directory is pruned as it empties.
Three things to get right:
naming_patternmust start with a zero-padded{seq:0N}wider thanshard_depth * shard_width. The directory names are cut from the front of every rendered name, so that front has to be a fixed width — otherwise chunk 999 and chunk 1000 would shard to different depths and lexical order would stop being queue order. A pattern that cannot be split is rejected at startup, as is one with no room left for the file name.- Both ends must agree on
shard_depthandshard_width. A consumer descends only as far as its own depth, into directory names only as wide as its own width, so either one set differently from the producer reads the spool as permanently empty (and understop_on_done, ends the stream immediately). It warns when a scan finds subdirectories it is not configured to enter, which is what that mistake looks like. - A control file cannot be shaped like a shard.
done_file: "000"withshard_width: 3is rejected, since one name cannot be both the sentinel and a directory.
A directory scan keeps up to 65,536 chunk names for the batches that follow, so draining a
backlog costs one scan per that many messages rather than one per batch. Two consequences
are visible from outside: chunks that arrive while a listing is still being served are
picked up only once it is exhausted (within poll_interval_ms on an idle spool), and the
batch that empties a listing can be shorter than the route’s batch_size.
Example: video frames written by one process and drained by another.
# Producer: raw H.264 chunks, no sidecar — an Annex B chunk is self-describing, and a
# sidecar per frame would double the file count and the fsyncs for nothing. Keep the
# default `metadata_extension: "json"` on a spool whose metadata has to travel.
# Holds the PRODUCER lock while it runs; on a clean finish it writes DONE, which is what
# ends the consumer's stream below. Use `emit_done: end` instead if the consumer must not
# wait on a producer that may die.
frame_capture:
input:
memory:
topic: "frames"
output:
dir_spool:
path: "/tmp/video_telemetry_spool"
naming_pattern: "{seq:09}_{timestamp}"
# 30fps fills a flat directory fast: two levels of three digits keep every
# directory under 1000 entries. The consumer needs the same shard_depth.
shard_depth: 2
shard_width: 3
payload_extension: ".h264"
metadata_extension: ""
emit_done: success
# Consumer: drain in sequence order, delete as we go, exit when the producer is done.
frame_ingest:
input:
dir_spool:
path: "/tmp/video_telemetry_spool"
shard_depth: 2
shard_width: 3
payload_extension: ".h264"
metadata_extension: ""
drain_on_read: true
stop_on_done: true
output:
mongodb:
url: "mongodb://localhost:27017"
database: "telemetry"
collection: "frames"
A message nacked by the route is left on disk and redelivered on the next poll. A chunk
whose payload file exists without a sidecar is still delivered, with empty metadata — which
is what lets a foreign producer write into the spool with nothing but write() and
rename().
Further reading
dir_spool is a deliberately old idea. Spooling — buffering plus a queue, so that a fast
producer hands work off and carries on while a slow consumer drains it at its own pace — has
been the standard answer to this problem since the 1960s, and the print spooler is its
canonical form. These are worth reading before designing anything on top of a spool
directory:
- Spooling (Wikipedia) — the concept and its vocabulary, the name (said to come from “Simultaneous Peripheral Operations On-Line”, possibly a backronym), and a bibliography that goes back to the primary sources: Lundin & Stoneman’s The Spooler User Guide (1977), Donovan’s Systems Programming (1972), and the spooling chapters of Peterson & Silberschatz and Tanenbaum.
- Printing and spooling
(University of Florida CISE, system administration lecture, 2004) — Unix print spooling
from the operator’s side: BSD copying job data into the spool directory under a job id
versus System V writing a request file that points at the original, the
lpd/lpscheddaemons, and the operational concerns that outlive the software — who owns the spool directory and with what permissions, and which hosts may submit into it. - Spooling: print spoolers in practice — a
modern worked example, tracing a job through CUPS end to end: the
cupsdscheduler, the queue under/var/spool/cups/, filter chains, and device backends. - Spooler Programmer’s Guide, Hewlett-Packard, part number 522287-002, August 2012 (PDF — the file is named for Spoolcom, one component it documents, but it is the programmer’s guide) — the deepest of these, and the one closest to this endpoint’s problem. It specifies a production spooler (HP NonStop, documented since 1995) as a set of cooperating processes with explicit state machines: a supervisor, collectors that accept data from application programs and write it to disk, print processes that drain a job and mark it complete, and perusal processes that read spooled data without consuming it. Worth reading for the state diagrams — job, collector, print process, device — and for the chapter on spooling from a fault-tolerant process pair, which is the classical treatment of the question this endpoint answers with locks and redelivery: what a spool owes a producer that may die mid-job.
Four things dir_spool takes from that tradition. The directory is the queue, so the
producer’s obligation ends when the file lands and a crash on either side leaves a state you
can read with ls. Data is copied into the spool rather than referenced in place — the BSD
choice rather than the System V one — which is what lets the producer finish and exit while
the consumer is still draining. The control information is a separate file from the data,
which is what the payload/sidecar split is, and why the two extensions must differ. And a
reader that consumes is a different thing from a reader that only looks: NonStop’s perusal
process is drain_on_read: false, which is why several of those may share a spool while
only one drainer may.
The vocabulary maps closely enough to be worth keeping in mind. A NonStop collector is
this endpoint’s publisher, a print process its consumer, PRINTCOMPLETE the acknowledgement
that deletes a chunk, a job number the sequence in a chunk’s name, and the supervisor’s
ready list the cached listing a consumer serves batches from.
What it does differently is have no supervisor. A classic spooler puts one privileged daemon
in front of the queue and lets it serialize access and own the state machine; dir_spool has
no daemon at all, which is why exclusion is a pair of pid locks, why the cardinality rules
above have to be stated rather than enforced by a process boundary, and why the states a
chunk moves through are implicit in the filesystem rather than written down as a diagram. The
filter chains a print spooler applies on the way out are, in mq-bridge, the route’s middleware
and handlers.
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
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 the connector pages 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.
An incoming message passes the last entry first and the first entry last: list
[aggregate, deduplication] to drop duplicates before they are counted.
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 |
timeout | – | ✅ | – | Fail a send that does not finish in time as retryable |
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 |
pack | – | ✅ | – | Combine a batch into one physical transport message |
unpack | ✅ | – | – | Split a packed physical message back into messages |
metrics | ✅ | ✅ | metrics | Emit throughput/latency/error metrics |
otel | ✅ | ✅ | otel | OpenTelemetry span per message, continuing traceparent |
lookup | ✅ | ✅ | – | Enrich each message with other endpoints’ responses |
aggregate | ✅ | ✅ | aggregate | Keep a running state per key and write it into each message |
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. Stacking compression with theencryptionmiddleware only works in one order — the codec must run before the cipher, since ciphertext does not compress. On an output that means listingencryptionbeforecompression, so compression ends up outermost of the two: see compress-then-encrypt over the wire. For compressed-and-encrypted data at rest, prefer the endpoints’ owncompression/encryptionfields, which do this per write batch.
Putting a middleware on the wrong side is a hard startup error, so check the table above:
dlq/retryon an input. A route that started without the retries or the dead-letter queue its config names would be worse than one that refuses to start.deduplication,weak_joinandidon an output. Deduplication cannot work on the publish side, and silently starting an un-deduplicated route is worse than refusing to start.packon an input andunpackon an output are hard errors too — the pair is directional.
A middleware whose feature is not compiled in (deduplication without dedup, metrics
without metrics, otel without otel, aggregate without aggregate) is likewise a startup error, not a silent no-op.
retry
Retries failed sends with exponential backoff. Output only. On an input the route fails at start.
| 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. On an input the route fails at start.
| 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. Paths have no quoting, so a source key that itself contains . or [ (a CSV header
such as price.usd) cannot be addressed: the path reads it as nesting and finds nothing.
Error paths spell such keys unambiguously, as $['price.usd'].
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. string → number keeps integral text an exact integer ("42" → 42,
"9007199254740993" unrounded); other text goes through f64, so digits beyond ~17
significant ones are rounded.
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, or a map when you need on_error:
- filter: "amount > 100"
- filter:
expression: "amount > 100"
on_error: drop
| Field | Type | Default | |
|---|---|---|---|
expression | string | required | The expression. |
on_error | fail | drop | fail | What to do with a message the expression cannot evaluate. fail stops the route; drop logs a warning and drops the message like a non-matching one. |
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, and so are all CSV fields and a SQL source’snumericand timestamp columns. Comparing one against a numeric literal reads it as a number, someta.http_status_code >= 400needs no cast. The literal is what decides:zip == "01234"stays a string comparison, andnumber()is still required where no literal names the intent (meta.a > meta.b,amount > 100 * 2) or where the text is not a number at all.
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 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. Set on_error: drop
when a source legitimately mixes in messages the expression cannot read.
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) |
replay_response | bool | no (default false) |
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[?durable=true](or a bare path) — a local sled database; per-process, not cluster-wide. sled writes to disk in the background, about every 500 ms, so a killed process can lose its newest markers and reprocess those messages.?durable=trueflushes before every source ack instead, at the price of one disk sync per batch.memory://[name][?max_keys=N]— process memory, the fastest store (~3x sled). Keys are compared exactly and survive reconnects and redeploys, but not a restart. Routes that name the same store share it; the name defaults to the route’s.max_keys(default 1,000,000) caps it: past the cap the oldest keys are evicted before their TTL, so later copies of them pass through again (a warning is logged once).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>".
A shared store’s default collection/table is named after the route, so replicas of one route must run under the same route name. A copy whose key is still in flight elsewhere waits for that delivery instead of being dropped; a failed delivery releases its key at once. A delivery in flight keeps its key however long the sink takes; only the key of a holder that died is freed, after five seconds. The ordering and its crash windows are in DELIVERY.md.
replay_response: true answers a duplicate request with the reply its first delivery produced,
stored next to the marker for the TTL — for request/reply inputs whose callers retry. Off by
default.
- deduplication: { store: "sled:///var/lib/mq-bridge/dedup", ttl_seconds: 3600 }
- deduplication: { store: "sled:///var/lib/mq-bridge/dedup?durable=true", ttl_seconds: 3600 }
- deduplication: { store: "memory://orders?max_keys=5000000", ttl_seconds: 600, key: "${payload:order_id}" }
- 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}" }
- deduplication: { store: "sled:///var/lib/mq-bridge/dedup", ttl_seconds: 600, key: "${metadata:idempotency-key}", replay_response: true }
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 |
ack | on_join | on_receive | on_join |
# 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
ack: on_join
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).
A discarded group’s members are acknowledged at once.
With ack: on_join (the default) a buffered message is acknowledged only once the joined
message it went into is committed downstream; a Nack of the joined message nacks its members,
and a crash before a group completes redelivers them. On a source that needs ordered commits
(Kafka, file, SQL) a source batch is committed only after every older batch is, so an open
group holds back later commits for up to timeout_ms. The source must also let enough
messages stay unacknowledged: with a prefetch limit (prefetch_count on AMQP and
NATS) below the number of messages held in open groups, groups can only finish by
timeout. ack: on_receive acknowledges on receipt instead, which avoids that limit but loses
the buffered members on a crash.
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.
On an output, each caller of a single send gets its own result back: its response, its
error, or the acknowledgement. A response is matched to its request by message id. Placed
after a lookup or an aggregate, buffer therefore lets
concurrent single sends share one batched lookup or one store round trip.
max_delay_ms: 0 on an output adds no wait. A send goes out at once when nothing is in
flight; sends that arrive while a batch is in flight leave together as the next batch, so
batches grow with the load:
- buffer: { max_messages: 500, max_delay_ms: 0 }
Only one batch is in flight at a time, with any max_delay_ms.
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.
timeout
Bounds each send. A send still pending after timeout_ms is dropped and fails as
Retryable, instead of blocking the route forever. Output only; on an input it fails at
startup.
| Field | Type | Required |
|---|---|---|
timeout_ms | integer | yes |
- timeout: { timeout_ms: 30000 }
- retry: { max_attempts: 3 }
List it before retry, so every attempt gets its own bound. The sink may already
have accepted a batch that timed out, so a retry can deliver it twice.
Without this middleware a stuck send is still visible: a route whose send has been
pending for a minute reports healthy: false with details.state: "send pending".
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 | {} |
max_cookies | integer — cap per session store | 256 |
- 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. A response with
several Set-Cookie headers arrives as one set-cookie value with one cookie per line, and
each line is stored.
A jar is one session. It also stores the cookie value of every message that passes it, so do
not put it on an input that serves several clients (an http server): their cookies would be
merged and sent on with each other’s requests. export_metadata_prefix copies cookie values
into message metadata, which travels on to sinks and a dlq.
Cookie names are chosen by the server, so the jar is bounded: Max-Age=0 (or negative)
deletes a cookie, and once max_cookies is exceeded the least recently set entries are
dropped. Expires is not parsed — use Max-Age, which every modern server also sends.
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 | {} |
authenticate_metadata | list of metadata keys bound into the AEAD tag (middleware only) | [] |
on_error | drop | fail — what an input does with a payload it cannot decrypt (middleware only) | drop |
- 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, so any bit-level tampering, a torn frame, or a
missing/wrong key fails decryption.
What happens then is set by on_error. The default, drop, logs the message, acknowledges
it and carries on — the route keeps running and the message is gone from the source. That
includes a wrong or missing key, where every message fails: a misconfigured reader then drains
its input. Set on_error: fail to stop the route on the first decryption failure instead,
leaving the message unacknowledged.
By default 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.
authenticate_metadata closes that gap for keys you know survive your transport. Listed
keys stay in the clear but are bound into the tag, so altering, removing or adding one in
transit fails decryption exactly like a tampered payload — no extra field or second key is
needed, since the AEAD tag already is the checksum:
- encryption:
key: "${env:MQB_ENC_KEY}"
authenticate_metadata: [tenant, mqb.id]
Both sides must configure the same list; a mismatch surfaces as a decrypt failure. Keys
are bound in the order listed, and an absent key is distinguished from an empty one. These
envelopes are tagged version 2, so adding the field is a one-way switch for data already at
rest or in flight — drain the route first. The field applies to this middleware only; the
file / object_store at-rest encryption fields reject it, since there is no per-message
metadata at that layer. 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). With format: parquet (object_store only) compression sets the
Parquet column codec instead and the extension stays .parquet. 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 csv sink takes its columns from the first row it writes, in that row’s field order. An
object_store sink writes each batch as a CSV file of its own, with its own header taken from
that batch’s first row; set columns when every object must have the same ones.
csv on its own means comma-separated, "-quoted, with a header record. The csv block
describes any other dialect, for the file and object_store endpoints:
input:
file:
path: "export.csv"
format: csv
csv:
separator: ";" # one character, `tab`, `space`, hex (`0x1f`), or `auto`
quote: "'" # one character, or `none`; default `"`
header: false # first record is data; a source then needs `columns`
columns: [id, name] # replaces the header's names; a sink writes exactly these
| Field | Default | Meaning |
|---|---|---|
separator | , | Field separator. auto (sources only) picks whichever of , ; tab | occurs most often outside quotes in the first record, and , on a tie. |
quote | " | Quote character; a doubled one inside a quoted field is a literal. none reads every byte as data and makes a sink fail a value it would have to quote. |
header | true | Whether the first record names the columns. A sink with false writes rows only. |
columns | — | Source: the keys to use, instead of the header’s. Sink: the columns to write, in this order; other keys are dropped. |
nested | flatten | Sink only. flatten writes a nested object as one parent.child column per leaf; json writes its JSON text into one cell. Arrays are always JSON text. |
on_mismatch | warn | Sink only. What happens to a record whose keys differ from the columns. warn writes it, with missing columns empty and extra keys dropped, and logs once. fail rejects the record as non-retryable, so a dlq takes it; with columns set, only a missing column counts. A source refuses fail at start. |
delimiter stays the record separator and is independent of all this; it only must not
contain the separator or the quote character. Common exports: Excel “CSV UTF-8” in a
semicolon locale is separator: ";" (the byte-order mark and CRLF are handled already),
mongoexport --type=tsv and psql -A -F $'\t' are separator: tab, and psql --csv and
COPY … WITH (FORMAT csv) are the default. PostgreSQL’s COPY text format (backslash
escapes, \N) and UTF-16 files are not CSV dialects and are not read.
A row with more fields than the header drops the extras, and one with fewer reads the missing
ones as empty. Each is warned about once per process; a short row is the usual sign of a wrong
separator.
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) |
on_error | fail | drop — what an input does with a payload it cannot decompress | fail |
- 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.
on_error: drop logs such a payload, acknowledges it and carries on with the rest of the
batch, so one bad message does not stop the route:
- compression: { algorithm: zstd, on_error: drop }
Without max_decompressed_bytes a small payload can decompress to gigabytes, and a batch
is decompressed in parallel. Set it on any input that producers you do not control can
write to, for example 67108864 (64 MiB).
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.
pack
Combines the messages of one publish batch into a single physical transport message, so a
thousand rows cost one transport operation instead of a thousand. Output only — pair it
with unpack on the reading route’s input.
| Field | Type | Default |
|---|---|---|
format | mqb | benthos_binary | mqb |
max_messages | integer — logical messages per physical message | 1000 |
max_bytes | integer — body-size threshold that closes a physical message | 4194304 (4 MiB) |
drop_message_id | boolean — leave each message_id out, saving 16 bytes per record | false |
- pack: { max_messages: 1000 }
A batch larger than either bound is split into several physical messages, and the final
partial batch is sent like any other. max_bytes is a threshold, not a hard cap: a single
message larger than it is sent on its own rather than being dropped.
Compression is separate. pack has no codec of its own — stack the
compression middleware around it and the whole physical message is
compressed, which is where the ratio is best anyway. Order matters
(see Ordering); on the output,
pack must be outermost of the two so it frames before the codec compresses:
- compression: { algorithm: zstd }
- pack: { max_messages: 1000, max_bytes: 4194304 }
- buffer: { max_messages: 1000, max_delay_ms: 20 }
buffer goes last so it ends up outermost and hands pack a full batch. A route already
reads its input in batch_size chunks, so buffer is only needed when batch_size is
small or messages arrive one at a time.
The physical message carries no per-message metadata of its own — only mqb.pack.count —
so do not combine pack with a sink that interpolates ${metadata:…} into a topic,
subject or path. It is likewise incompatible with request/reply: one physical message has
one acknowledgement and cannot carry N replies.
To save 16 bytes per record, set drop_message_id: true — the unpacked messages then get
fresh ids. To drop metadata too, use format: benthos_binary.
unpack
Splits a physical message written by pack back into its logical messages.
Input only.
| Field | Type | Default |
|---|---|---|
format | mqb | benthos_binary — must match the sender | mqb |
max_messages | integer — reject a batch declaring more messages than this | unset (no limit) |
on_error | fail | drop — what happens to a physical message that is not a valid batch | fail |
- unpack: {}
A malformed batch, or one over max_messages, stops the route by default. on_error: drop
logs it, acknowledges it and reads on; all logical messages inside it are lost with it:
- unpack: { on_error: drop }
Set max_messages on an input that producers you do not control can write to: each
record of a few bytes becomes a full message in memory.
The reading list is the mirror of the writing one, so compression comes after
unpack — the codec then decompresses the physical message before unpack sees it:
- unpack: {}
- compression: { algorithm: zstd }
With format: mqb, unpacked messages behave exactly like any other: payload, metadata,
message_id and order are all as they were before packing. format: benthos_binary carries
payloads only — an input reading it gets the payloads and their order back, but no metadata
and no message_id. Either way receive_batch still honours the route’s batch_size —
anything over it is held and handed out on the next read.
Payloads are sliced out of the physical message rather than copied, so unpacking costs
almost nothing per record — except for metadata, which has to be rebuilt into an owned
map (one allocation for the map plus two per key/value pair, per message). On the
benches/pack_bench.rs corpus that is the difference between ~64M and ~4.5M records/s.
It is the same price any consumer pays for metadata, and still cheaper than decoding a
per-message metadata frame; but if a pipeline does not need per-record metadata,
format: benthos_binary skips it entirely.
A malformed, truncated or foreign payload is a permanent consumer error, not a reconnectable one, so a poison message is not re-read forever. So is an envelope written by a newer format version.
Acknowledgement. The physical message is what the transport acks, so the messages inside
one share its fate. unpack holds the source’s commit until every message it produced has
been dispositioned, then collapses the group: any Nack nacks the whole physical message and
all N are redelivered. Nothing is lost; some may be seen twice. That is the same at-least-once
widening a batch_size above 1 already has — pair with deduplication or
an idempotent sink if duplicates matter.
Interoperability. format: benthos_binary reads and writes the layout Redpanda Connect’s
archive: binary / unarchive: binary processors use (u32 big-endian count, then a u32
big-endian length before each payload). It carries payloads only — metadata and message_id
have nowhere to go:
- unpack: { format: benthos_binary }
Compress-then-encrypt over the wire
pack, compression and encryption compose into the full wire stack, and the
ordering rules decide which way round they have to go. On an output, the last entry is
outermost and runs first, so listing them in this order gives
encrypt(compress(pack(messages))):
- encryption: { key: "${env:MQB_KEY}" }
- compression: { algorithm: zstd }
- pack: { max_messages: 1000 }
- buffer: { max_messages: 1000, max_delay_ms: 20 }
Read the list bottom-up to follow a message: buffer collects, pack frames the batch
into one physical message, compression compresses that whole message, encryption seals
the result. Packing first is what makes the compression worth having — the codec sees a
thousand similar records instead of one payload at a time.
The input list is the plain mirror, with unpack in place of pack:
- unpack: {}
- compression: { algorithm: zstd }
- encryption: { key: "${env:MQB_KEY}" }
Getting either order wrong fails loudly rather than silently: compressing ciphertext merely wastes time, but decrypting something that was never encrypted, or unpacking something still compressed, is a permanent error on the first message.
Asserted by
middleware::pack::tests::pack_compression_and_encryption_stack_in_the_documented_order.
metrics
Emits a message counter (queue_messages_processed_total) and a duration histogram
(queue_message_processing_duration_seconds) 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.
Only successful messages are counted; there is no failure counter. On an input a message counts when it is received, not when it is committed, and the duration includes the time spent waiting for it to arrive.
otel
Opens an OpenTelemetry span per message and carries its W3C trace context in the
traceparent / tracestate metadata. Input and output. Requires the otel feature. Takes no
options.
- otel: {}
- Input: a
Consumerspan named<route> receivethat continues the incomingtraceparentand lasts until the message is committed. A Nack or a failed commit marks it as an error. The metadata then points at this span, so the output continues the trace. - Output: a
Producerspan named<route> send, ending with the send result. The published message carries the newtraceparent, so a downstream service can continue the trace. - Attributes:
mqb.routeandmessaging.message.id.
The library only uses the OpenTelemetry API; the host exports the spans. The middleware
activates itself when a tracer provider is installed (opentelemetry::global::set_tracer_provider,
re-exported as mq_bridge::opentelemetry) before the route starts. Without one it is left
out of the chain at startup and costs nothing per message. mq-bridge-app installs an OTLP
HTTP exporter when OTEL_EXPORTER_OTLP_ENDPOINT or OTEL_EXPORTER_OTLP_TRACES_ENDPOINT is set;
OTEL_SERVICE_NAME defaults to mq-bridge-app.
The activation check starts one unsampled mq-bridge.probe span per middleware. The default
parent-based sampler drops it, but an always_on sampler exports it.
lookup
Asks other endpoints per message and writes their responses into the payload. Input and output. The typical use is enriching an event with records fetched by key, then routing on whether they were found. On an input, the handler already sees the enriched message.
| Field | Type | Default |
|---|---|---|
from | endpoint | must answer with a response; required unless entries is set |
into | dotted payload path | required with from |
metadata | map string→template | {} |
payload | template | the message’s own payload and metadata |
entries | list of {from, into, metadata, payload} | []; further lookups |
concurrency | integer | 16 requests in flight per entry, when from cannot answer a batch at once |
- lookup:
from:
http:
url: "http://users:8080"
pass_through_status: true
metadata:
http_path: "/users/${payload:user_id}"
http_method: GET
into: user
metadataandpayloadare placeholder templates, rendered against the outgoing message.metadatais added to the request, so it setshttp_path,http_methodorhttp_queryon anhttpendpoint. Those three keys are not inherited from the message.- The response is parsed as JSON; a non-JSON response is written as a string. An empty
response, or HTTP status 404, writes
null. The metadatalookup.foundistrueorfalse, for a followingswitch. - An
httpsource always hands its status to the lookup: 404 is “not found”, 401, 403, 408, 429 and 500 to 504 are retryable, any other 4xx fails the message.pass_through_statusneed not be set; set it on the endpoint when thehttpsource sits behind aref. frommust answer:http,static,nats/memorywithrequest_reply: true,mongodbwithfind,sqlxorclickhousewithlookup_query,http_bulkwithquery, orgrpcto an mq-bridgegrpcinput whose route replies. An endpoint that only acknowledges fails the message as non-retryable.mongodb.findis an Extended-JSON filter template and answers with the first matching document;sqlx.lookup_querybinds${payload:…}/${metadata:…}tokens likeinsert_queryand answers with the first row as a JSON object. Neither adds a write of its own (alookup_querythat writes, e.g.INSERT … RETURNING, runs as given and is logged at startup); they also setmongodb.found/sqlx.found.clickhouse.lookup_querytakes the same tokens and sends each as a typed query parameter (Int64,UInt64,Float64,Bool,String; a missing field isNULL), so compare against a matching column or wrap it, e.g.toDate(${payload:day}). AddLIMIT 1and noFORMATclause. It setsclickhouse.found; a query error (HTTP 4xx other than 408 / 429) is non-retryable.- On Postgres, cast placeholders to the column type (
WHERE id = ${payload:author_id}::int): tokens are bound untyped, and a field missing from the payload (e.g. on a CDC delete) is bound as text. CastNUMERIC,TIMESTAMPTZand similar result columns to::text; theAnydriver cannot decode them and fails the message as non-retryable.
Batched lookups. Put the token inside IN (…) (SQL) or $in: […] (MongoDB) and one
query answers the whole batch instead of one query per message:
- lookup:
from:
sqlx:
url: "postgres://localhost/crm"
table: "customers"
lookup_query: "SELECT id, name, tier FROM customers WHERE id IN (${payload:customer_id}::bigint)"
into: customer
- The query’s shape picks the mode: a token compared directly (
id = ${…}) runs once per message; the same token as the only element ofid IN (…)runs once per batch, with the element repeated for each distinct key (at most 1000 per query). - Rows are matched back to messages by the column left of
IN(c.idmatches the result columnid), so select that column under that name. A message whose key is missing or matches no row getsnull; if several rows match, the first wins. - A batched query takes no other token and no plain
LIMIT(it would cap the whole batch; ClickHouse’sLIMIT 1 BY idis fine). An error fails every message of the batch. - MongoDB:
find: '{"_id": {"$in": ["${payload:customer_id}"]}}'matches documents by the field that holds$in(dotted paths work). - Only the endpoint itself batches:
middlewaresonfrom(e.g.retry) fall back to one request per message.
- lookup:
from:
mongodb:
url: "mongodb://localhost:27017"
database: "crm"
collection: "customers"
find: '{"_id": "${payload:customer_id}"}'
into: customer
An http_bulk endpoint with query batches too: one HTTP request answers every message
of the batch, matched by position. An entry that reports an error fails only its message;
the others keep their answers.
- lookup:
from:
http_bulk:
url: "http://localhost:9200"
query:
path: /books/_mget
format: json_array
request: '{"_id":"${payload:isbn}"}'
envelope: '{"docs":{requests}}'
responses: /docs
value: /_source
found: /found
into: book
- HTTP 408, 429 and 5xx fail the message as retryable, other statuses as non-retryable.
Without
pass_through_status: truethehttpendpoint already fails on any non-2xx response, so a missing record is an error rather thannull. - The payload must be a JSON object along
into. - On an output, a failed lookup fails only its message; the rest of the batch is published.
List
retry/dlqafterlookupto catch its failures. - On an input, a retryable error (e.g. the database is down) nacks the whole received batch,
so the source redelivers it, and reconnects the route. A non-retryable error (non-JSON
payload, HTTP 4xx, a query error) is logged and acks and drops only its message; the rest
of the batch goes on. A misconfigured
fromtherefore drops every message.
Writing lookups. A per-message lookup_query that writes, such as a running counter or
moving average, batches on PostgreSQL and SQLite: the batch runs in order in one transaction,
each message seeing its predecessors’ writes.
- lookup:
from:
sqlx:
url: "postgres://localhost/metrics"
table: "ema"
lookup_query: >-
INSERT INTO ema (k, v) VALUES (${payload:sensor}, ${payload:value})
ON CONFLICT (k) DO UPDATE SET v = ema.v * 0.9 + ${payload:value} * 0.1
RETURNING k, v
into: average
- PostgreSQL runs the batch as one call to a temporary function that the publisher creates
on each connection; values are bound as one
jsonbparameter, never spliced into SQL. The function holds a transaction-scoped advisory lock perlookup_query, so concurrent batches of the same query, across routes and processes, run one at a time and cannot deadlock each other. Behind a transaction-mode pooler (PgBouncer) the function cannot persist; the publisher notices and falls back to one query per message. - SQLite opens the transaction with
BEGIN IMMEDIATE, taking the write lock up front. - If a message is rejected (a constraint or data error), the batch rolls back and is redone
one message at a time; the rejected message answers
null. A query that cannot run inside a function (e.g. its ownWITH … INSERT) falls back, with a warning, to one query per message, as on the other databases. - The redo runs in one transaction with a savepoint per message: a rejected message rolls back only its own write, any other failure rolls the whole redo back, so a retry never repeats a half-committed redo. A batch that committed and then fails downstream is written again when it is retried: at-least-once, as without batching.
- On PostgreSQL the row is encoded by
to_json:NUMERICand timestamps need no::textcast,byteareads"\\x…",realkeeps itsfloat4digits andNaNis a string. Where the function is not used (behind a pooler, or a query it cannot wrap), rows come from theAnydriver as in a per-message lookup, so those columns still need the::textcast. - Order matters for a query that builds on the stored value. A batch is applied in order, but
at route
concurrencyabove 1 batches run in parallel unless the sink keeps order (filedoes), and the per-message fallback (MySQL, a pooler, a query the function cannot wrap,middlewaresonfrom) sendsconcurrencymessages at once. Setconcurrency: 1on thelookupand the route, or write the query so order does not matter, as below. - SQLite math functions (
exp,ln,pow) are built intomqband the Python and Node packages. A Rust project depending onmq-bridgebuilds its own SQLite and needsLIBSQLITE3_FLAGS=SQLITE_ENABLE_MATH_FUNCTIONSset (e.g. in.cargo/config.toml[env]).
A time-decayed sum per key (e.g. card velocity) that gives the same result in any order: a late event is added with its own decay instead of being dropped, so parallel routes and instances can share the table.
- lookup:
from:
sqlx:
url: "postgres://localhost/metrics"
table: "velocity"
lookup_query: >-
INSERT INTO velocity (k, v, ts)
VALUES (${payload:card}, ${payload:amount}::float8, ${payload:ts}::bigint)
ON CONFLICT (k) DO UPDATE SET
v = CASE WHEN excluded.ts > velocity.ts
THEN velocity.v * exp((velocity.ts - excluded.ts) / 60000.0) + excluded.v
ELSE velocity.v + excluded.v * exp((excluded.ts - velocity.ts) / 60000.0) END,
ts = GREATEST(velocity.ts, excluded.ts)
RETURNING k, v
into: velocity
60000.0 is the decay time in the unit of ts (here one minute in milliseconds). A retried
batch still adds its events again; key a deduplication on the event id
to prevent that.
from and every entry in entries run in parallel, so a batch waits for the slowest
lookup, not the sum of all. Each batched entry costs one query per batch; for a lookup that
needs another’s result (e.g. a merchant id from the fetched user), chain a second lookup. Each one sets lookup.<into>.found, and
lookup.found is true only when all of them found something.
- lookup:
concurrency: 64
entries:
- into: features.user
from:
mongodb:
url: "mongodb://localhost:27017"
database: "features"
collection: "users"
find: '{"_id": "${payload:user_id}"}'
- into: features.card_avg
from:
sqlx:
url: "postgres://localhost/payments"
table: "payments"
lookup_query: >-
SELECT avg(amount)::text AS avg20 FROM (SELECT amount FROM payments
WHERE card_id = ${payload:card_id} ORDER BY ts DESC LIMIT 20) t
- into: features.risk
from:
clickhouse:
url: "http://localhost:8123"
table: "card_risk"
lookup_query: >-
SELECT score, updated_at FROM card_risk
WHERE card_id = ${payload:card_id} ORDER BY updated_at DESC LIMIT 1
aggregate
Keeps one state per key in this process and updates it with every message: running sums,
counters, moving averages. Input and output. The message is not consumed; it leaves with
the state written into its payload. Use it where a lookup would ask a database
to keep a counter: the arithmetic runs here, so no request is made per message.
| Field | Type | Default |
|---|---|---|
key | template | selects the state, e.g. ${payload:sensor_id}; required unless entries is set |
expression | expression | returns the new state; key needs this or fields |
fields | map of name to built-in aggregate | – ; the fast alternative to expression |
into | dotted payload path | where the result is written; required with key |
output | expression | the state itself; shapes what is written to the message |
emit | updated | previous | updated |
entries | list of {key, expression, fields, into, output, emit} | []; further aggregates |
store | URL | –; postgres://…/db[/table], sqlite://…, mongodb://host/db[/collection]. Without it consistency: single_writer is required |
consistency | shared | single_writer | shared, which needs a store; single_writer without a store keeps the states in memory only |
max_keys | integer | 1000000; states kept in memory per entry, 0 is unlimited |
time | dotted payload path | – (the clock); the message’s event time, for fields entries |
on_error | drop | fail | skip | drop; what happens to a message that cannot be folded |
read_only | bool | false; reads the states of the store and never changes them |
- aggregate:
consistency: single_writer
key: "${payload:sensor_id}"
into: sensor
expression: "{ total: (state.total ?? 0) + reading, count: (state.count ?? 0) + 1 }"
{"sensor_id": 7, "reading": 20} leaves as
{"sensor_id": 7, "reading": 20, "sensor": {"total": 20, "count": 1}}; the next message for sensor 7
continues from that state.
States in memory only are opt-in. Without a store the states are lost on restart and
not shared between instances, so that has to be asked for with consistency: single_writer,
as above. An aggregate with neither a store nor consistency: single_writer is a startup
error.
Expressions are the ones filter and transform use. They read
payload fields by name and metadata as meta.<key>, plus state: the value expression
returned for the previous message with the same key. For a key seen for the first time
state is null, so state.total ?? 0 and state ?? 0 both give the starting value. The
state can be an object or a single value. A payload field named state or meta is hidden
by these two names.
output runs after the update, with state set to the state the message carries. Use
it when the stored state is not what a reader wants to see. With emit: previous the
message carries the state from before its own update, null for a new key; output is then
skipped for a new key.
A moving average that a first value does not skew. The usual
avg = avg * 0.99 + reading * 0.01 starts at the first reading and takes hundreds of messages
to forget it. Keep the weighted sum and the weight instead and divide when writing:
- aggregate:
consistency: single_writer
key: "${payload:sensor_id}"
into: sensor.avg_reading
expression: "{ s: (state.s ?? 0) * 0.99 + reading, w: (state.w ?? 0) * 0.99 + 1 }"
output: "state.s / state.w"
After a first reading of 500 followed by readings of 50, this reads 52.6 after 100 messages; the usual form still reads 216.
Several keys update from the same message with entries, each with its own states:
- aggregate:
consistency: single_writer
entries:
- key: "${payload:sensor_id}"
into: features.sensor
expression: "{ sum: (state.sum ?? 0) + reading, n: (state.n ?? 0) + 1 }"
- key: "${payload:site_id}"
into: features.site
emit: previous
expression: "{ max: max([state.max ?? reading, reading]) }"
Built-in aggregates. fields replaces expression for the common cases and is several
times faster, because no expression runs and the payload is not parsed into a tree:
- aggregate:
consistency: single_writer
key: "${payload:sensor_id}"
into: sensor
fields:
n: count
total: sum(reading)
high: max(reading)
avg: ema(reading, 0.01)
| Aggregate | Result |
|---|---|
count | number of messages |
sum(path), min(path), max(path), last(path) | over the values at path |
mean(path) | arithmetic mean |
stddev(path), variance(path) | sample standard deviation and variance (divided by n − 1) of all values; null for the first message |
ema(path, alpha) | moving average weighting each new value with alpha (0 < alpha ≤ 1), without a start bias: the first message reads its own value |
ema(path, half_life) | the same, weighted by time: a value counts half after half_life, e.g. ema(reading, 5m). Units: ms, s, m, h, d |
ema_stddev(path, alpha), ema_variance(path, alpha) | the same over values weighted like ema, so old values fade; null for the first message. Both take a half-life in place of alpha too |
decayed_count(half_life) | messages, each counting half after half_life. At a steady rate of r per second it settles at r × half_life / ln 2, about the messages of the last 1.44 half-lives |
decayed_sum(path, half_life) | the same over the values at path: a recent total that fades instead of dropping out of a window |
path is a dotted payload path such as reading or device.reading. Its value must be a
number or a numeric string (what a CSV source delivers); a message without one cannot be
folded, see on_error. The result is an object with the field names in
alphabetical order, e.g. {"avg": 20.0, "high": 20.0, "n": 1, "total": 20.0}. An entry takes
either expression or fields; entries of both kinds can be mixed. fields takes no output.
Time. ema(path, alpha) counts messages: ten messages in one second fade a value as
much as ten messages in one day. With a half-life the weight follows the time between two
messages of a key instead. time names the payload field holding the message’s time: epoch
seconds, epoch milliseconds (any number above 1e11), or an RFC 3339 text such as
2026-10-03T12:00:00Z; a text without a zone is read as UTC. Without time the clock of
this process is used, which makes a replay compute different values.
- aggregate:
consistency: single_writer
key: "${payload:sensor_id}"
into: sensor
time: measured_at
fields:
n: count
avg: ema(reading, 5m)
spread: ema_stddev(reading, 1h)
Trends. The same value with a short and a long half-life side by side shows whether
activity is rising or falling. Divided by its half-life, each is a rate: orders_10m / 10
minutes above orders_1d / 1440 minutes means a key is busier than its own baseline.
- aggregate:
consistency: single_writer
key: "${payload:account_id}"
into: activity
time: created_at
fields:
orders_10m: decayed_count(10m)
orders_1d: decayed_count(1d)
amount_10m: decayed_sum(amount, 10m)
amount_1d: decayed_sum(amount, 1d)
typical_amount: ema(amount, 1d)
amount_spread: ema_stddev(amount, 1d)
With emit: previous the decayed values are read faded to the message’s time, so they
describe the activity before it.
Late messages fade by their lateness. Every fields state with time or a half-life
remembers the newest time it has seen. A message with an older time counts with the weight
it would have had by then: 0.5 per half-life it is late. So ema, ema_stddev,
ema_variance, decayed_count and decayed_sum end up the same whatever order the
messages arrive in, across batches and instances too; the clock does not move back. last
keeps the value of the newest message. ema(path, alpha) has no time and takes a late
message in arrival order. A late message carries the metadata mqb.aggregate.late naming
the entries concerned by their into, comma-separated. A filter or a switch
can act on it as meta["mqb.aggregate.late"]. The first occurrence is logged as a warning.
time adds one slot to each fields state; states stored before it was set are read and
extended. Expression entries do not use time.
Correcting a state. A message with the metadata mqb.aggregate.overwrite: true is
folded as the newest one even if it is older: last takes its value, and the state’s clock
is set to its time. Use it to repair a state whose clock a wrong time pushed into the
future, which would make every later message late.
A message that cannot be folded. Its payload is not a JSON object, a key has no value,
a field read by fields or time is missing or not a number, or an expression fails. One
entry that cannot be computed is enough. on_error decides what follows; no state changes
for an entry that was not computed.
on_error | On an input | On an output |
|---|---|---|
drop (default) | logged, acked and dropped. The source sees a success: an HTTP caller gets 202 | only that message fails, as non-retryable |
fail | logged and nacked, so the source sees the failure: an HTTP caller gets 500. A source that redelivers nacked messages (a broker) delivers it again | as drop |
skip | the entries that cannot be computed are left out, the others run, and the message goes on. A message no entry can fold goes on unchanged | the same |
A message that left entries out under skip carries the metadata mqb.aggregate.skipped
with their into paths, comma-separated, and the first occurrence is logged as a warning;
the reason for each is logged at debug level.
- aggregate:
consistency: single_writer
on_error: skip
entries:
- { key: "${payload:sensor_id}", into: sensor, fields: { avg: "ema(reading, 0.01)" } }
- { key: "${payload:site_id}", into: site, fields: { high: max(reading) } }
Reading without writing. read_only: true computes and writes into the message as
usual but stores nothing, so a dry run or a second route can use the states another route
maintains. emit: updated shows what the state would become with this message,
emit: previous the stored state as it is. It needs a store; states are loaded per batch
whatever consistency says, and each message of a batch sees the stored state, not the
messages before it.
- aggregate:
store: "postgres://localhost/telemetry/sensor_states"
read_only: true
emit: previous
key: "${payload:sensor_id}"
into: sensor
fields: { n: count, avg: "ema(reading, 0.01)" }
Keeping the states in a store. With store the states survive a restart. The table or
collection defaults to mqb_aggregate_<route> and is created on start; PostgreSQL, SQLite
and MongoDB are supported.
- aggregate:
store: "postgres://localhost/telemetry"
key: "${payload:sensor_id}"
into: sensor
fields: { n: count, avg: "ema(reading, 0.01)" }
consistency | How a batch runs | Correct with | Speed |
|---|---|---|---|
shared (default) | loads its states, folds, writes them back guarded by a version | any number of instances | bound by the store: two round trips per batch |
single_writer | folds in memory, writes changed states behind; the ack waits for that write | exactly one instance | several times faster; grows with how often keys repeat |
- Every stored state is one row: the key
<into>:<key>, the state as JSON text, and a version that counts its writes. A write names the version it was computed from and only takes effect while the row still has it (optimistic concurrency). - With
shared, a batch loads the states of its distinct keys in one query, folds, and writes the changed ones back in one statement. Two instances that change the same key at the same time cannot overwrite each other: the later write is refused and the batch folds again from the reloaded states, up to 32 times before it fails. MongoDB reloads and folds again only the refused keys; PostgreSQL and SQLite roll the transaction back and repeat the whole batch. The message goes on only after the write. No state stays in memory between batches, and one instance runs its batches against the store one at a time. - With
single_writer, a key is loaded once, when it is first seen. Batches fold in memory and go on at once; a background task writes the changed states, and starts the next write as soon as one returns, so under load one write covers many batches and a key changed often is written once. A batch is acked (on an output:sendreturns) only after the write that covers it. If a write fails, the states in memory are dropped, the batches not yet stored are nacked and fold again from the stored states. A second instance writing the same states is detected as a refused write, not prevented. - The state write and the ack are two steps, not one transaction. A crash between them redelivers a batch whose states are already stored, and it counts twice.
- A store that cannot be reached nacks the whole batch on an input and is a retryable error on an output.
- PostgreSQL and SQLite write the states of a batch in one transaction. MongoDB writes each state atomically but not the batch as a whole.
- Larger batches help:
batch_size: 4096to8192roughly halves the cost per message. - Changing the
fieldsof an entry changes the layout of its stored states. Start with a new table, or a newinto, which is part of the stored key.
What to expect.
- Without
store, state lives in memory only. It is lost on restart and it is not shared between instances, which is why it needsconsistency: single_writer. - Memory is bounded by
max_keys. An entry keeps at most that many states in memory, one million unless set. When it is full, the half that was used least recently is dropped. Without astorethose states are forgotten: their key starts again from an empty state, and a warning is logged the first time. Withsingle_writerthey are only dropped from memory and loaded again from the store when their key returns, so results do not change.sharedkeeps nothing in memory. Afieldsstate takes roughly 100 to 300 bytes. - A replayed message counts twice. Delivery is at-least-once: a batch that is nacked or
retried after it was folded is folded again. Where that matters, let
deduplicationsee the message first: on an input, list it afteraggregate(see Ordering). - Order matters for order-dependent states such as a moving average. Within a batch
messages are folded in order; with a route
concurrencyabove 1 the order across batches is not guaranteed. Withtime, a message older than its state is weighted by its lateness and marked, not reordered. - A message that cannot be folded fails alone and changes no state. By default an input
drops it and still acks it, so an HTTP caller gets
202; seeon_errorforfailandskip. - No windows and no expiry by time. There is no “last hour” and no time to live: a state
covers every message of its key, and a row in the store is never removed.
emaandema_stddev,decayed_countanddecayed_sumstand in for a sliding window, by message count or with a half-life by time. For fixed windows put the window into the key, e.g.key: "${payload:sensor_id}:${payload:hour}", and use astorewithshared, which keeps no state in memory; the rows of past windows stay until you delete them. - No exactly-once and no key ownership. A stream processor commits state and input
position together and assigns each key to one worker. Here a redelivered message counts
twice, and
single_writerrelies on you running one instance. - Field order. The fields of a written state object are not in the order the expression lists them.
- Numbers. Expression states are held as decimals and stored with every digit.
Fractions are written to the payload as the shortest
f64, integers exactly.fieldscompute inf64. Fields the middleware does not write are copied byte for byte.
Throughput on one Apple M-series core, three moving averages per key (indicative):
| 1 key | 5 keys | |
|---|---|---|
expression, in memory | 330k msg/s | 100k msg/s |
fields, in memory | 1.8M msg/s | 700k msg/s |
fields, single_writer, local PostgreSQL, batch 8192 | 370k msg/s | 240k msg/s |
fields, shared, local PostgreSQL, batch 8192 | 49k msg/s | 17k msg/s |
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 |
sequence | ✅ | – | Read several inputs in order: drain each, then stream the last |
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. as text that a numeric
literal reads as a 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 and counted as unmatched in the endpoint status. The count is not an
error: the route stays healthy and a drain job still ends cleanly. Set a default to keep
such messages. 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.
A batch is split by destination and the parts are sent concurrently. When one destination
fails as a whole, the batch is redelivered to all of them, so the others see those messages
twice: delivery is at-least-once per destination, as for fanout. The health status is
unhealthy when any destination is.
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; the full list is the “Replies” column in
CAPABILITIES.md. A to that can never reply is refused when the
route starts, because every message would be acked and nothing forwarded. The exception is
forward_to: {}: the response is discarded anyway, so any endpoint may be the target. 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.
Where to answers a whole batch in one call (openai_embeddings, openai_chat with
messages_per_request, an http_bulk query), put a buffer with
max_delay_ms: 0 on to. The concurrent requests of a batch then reach it as one batch,
and each message still gets its own answer or error:
output:
request:
to:
openai_embeddings: { model: text-embedding-3-small, input: "${payload:text}" }
middlewares:
- buffer: { max_messages: 500, max_delay_ms: 0 }
forward_to: { file: { path: "vectors.jsonl", format: raw } }
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 Request / reply.
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.
The reply carries the triggering request’s message id, which is what matches it to its caller.
The id of the message that was read is in the metadata mqb.reader.message_id.
A single trigger waits until the source has a message. To bound that wait, put a
timeout middleware on the output; the trigger then fails as retryable. Triggers
that arrive as a batch are answered in order with the messages the source returns for one
read; a trigger left without a message fails as retryable, it is not acknowledged empty.
sequence
Reads several inputs one after another. Each is drained before the next begins, and the last one streams until the route stops. Input only.
The motivating case is a change-data-capture backfill — snapshot a table, then tail its replication stream — but the shape fits any history-then-live pair.
For a CDC backfill, reach for the endpoint’s own consume first
You usually do not need to write a sequence by hand. Both CDC endpoints spell
“backfill, then follow changes” the same way, as one field:
input:
postgres_cdc:
url: "postgres://user@localhost/shop"
publication: "orders_pub"
consume: capture_all # page the tables by primary key, then stream changes
input:
mongodb:
url: "mongodb://localhost:27017"
database: "shop"
collection: "orders"
consume: capture_all # page the collection by _id, then stream changes
postgres_cdc: consume: capture_all expands to exactly the sequence below — it reads the
publication’s tables, takes each table’s primary key as the cursor, and appends the CDC phase. Use
the explicit form only when that expansion cannot serve you:
| Situation | Spelling |
|---|---|
| Back fill a Postgres or MongoDB CDC source | consume: capture_all on the endpoint |
| A table with a composite or non-integer primary key | sequence, with your own cursor_column |
| Back fill from somewhere else entirely — a file, an S3 prefix, a different database | sequence |
| A custom snapshot query, or only some of the publication’s tables | sequence |
Fields
| Field | Type | Default |
|---|---|---|
endpoints | list of endpoints | required |
cursor_id | string | none |
checkpoint_store | string | none |
# The explicit form. This is what `postgres_cdc: consume: capture_all` becomes.
orders_to_search:
input:
sequence:
endpoints:
- sqlx: { url: "postgres://user@localhost/shop", table: "orders", cursor_column: "id" }
- postgres_cdc: { url: "postgres://user@localhost/shop", publication: "orders_pub", slot_name: "orders_slot" }
cursor_id: "orders_backfill"
checkpoint_store: "/var/lib/mqb/orders-phase.json"
output:
meilisearch: { url: "http://localhost:7700", index: "orders", primary_key: "id" }
The order of operations is what makes this gapless. Before the first phase reads anything,
every later phase is asked to pin the position it will resume from. For postgres_cdc that
creates the replication slot, so Postgres retains WAL from that moment while the snapshot runs,
and the stream later resumes from the slot’s own consistent point. Endpoints with nothing to
pin — a table scan, a file, a queue — are left alone.
Consequences worth knowing:
- Delivery is at-least-once across the handoff. A row changed while the snapshot was running
is read twice: once by the snapshot, once from the stream. The sink must be idempotent — an
upsert keyed on the primary key, which is what a search index or a
ON CONFLICTsink already does. There is no exported-snapshot mode that would deduplicate this. - A phase must be able to drain. An intermediate phase is always run in drain mode, and its
first empty batch is the handoff signal; only the last phase inherits the route’s own
exit_on_empty. An endpoint that never reports empty would never hand off. The handoff then waits, for up to 30 seconds, until the batches of that phase are acknowledged. If one of them failed, the phase is polled again and thecursor_idmarker is not advanced, so a restart re-enters the phase. - For a broker phase, “empty” means “nothing arrived for a moment”. A blocking source
reports empty after
MQ_BRIDGE_DRAIN_IDLE_TIMEOUT_MS(1 second by default) without a message. A rebalance or a slow connection can look the same, and with a marker the unread rest of that phase is then not read. Use a source with a real end (SQL cursor, file, object store) for intermediate phases, or raise the timeout. postgres_cdcneedstemporary_slot: falsewhen it follows another phase. A temporary slot is dropped when the route stops, so a restart would resume with no retained WAL and silently skip every change made while the earlier phase ran. This is rejected at startup, forconsume: capture_allas well as for a hand-writtensequence.- Without
cursor_id+checkpoint_storeevery restart re-enters the first phase. Whether that re-reads anything is then up to that phase’s own cursor — asqlxphase with its owncursor_idpicks up where it left off. The marker records which phase was reached, so a restart skips the earlier ones outright. - The marker belongs to one list of endpoints. It stores the endpoint types next to the
phase, and a
sequencewhose types differ refuses to start. Use a newcursor_idor remove the marker after replacing or reorderingendpoints. - Phases connect lazily — a later phase opens no connection while an earlier one is still running, so a long snapshot does not hold a replication stream open.
Which endpoints can be a phase: all of them. Any endpoint valid as an input works in any
position — there is no per-endpoint support list. An intermediate phase is run in drain mode, and every consumer either returns an empty
batch when idle (the poll-style sources: sqlx, clickhouse, object_store, aws, ibm_mq)
or surfaces one after a short idle timeout — the same mechanism --drain uses. That timeout
defaults to 1s and is set process-wide by MQ_BRIDGE_DRAIN_IDLE_TIMEOUT_MS, so a handover costs
about a second on a blocking source.
What differs per endpoint is the pin — whether the last phase can guarantee it resumes from before the backfill rather than from “now”:
| Last phase | Pinned by | Gapless |
|---|---|---|
postgres_cdc, or sqlx with a publication | Creating the replication slot, so the server retains WAL for the whole backfill | ✅ |
mongodb with consume: capture_all | Nothing — it already captures a resume token, snapshots, then streams from that token, inside the consumer | ✅ |
kafka, nats JetStream, redis_streams | Not pinned. These retain history themselves, so start the consumer at the earliest position rather than relying on a pin | n/a |
| Everything else | Nothing to pin | ⚠️ |
⚠️ means the handover is only as gapless as the source’s own retention: a change made during the backfill is missed if the source does not keep it. For a queue or a log that is not a concern — nothing is dropped while you are not reading. It matters for sources that only expose “what is true now”.
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.
As an input it produces the message without end. In a drain (mqb copy --drain,
exit_on_empty) it emits one batch and ends.
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 |
idle_ttl_secs | integer | default 3600; 0 disables |
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.
The publisher creates a partition on demand, but only a consumer’s shutdown removes one, so
a correlation id whose reader never arrives would hold its buffered batches until the
process exits. idle_ttl_secs discards such a partition once nothing has published to it
for that long. A partition with a consumer attached is never discarded, however idle.
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
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 effectively-once: exactly-once effects — the record lands once no matter how many times it is delivered. Nothing here is exactly-once delivery. Everything below is about how to arrange that.
Enabling effectively-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 logs the
inferred guarantee as at-most-once, at-least-once or effectively-once; it does not silently
change how the sink writes data. The same value is available in code as
Route::delivery_guarantee(), a DeliveryGuarantee.
To make the guarantee a requirement rather than a log line, set required_delivery on the route.
A route whose configuration cannot meet it fails at startup instead of running with less:
orders_to_postgres:
required_delivery: effectively_once # at_most_once | at_least_once | effectively_once
input: { kafka: { topic: "orders", url: "localhost:9092" } }
output:
sqlx:
url: "postgres://localhost/shop"
insert_query: "INSERT INTO orders (id, body) VALUES (${payload:id}, ${payload:body}) ON CONFLICT (id) DO NOTHING"
at-most-once is inferred for inputs that forget a message once handed over: zeromq, core NATS
(no_jetstream: true), MQTT with qos: 0, HTTP with fire_and_forget: true, a websocket
listener, and a grpc dynamic client (descriptor set or reflection). A crash in the
route loses what it held.
This table is advice on how to configure an idempotent sink — it is not a list of
switches the engine flips. A plain INSERT is not idempotent; you make it so by writing
the conflict clause yourself.
| Sink | How to make the write idempotent | Reported as effectively-once at startup? |
|---|---|---|
| MongoDB | id_field set to a payload field or replay-stable template such as ${metadata:mqb.id} | Yes (a key read from mqb.src.* only over an input with a replay position) |
| PostgreSQL / SQLite | sqlx.insert_query uses a unique key with ON CONFLICT … DO NOTHING | Yes (same mqb.src.* rule) |
| PostgreSQL / SQLite | sqlx.insert_query uses a unique key with ON CONFLICT … DO UPDATE (convergent upsert) | No — still idempotent, but the startup line says at-least-once |
| MySQL / MariaDB | sqlx.insert_query uses a unique key with ON DUPLICATE KEY UPDATE assigning replay-stable values (a convergent upsert; an accumulating assignment such as c = c + 1 is not idempotent) | No — still idempotent, but the startup line says at-least-once |
| 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 | Yes |
The right-hand column only describes the informational log line mq-bridge emits when a
route starts. The inference is deliberately conservative: it recognises the two forms it can
read off the configuration with certainty, and says at-least-once for everything else. A
route in the third or fourth row is still effectively-once in practice — the sink absorbs the
replay either way. Do not read at-least-once in the log as “this route creates duplicates”.
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 effectively-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 (delete) | Yes — the stored document _id | No |
mongodb (capture_new/capture_all) | No — fresh id per read | No |
amqp | Yes when the producer set the AMQP message_id property — mq-bridge writes it; any string is accepted (a non-UUID one is hashed). Without it, a fresh id per read — never the delivery tag, which restarts at 1 on every 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) |
dir_spool | Yes — the sidecar’s message_id, when the chunk was written by mq-bridge. A foreign producer’s payload-only chunk gets a fresh id | No — a drained chunk is deleted |
mqtt | MQTT 5: only if the producer set the mq_bridge.message_id user property, which mq-bridge writes. MQTT 3: no | No |
ibm_mq | Yes — hash of the MQMD MsgId, which a backout keeps. It names the queued message, not the record: a producer that puts a record twice gets two ids, and one that reuses a MsgId gives two messages one id | No |
aws (SQS) | Yes — the SQS MessageId, which a redelivery keeps. A record the producer sends twice gets two ids | No |
zeromq, 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-bridgewrites the message id on the sink side for Kafka, NATS, Redis Streams, MQTT 5 and AMQP (as the nativemessage_idproperty), 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) |
dir_spool | Not idempotent: every write consumes a new {seq} value, so a replay creates a new chunk path even when naming_pattern contains {message_id} | Rely on downstream deduplication or an idempotent sink |
kafka | enable.idempotence dedups producer retries within one session — this is not exactly-once semantics | On by default |
nats (JetStream) | Nats-Msg-Id from the message id; the server drops a repeat within the stream’s duplicate window (2 minutes by default) — a replay after that lands again | deduplicate: true |
amqp, mqtt, redis_streams, aws, ibm_mq, zeromq | None — at-least-once | 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 beyond what the
table above lists. Either filter before it with the
deduplication middleware, or accept at-least-once and make the
downstream consumer idempotent.
The matrix
What a route achieves, by what its input offers and what its output can absorb:
| Input ↓ / Output → | Keyed idempotent write (Mongo id_field, SQL ON CONFLICT) | Positional names (file / object_store source_position) | Broker or plain sink |
|---|---|---|---|
Replayable position (kafka, postgres_cdc, MongoDB CDC, file in consume, sqlx cursor) | effectively-once — key on a business id or on the position itself | effectively-once | at-least-once |
Stable identity only (JetStream, AMQP/MQTT 5 with an id, mongodb delete mode, dir_spool, aws, ibm_mq) | effectively-once — key on the id | not available (no position) | at-least-once; deduplication narrows duplicates, see below |
Neither (MQTT 3, file re-read, …) | effectively-once only with a business key (id middleware) | not available | at-least-once |
No acknowledgement (zeromq, core NATS, MQTT QoS 0, HTTP fire_and_forget, websocket, grpc dynamic client) | at-most-once | at-most-once | at-most-once |
A handler, or any side effect it performs, sits outside this table: see Handlers.
A custom endpoint (or plugin) is placed in it by its own declaration — see
Declaring a delivery guarantee; one that declares
nothing counts as an acknowledging source and a non-idempotent sink.
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 effectively-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). Only _id works this way: a duplicate on another unique index
is a refused document and fails as non-retryable, so it reaches a dlq if one is configured.
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 delete competing-consumer mode, which
requires 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.
Keying a sink on the source position
A source with a replay position already identifies every record: Kafka by topic, partition and
offset, Postgres CDC by slot, LSN and ordinal. Keying the sink row on that position needs no
business key and no extra state — it is the Kafka Connect JDBC sink’s pk.mode=kafka:
kafka_to_postgres:
input: { kafka: { topic: "events", url: "localhost:9092" } }
output:
sqlx:
url: "postgres://localhost/shop"
insert_query: >
INSERT INTO events (topic, part, off, body)
VALUES (${metadata:mqb.src.kafka_topic}, ${metadata:mqb.src.kafka_partition},
${metadata:mqb.src.kafka_offset}, ${payload:body})
ON CONFLICT (topic, part, off) DO NOTHING
A SQL insert_query or Mongo id_field that reads mqb.src.kafka_*, postgres_*, mongodb_*,
file_* or sqlx_* turns source_metadata on for the input, as positional file names do. Over an
input without a replay position those keys are absent, so such a route is not reported as
effectively-once. Prefer a business key when records have one: a position identifies this copy
of the log, so re-publishing the same data to a new topic writes it again.
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 insert and update carries the row (so the primary key is in the payload; a delete carries
the key columns, and an update that changes the key names the previous one in
postgres.old_key), postgres.lsn (a
monotonic version), postgres.operation/schema/table, and — when the table has a primary key /
replica identity — postgres.key (the key value; a truncate event names no row and has none). The event’s message_id is a stable hash of
schema.table + key + operation + lsn + position in the transaction, 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).
Several changes to one primary key within a single transaction share that transaction’s commit
LSN but differ in operation or position, so each gets its own message_id and all are delivered.
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.
Three things decide whether it actually works:
key. The default keys onmessage_id, which many sources mint fresh per read (see the source table) — the route then looks configured and dedupes nothing; for the sources that always do this, startup logs a warning. 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. Content hashing is deliberately not offered as a fallback: two legitimately equal payloads would collapse.- Ids from producers you do not trust. A string
message_idthat is not a UUID or a number is folded to 128 bits with FNV-1a, which is fast but not collision-resistant: a producer who chooses its ids can craft one that collides with another message’s, and deduplication onmessage_idthen drops that message. SetMQB_ID_HASH=sha256(or callset_string_id_hash) for such inputs. Switching changes every hashed id, so start with an empty dedup store. Numeric strings are read as numbers, so"123"and"0123"are one id. store.sledis single-instance. Point it at a shared MongoDB or SQL deployment for anything scaled out. The shared collection or table defaults tomqb_dedup_<route name>, so replicas must run under the same route name — or name the collection/table explicitly in the URL (not possible for SQLite, which has no path slot for it).ttl_seconds. A key is remembered for this long after it was processed. It must exceed the longest time in which the same record can come back: broker redelivery and retry limits, a producer’s retry horizon, and any replay an operator may trigger from a DLQ. A copy arriving after that is processed again. Longer costs only storage.
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.
How it orders its steps. A key is claimed when its message is read, marked processed after the sink accepted the write — before the source is acked — and released when the write failed. A copy that arrives while another is still in flight waits for it rather than being acked on its strength: if the first write then fails, the copy is processed; if it succeeds, the copy is dropped. A claim is renewed while its delivery is in flight, so a slow sink does not lose it; a claim whose holder died lapses after five seconds. What each crash window does:
| Crash or failure between | Outcome |
|---|---|
| claim → sink write | The claim lapses; the redelivery is processed. No loss. |
| sink write → marker | The redelivery is processed again — a duplicate, unless the sink is idempotent. |
| marker → source ack | The redelivery is recognised and acked. No duplicate. With a sled store this needs ?durable=true when the process is killed: sled otherwise writes markers to disk up to about 500 ms late. |
| a failed write (nack) | The claim is released at once; the redelivery is processed. No loss. |
| the store is unreachable | The batch is nacked back to the source and the route reconnects. |
The second row is why the middleware stays at-least-once: its marker is not committed in the sink’s transaction. Only an idempotent sink closes that window.
Replaying a reply. On a request/reply route (HTTP, gRPC, NATS or memory requests) a duplicate
is normally acked with nothing to say, so a caller retrying a request gets an empty answer.
replay_response: true stores each reply next to its marker and answers a duplicate with it —
the idempotency-key pattern of payment APIs. The reply is kept for the whole TTL, in the store,
so size the store for it and consider what the replies contain. Off by default.
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”.
Read by key, then branch (lookup)
To branch on whether a record already exists without writing it, read it first. The
lookup middleware asks a read-by-key endpoint (MongoDB find, SQLx
lookup_query, HTTP) and sets lookup.found, which a switch routes on:
orders_enrich_branch:
input: { nats: { url: "nats://localhost:4222", subject: "orders" } }
output:
middlewares:
- lookup:
from:
sqlx:
url: "postgres://app@localhost/crm"
table: "customers"
lookup_query: "SELECT id, name, tier FROM customers WHERE id IN (${payload:customer_id}::int)"
into: customer
switch:
metadata_key: "lookup.found"
cases:
"true": { ref: "known_customer_orders" }
default: { ref: "new_customer_orders" }
A read is not a reservation: two messages for the same new key can both see false. When
exactly one of them must win, insert with report_outcome (above) instead.
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 effectively-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 — delete 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.
A recreated source. Covered ranges are keyed on the source position alone. A source that
is dropped and created again under the same name starts at low positions the sink already
holds, and those records are skipped as covered. This applies to a Kafka topic or a SQL table
that was recreated, and to a file that was replaced in consume mode. Write to a new path
or prefix, or remove the old objects, when you recreate a source.
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.
Durability of the file sink. By default the sink flushes each batch to the operating
system and does not call fsync, so a power loss can lose batches that were already
acknowledged. fsync: batch syncs the file before each batch is acknowledged; this costs one
disk sync per batch. fsync: periodic syncs in the background every fsync_interval_ms
(default 1000) and bounds the loss to that interval at almost no cost in throughput.
delete: true needs a single writer. Acknowledged records are removed by rewriting the
file and renaming the copy into place. Lines that another process appends during that
rewrite are lost. Use it only when the file is complete, or written by an mq-bridge file
sink in the same process. A final record without its delimiter is held back until it has
been unchanged for 100 ms, so a line that is still being written is not delivered in part.
In a CSV file the header line stays in the file.
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 claims a key on receive and only marks it processed once the write succeeded; a claim whose holder crashed lapses after five seconds, so the redelivery is processed rather than lost. A crash after the handler ran but before the marker was written 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. This is a choice: transactions only cover a Kafka sink fed by a Kafka source, needread_committedconsumers downstream, and cost a round trip per batch. An idempotent sink or consumer covers every combination instead. - Effects inside handlers. An HTTP call, an email or any write a handler makes on its own is not covered by any of the above; see Handlers.
- 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.
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.
The publisher’s side is at-most-once across a consumer crash. A send is acknowledged once its bytes are in the kernel socket buffer, not when the consumer has read or processed them. If the consumer process dies, whatever sat in that buffer or in the consumer’s memory is lost, and the publisher’s source has already been committed.
A second publisher on the same socket connects without an error, because the kernel queues the connection, but the consumer only reads it after the first publisher disconnects. Until then its sends fill the socket buffer and then block; the 5-second stalled-send warning is the only sign.
If the hand-over must survive a crash of either process, use dir_spool between the two processes, or a 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.
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, the UI binds
0.0.0.0:9091 through ENV MQB_UI_DEFAULT_ADDR, 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 |
UI address 0.0.0.0:9091, once the UI is asked for | ENV MQB_UI_DEFAULT_ADDR | Yes |
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). - Limit an exposed HTTP input: set
header_read_timeout_ms(for example30000) so a half-sent request does not hold a connection, andmax_body_bytesto the largest body you expect. Both are off or wide by default; the timeout costs some throughput. - 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
docs/book/**. It runs the local
docs/book/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).
Tracing (OpenTelemetry)
Attach the otel middleware to get one span per message. The
input span continues the incoming W3C traceparent, and the output writes a new
traceparent into the published message, so a trace runs through the bridge into the next
service:
orders_bridge:
input:
middlewares: [ { otel: {} } ]
kafka: { topic: "orders", url: "localhost:9092" }
output:
middlewares: [ { otel: {} } ]
nats: { subject: "orders.processed", url: "nats://localhost:4222" }
mq-bridge-app exports the spans over OTLP/HTTP when OTEL_EXPORTER_OTLP_ENDPOINT (or
OTEL_EXPORTER_OTLP_TRACES_ENDPOINT) is set; OTEL_SERVICE_NAME defaults to
mq-bridge-app. When neither endpoint variable is set, no exporter is installed and the
middleware is left out of the chain, so it costs nothing per message.
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. Every mqb process of your
user (a copy, a headless config run, the web UI, the desktop app, an MCP server) reports its
routes to a local status registry, and three views read it:
mqb statusprints one row per route and consumer on the machine: state, current and average rate, total, pending and uptime. It redraws every second;--no-watchprints once and--jsonprints the records. See the CLI reference.- In the UI, the Status tab shows the same rows with the last 60 seconds of each rate. The publisher and consumer views show health and message counts for this instance.
- Through the MCP server,
bridge_statusreturns the same records. - 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.
A process drops out of these views within 7 seconds of exiting. Records carry endpoint types,
not URLs or credentials, and an error appears only as the word error. To see the error text,
set MQB_STATUS_SHOW_ERRORS=1 for both the process that runs the route and the one that reads
the status; error text can contain connection details, so leave it off on shared machines.
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. Once that many
batches wait for their commit, the route stops reading until one finishes. Rarely needs
changing.
Writing to a Postgres table
A SQL sink writes in one of three ways, and on Postgres they differ in speed:
| Mode | Writes | Use it for |
|---|---|---|
columns: auto | each field into the column of the same name | table → table copies; nothing to write by hand |
insert_query with ${payload:field} tokens | the fields you name, as one multi-row INSERT | the fastest INSERT, and full control of the statement |
| neither | the whole message into one payload column | a queue table that an mq-bridge consumer reads back |
- Batch size. Between about 1,000 and 8,000 rows per batch is enough. Above that a multi-row
INSERTgains nothing, and withconcurrency: 4it got slower. bulk_copyswitches the sink toCOPY FROM STDIN. It pays off from roughly 8,000 rows per batch and is no faster below.COPYcannot upsert, so it does not combine withkeyor anON CONFLICTclause.insert_queryovercolumns: auto. Mapping every record by name costs throughput: in the benchmark aninsert_querywas 1.2–1.5x faster on the same rows. Write the query out when the sink is the bottleneck.- The server sets the ceiling. With
insert_query,bulk_copyand large batches the sink reached the rate of apsqlCOPYpipe into the same table. Past that point the gains are in Postgres itself (indexes, WAL, disk), not in the bridge.
The measurements are in scenario 1 of the benchmark README.
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 | Version |
|---|---|---|---|---|---|
| CSV → JSONL (strings passthrough, 1M rows ~116 MiB) | 1024 | 1 | 3,134,796 rows/s | ~29 MiB | 0.4.20 |
| CSV → JSONL, same job driven through an MCP tool call | 1024 | 1 | 2,466,515 rows/s | — | 0.4.20 |
CSV → JSONL with typing transform (id→int, embedded JSON) | 1024 | 1 | 1,626,016 rows/s | ~71 MiB | 0.4.20 |
IPC forward (static → memory, Unix socket) | 1024 | 1 | 1,419,025 rows/s | — | 0.4.20 (300 s window) |
| Postgres → JSONL (1M rows, 7 mixed-type cols) | 1024 | 4 | 505,050 rows/s | ~57 MiB | 0.4.20 |
| Postgres → JSONL (same) | 1024 | 1 | 413,907 rows/s | ~55 MiB | 0.4.20 |
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. Rows were measured on
different versions; see the Version column.
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.26 µs/row — CSV→JSONL drops from 2.82M to 1.64M rows/s, about 1.73x, 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, ~28 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 ~68 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). - A second publisher on the same socket connects without an error but is only read after the first disconnects. Its sends block once the socket buffer is full.
- A send is acknowledged once it is in the socket buffer, and IPC redelivery is consumer-local: neither survives a consumer crash. Use a directory spool or a broker for a durable hand-over. 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: delete. - 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 = "1", 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, and
meilisearchis a built-in endpoint. Address Pulsar directly, aspulsar: { 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 as the Python binding (mq-bridgeon PyPI), do load it as a plugin.To run a plugin in place of a built-in endpoint of the same name, install it (Homebrew, conda) or put its directory in
MQB_PLUGIN_DIR, and setMQB_PLUGIN_OVERRIDE=<name>. For example,MQB_PLUGIN_OVERRIDE=meilisearchuses the Meilisearch plugin, which has options the built-in endpoint leaves out.--pluginandplugins:do not work for this. With-v, startup logs which copy is in use.
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
A plugin is loaded either by path, as below, or by the endpoint name a route asks for — see Installing a plugin by name.
// 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.
Installing a plugin by name
A plugin does not have to be listed anywhere. When a route asks for a custom
endpoint no factory is registered under, mq-bridge looks for one file named
after it:
| Platform | File |
|---|---|
| Linux | libmq_bridge_<name>.so |
| macOS | libmq_bridge_<name>.dylib |
| Windows | mq_bridge_<name>.dll |
- in an endpoint name becomes _ in the file name, so ibm-mq is
libmq_bridge_ibm_mq.so. Installing that file is the whole installation step —
no plugins: entry, no --plugin, no load_endpoint_plugin call:
mqb copy 'connect://…' 'postgres://…'
Directories are searched in this order, nearest first:
- every directory in
MQB_PLUGIN_DIR(:-separated,;on Windows) — a custom location, and the one to set for an unpackaged build - the directory holding the running binary
lib/mq-bridgeandlibunder that binary’s prefix —/opt/homebrew/libfor a brewedmqb,$CONDA_PREFIX/libfor a conda-installed one- the same two under
$CONDA_PREFIXand$HOMEBREW_PREFIX, which reach a plugin the running binary is not installed beside —pythonin a venv,cargo run, a plugin brewed for a binary from somewhere else /opt/homebrewand/usr/local(/home/linuxbrew/.linuxbrewand/usr/localon Linux), becauseHOMEBREW_PREFIXcomes frombrew shellenvin a shell profile and a service or container has none$XDG_DATA_HOME/mq-bridge/plugins, else~/.local/share/mq-bridge/plugins(%APPDATA%\mq-bridge\pluginson Windows)
On Windows each prefix also contributes Library\bin, where a conda environment
keeps its DLLs. Both lib/mq-bridge and plain lib are searched under every
prefix, so a package manager needs no special layout: lib.install in a brew
formula or a conda package’s default lib is enough, and lib/mq-bridge keeps
a hand-managed install tidy.
Only the named file is opened. A route never loads a library it did not name,
and the search runs only after the registry has missed, so it costs nothing when
every endpoint is built in. plugin::discover_all_endpoint_plugins() is the one
call that lists the directories and loads every libmq_bridge_* up front;
mq-bridge-app makes it at startup so its UI lists those endpoints. A file that
does not export mq_bridge_plugin_v1 — a plugin’s own helper library, such as
libmq_bridge_connect_go — is recognised from its export table and never opened.
Set MQB_PLUGIN_DISCOVERY=0 (or false, off, no) to switch the search off
and resolve endpoints only from factories the host registered or a config listed
by path.
plugin::disable_plugin_loading() goes further: after it, the process opens no
plugin library at all, by path or by discovery, and nothing switches it back on.
Call it at startup in a deployment that must not load native code at runtime.
Which files discovery trusts
Loading a library runs its code, so a library found by discovery is loaded only if nobody but you or root could have put it there. On Linux and macOS the file and every directory above it, symlinks resolved, must:
- belong to the user running mq-bridge, or to root, and
- not be world-writable. A sticky directory such as
/tmpis fine, because others may add files there but not replace yours.
Group write is allowed, because Homebrew’s lib directory is 775. A library
that fails the check is refused with a message naming the offending directory.
When discover_all_endpoint_plugins() finds one, it logs a warning and skips it.
Running as root, only root-owned libraries pass. A service started as root
therefore never picks up a plugin a user installed into their own Homebrew
prefix or ~/.local/share. Install system-wide plugins as root, or give the
service a user of its own.
Every library discovery loads is logged at info with its path and SHA-256, so
there is a record of what ran without being named.
A library loaded by path — load_endpoint_plugin, --plugin, a plugins:
entry — is not checked: naming the file is the trust decision. Windows has no
equivalent owner and mode to check, so discovery there relies on the directory
permissions of the install location; set MQB_PLUGIN_DISCOVERY=0 and load by path
where that is not enough.
A file whose name does not match the endpoint the library actually provides is an error naming both — and the library stays loaded, because nothing is ever unloaded. The same applies to a library that exists but fails to load: that is reported as a load failure, not as an unknown endpoint.
Installing one with a package manager
Homebrew and conda install into a prefix the search above already covers, so
either one is the whole installation — no plugins: entry, no --plugin, no
load_endpoint_plugin call:
brew install marcomq/tap/mq-bridge-pulsar # macOS arm64, Linux x86_64/arm64
conda install -c marcomq mq-bridge-pulsar # the same, plus Windows x86_64
Both cover pulsar, meilisearch and connect. Neither depends on
mq-bridge-app: one installed library serves whatever host asks for the
endpoint — the brewed CLI, the desktop app, a Python or Node process in a
virtualenv — which is why the search covers HOMEBREW_PREFIX and
CONDA_PREFIX rather than only the running binary’s own prefix.
Replacing an endpoint mqb already has
Some endpoints are compiled into mqb itself — Pulsar is a separate crate
linked into the full build, and meilisearch, typesense, elasticsearch,
qdrant, openai_chat and openai_embeddings are built on http_bulk — and a registered factory is found before the search
above ever runs. The built-in copy therefore wins by default,
so one binary behaves the same wherever it runs.
MQB_PLUGIN_OVERRIDE reverses that, which is how such an endpoint is updated
without waiting for a new mqb release:
# prefer the installed Meilisearch plugin over the linked-in copy
MQB_PLUGIN_OVERRIDE=meilisearch mqb copy 'postgres://…/orders' 'meilisearch://localhost:7700?index=orders'
# prefer an installed plugin for every built-in extension
MQB_PLUGIN_OVERRIDE=1 mqb run -c config.yaml
The value is 1, true, yes, on or all for every extension, or a
comma-separated list of endpoint names. It is an environment variable rather
than a flag or a config key because endpoints are registered once per process,
before any config is read — a mqb copy between two URIs never loads a config
file at all, and two routes in one process cannot use different versions of the
same endpoint.
--plugin and plugins: cannot replace a built-in endpoint. They load a
library next to what is already registered, so naming a library for pulsar or
meilisearch stops mqb at startup with `meilisearch` … is already registered by another factory, with or without MQB_PLUGIN_OVERRIDE. The
override only takes a library found on the search path.
To try a build that is not installed, point MQB_PLUGIN_DIR at its directory:
MQB_PLUGIN_DIR=$PWD/target/release MQB_PLUGIN_OVERRIDE=meilisearch \
mqb copy -v 'postgres://…/orders' 'meilisearch://localhost:7700?index=orders'
The directory must pass the ownership check described above, and the file must
keep its build name (libmq_bridge_meilisearch.so, .dylib on macOS). With
-v, startup logs using the installed `meilisearch` plugin … instead of the built-in copy; without that line the built-in copy is in use.
When a plugin is installed but not preferred, startup says so rather than
ignoring it silently, naming the file and the variable that would use it. And
because a plugin’s version cannot be read without loading it, preference is by
name, not by version: an older installed plugin will replace a newer
built-in. MQB_PLUGIN_DISCOVERY=0 switches off the search, the notice and the
override together.
All built-in endpoints as one plugin
plugins/full builds libmq_bridge_full, a library with a factory for every
built-in endpoint, each under the endpoint’s own name. It is for an application
that embeds mq-bridge without the endpoint features and wants them in some
deployments only:
mq-bridge = { version = "1", default-features = false, features = ["plugin"] }
mq_bridge::plugin::load_endpoint_plugins("/opt/app/libmq_bridge_full.so")?;
Routes are written as usual (kafka:, file:, sqlx:, not custom:). An
endpoint the host has a feature for is built by the host; any other is built by
the factory registered under its name, and without one the route fails with
Unsupported … endpoint type as before. Build a smaller library with
--no-default-features --features portable, or with single features such as
--features mq-bridge/kafka.
The library has its own engine. A memory channel, a ref name or a factory
registered in the host is not visible to an endpoint it creates, so keep
structural endpoints and memory in the host’s route and let the library provide
only the transport. --no-resume is not applied to an input served this way.
Writing a plugin
Implement the ordinary mq-bridge contracts — CustomEndpointFactory,
MessageConsumer, MessagePublisher (see EXTENDING.md) — then
export the factory:
[dependencies]
mq-bridge = { version = "1", 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,
}
Since ABI 1.2 one library can export several endpoints. Each entry takes
the same arguments as export_endpoint_plugin!:
mq_bridge::export_endpoint_plugins! {
{ name: "pulsar", factory: PulsarFactory },
{ name: "pulsar-admin", factory: AdminFactory, capabilities: mq_bridge::plugin::sdk::CAPABILITIES_OUTPUT_ONLY },
}
Loading the library registers all of them, or none if one name is taken.
A route’s lookup opens only the file named for the endpoint it asks for, so
install the library under each name a route may ask for (a symlink will do), or
load it explicitly. discover_all_endpoint_plugins() finds every entry either
way. A 1.0/1.1 host sees only the first entry.
Shared helpers
Things most endpoints need, so a plugin doesn’t write them itself:
mq_bridge::errors::InvalidConfig. Return a config error wrapped in it fromcreate_consumerorcreate_publisher, and the route stops instead of reconnecting forever:config::resolve(value).map_err(InvalidConfig)?. The same wrapper works for both sides, linked directly or loaded as a plugin.mq_bridge::support::stream_batch::next_batch. Collects one batch from a client that hands out messages as aStream. A live route waits for the first message; a draining one (exit_on_empty) gets an empty batch from an idle source after 250 ms, which is what ends the drain. A stream error comes back as aPartialBatchholding the messages collected before it: deliver those, then report the error on the next call.SentBatch::from_failures.Ackwhen nothing failed, otherwise aPartialnaming the failed messages.SentBatch::from_outcomes. The same from oneOption<PublisherError>per message in batch order, for a sink that tracks failures by position.mq_bridge::support::ndjson::chunk. Frames JSON payloads as NDJSON request bodies and starts a new body before one exceeds a byte limit, for a sink that takes documents in bulk over HTTP.mq_bridge::support::http_status.is_retryable(status)is true for 408, 429 and the 5xx codes a retry can help with;publisher_error(status, error)wraps an error asRetryableorNonRetryableon that basis.mq_bridge::support::poll_job::poll_until. Polls a job the remote system runs asynchronously until it reports an end, backing off from 10 ms to 250 ms. A callback fires every 60 s so the endpoint can log that it is still waiting, and after 5 minutes it answersNone. All four durations are set throughPollSchedule.mq_bridge::support::change_op::ChangeOp.ChangeOp::classify(operation, delete_values)maps a change record’s operation name ontoUpsert,DeleteorTruncate, for a sink fed by a CDC source.mq_bridge::support::interpolation::CompiledTemplate. Compiles a${metadata:…}or${payload:…}config value once and renders it per message, for a field such as a routed index name or a change operation.mq_bridge::checkpoint.parse_checkpoint_storeandbuild_external_storeturn acheckpoint_storespec into a store for a source’s read position. The file backend needs no feature; the SQL, MongoDB and object-store ones need the matchingmq-bridgefeature in the plugin.
A batch’s commit function gets exactly one disposition per message, so it needn’t count them; the plugin host rejects any other count before calling it.
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.
An error from on_receive or on_send fails the batch as retryable. Return a
ProcessingError::NonRetryable (or a ConsumerError::Permanent) for a batch
that can never pass, so that it is not retried forever:
return Err(ProcessingError::NonRetryable(anyhow!("not a JSON payload")).into());
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,
}
Describing its configuration
An endpoint’s config is an opaque JSON object: the plugin defines it, and the
host has no idea what is in there. Since ABI 1.1 a plugin can describe it as
a JSON Schema, which buys two things — a host can render a form for the endpoint
instead of a blank text box, and it can turn a URI into configuration with the
right types.
The second one matters more than it sounds. A URI carries only text, so without
a schema ?batch_size=100 reaches the plugin as the string "100" and
?tls=true as "true". Any plugin with a non-string config field is unusable
from a URI until it describes one.
impl CustomEndpointFactory for ConnectFactory {
fn config_schema(&self) -> Option<serde_json::Value> {
Some(serde_json::json!({
"type": "object",
"properties": {
"url": { "type": "string", "x-mqb-uri": "origin" },
"topic": { "type": "string", "x-mqb-uri": "path" },
"group": { "type": "string", "default": "mq-bridge" },
"batch_size": { "type": "integer", "default": 100 }
},
"required": ["url", "topic"]
}))
}
// ...
}
Deriving it beats writing it by hand if your config is a Rust struct — add
schemars to your own crate and return
serde_json::to_value(schemars::schema_for!(ConnectConfig)).ok(). Nothing but
JSON crosses the ABI, so your schemars version and the host’s need not agree,
and a plugin written in another language just emits the document. title,
description and default are what a host shows, so they are worth filling in;
models.rs doc comments serve the same purpose for built-in endpoints.
A derived schema spells an enum field as a $ref into $defs, and a documented
enum as a oneOf of consts. The host flattens both
(mq_bridge::support::config_schema::flatten) before it renders a form or
checks a configuration, so derive the enum like any other field. Hosts older
than 0.4.18 read only a flat {"type": "string", "enum": [...]}.
MiddlewareFactory::config_schema does the same for the middleware half. A
middleware is never addressed by a URI, so only the form applies.
Where each field sits in a URI
x-mqb-uri names a field’s place in a URI. JSON Schema reserves the x- prefix
for annotations, so a validator ignores it and the document stays a plain schema.
x-mqb-uri | Gets | From rp://user@host:9092/orders?group=g |
|---|---|---|
subscheme | the scheme’s part after + | nothing; see below |
origin | scheme, userinfo, host and port | rp://user@host:9092 |
url | everything before ? | rp://user@host:9092/orders |
path | the path, without its leading / | orders |
query | the query parameter of the same name (the default) | g |
Each position but query may be claimed by at most one field.
Everything unannotated is a query parameter, read as its declared type —
integer, number, boolean, array (comma-separated, with items honoured)
or string. A value that is not what the field declares is refused by name,
rather than handed to the plugin’s deserializer to complain about.
A parameter the schema does not declare stays a string. A plugin that takes
open-ended options (additionalProperties) and cannot declare their types can
set "x-mqb-uri-infer-scalars": true at the schema’s top level: an undeclared
value of true or false becomes a boolean, a plain integer an integer, and a
decimal like 0.5 a number. Anything else stays a string, including 007,
1.0.0 and integers too large for 64 bits.
A query parameter always wins over the position it would have had, so
?url=... remains the escape hatch for an address a URI cannot spell.
Annotate nothing and the mapping is the one that predates schemas: url gets
everything before ? and every parameter stays a string. That is also what a
plugin describing no schema at all gets, so nothing changes under an older
plugin.
A plugin that is a gateway
A plugin reaching a family of protocols rather than one — a compatibility layer,
a driver host — names the protocol in the scheme, after a +:
mq-bridge --input 'connect+mqtt://localhost:1883/orders' --output 'kafka://...'
This is the spelling git+ssh://, svn+ssh:// and SQLAlchemy’s
postgresql+psycopg2:// made familiar. The part before the + names the
plugin, so that is what the host looks the factory up by; the part after it is
the plugin’s own vocabulary, and a field annotated subscheme receives it.
Everything after the scheme then describes the inner protocol rather than the
plugin, so origin and url are handed over carrying the inner scheme:
From connect+mqtt://host:1883/orders | |
|---|---|
subscheme | mqtt |
origin | mqtt://host:1883 |
url | mqtt://host:1883/orders |
path | orders |
A scheme may hold only letters, digits, +, - and .
(RFC 3986) — no _. A
plugin whose protocol names contain one accepts - in its place and maps it
back itself, which stays unambiguous only as long as no name of its own uses
-.
A schema the host cannot use — not an object, two fields claiming the same
position, a misspelled x-mqb-uri — fails at load time rather than at
whichever call site looked first.
Enforcing it
A schema is read and mapped, not enforced — your factory still checks what it
is given. Set MQB_PLUGIN_VALIDATE_CONFIG=1 and the host checks a route’s config
against it first, which turns a deserializer’s complaint into a message naming
the field:
endpoint `connect` configuration: unknown field `topci`; this endpoint takes
batch_size, group, topic, url
It is off by default on purpose. The schema is yours, not the route’s, and the
host checks only the subset the transform middleware already validates against
— type, required, enum, items, nested properties, plus unknown
top-level fields when you set additionalProperties: false. A local $ref and
a oneOf of constants are resolved first. A schema using more than that (a
oneOf of shapes, $ref to a remote document, patternProperties) is logged
as uncheckable and passed through rather than rejected, so describing yourself
richly for the sake of a form never costs you a working endpoint.
Declaring a delivery guarantee
A route logs — and with required_delivery enforces — the guarantee it can give
(see DELIVERY.md). For a custom endpoint only its author knows the
answer, so the factory states it. Two top-level schema annotations cover the
common case and cross the plugin boundary unchanged:
| Annotation | Meaning | Default |
|---|---|---|
x-mqb-idempotent-sink | writing the same record twice leaves one effect, so a route into this sink is effectively-once | false |
x-mqb-acknowledges | the consumer acks, so a message lost in a crash is redelivered; false makes a route from it at-most-once | true |
{ "type": "object", "x-mqb-idempotent-sink": true, "properties": { "...": {} } }
When the answer depends on the configuration — idempotent only with a key set —
override CustomEndpointFactory::idempotent_sink or acknowledges instead; both
receive the endpoint’s config. Since ABI 1.2 the host asks a loaded plugin the
same way (factory_delivery); a 1.0/1.1 plugin is judged by its schema alone. Claim idempotency
only for a write keyed on something replay-stable: the route trusts it.
Asking for a batch size
mqb copy sends 1,024 messages per batch unless --batch-size says otherwise. A
sink that is much faster with larger batches states its own default with a
top-level schema annotation:
{ "type": "object", "x-mqb-default-batch-size": 10000, "properties": { "...": {} } }
mqb copy and the MCP server’s start_route use it when the output is that
endpoint and no batch size was given. Routes in a config file are not affected:
they take batch_size from the file. Larger batches hold more messages in
memory at once, so pick the value from a measurement.
Learning how the route ended
A publisher’s on_disconnect_hook can read mq_bridge::traits::disconnect_outcome()
to learn whether the route Completed (its input ended), was Stopped, or
Failed. Since ABI 1.3 this also works in a loaded plugin, with mq-bridge
0.4.18 or later as the host: the host calls publisher_disconnect with the
outcome before it closes the publisher.
In an older host the hook still runs when the publisher closes, but
disconnect_outcome() returns None. Treat None as Stopped. An action that
is only safe after a complete run, such as swapping a freshly loaded table into
place, must be skipped then.
An error returned from the hook is logged as a warning. It does not fail the
route, and mqb copy still exits successfully.
Ordered publishing
A sink whose correctness depends on batches arriving in source order — anything
keyed, where a stale write can overwrite a newer one — overrides
MessagePublisher::requires_ordered_publish. Since ABI 1.1 the host reads
that through the plugin boundary too, so a plugin-loaded sink gets its sends
sequenced exactly like a directly linked one, whatever the route’s
concurrency.
A plugin built against ABI 1.0 has no such entry. The host cannot ask, so it assumes unordered — which is what those plugins already do today. Rebuild against 1.1 to have the flag honoured.
Partial publishes
A publisher that returns SentBatch::Partial says some of the batch landed and
some did not. Since ABI 1.1 that survives the boundary: the host hands the
plugin a byte per message and the plugin marks the ones that failed, so the route
nacks or dead-letters only those and acknowledges the rest.
Nothing new to write — the SDK derives the marks from the Partial your
send_batch already returns, matching each failure back to its position by
message id. Two details are worth knowing:
- The class travels per message, the text does not. Each mark says retryable or permanent; one error string describes the batch. Per-message text would cost an allocation per failure, and can be appended in a later minor if it is ever needed.
- A batch where nothing landed stays a batch error, not a
Partiallisting every message, so a connection-level failure can still mean “reconnect this endpoint” — something no per-message mark can express.
A plugin built against ABI 1.0 has no such entry. The host falls back to the all-or-nothing send, where a partial failure is reported as the first failure’s class and the whole batch — including the part that succeeded — is retried or dead-lettered.
Request/reply and status
Since ABI 1.2 request/reply works through a plugin in both directions:
- Publisher: the responses your
send_batchreturns (Sent::Response,SentBatch::Partial { responses, .. }) cross the boundary. The route matches each one to its request bymessage_id, exactly as for a linked endpoint. Failures and responses in the same batch both survive. - Consumer: a
MessageDisposition::Replyreaches your batch’s commit together with the reply message, so a plugin source can answer the request it received.
status() crosses too: the host asks the plugin, and your endpoint’s
EndpointStatus is what a host shows. A consumer busy in receive_batch
answers healthy with details.state = "receiving" rather than waiting for its
next message.
Receive, commit, send and flush no longer tie up a host thread either: the host starts the call and the plugin reports back through a completion callback when its runtime has finished. Before 1.2 each of those calls held a thread of the host’s blocking pool for its whole duration.
That is measurably faster. The same in-memory endpoint, which does no work of its own, was built against the 1.1 and the 1.2 SDK and driven by the same host (messages per second, macOS arm64, release build):
| Batch | Call | 1.1 | 1.2 | Gain |
|---|---|---|---|---|
| 1 | send | 94k | 154k | 1.6× |
| 1 | receive + commit | 46k | 76k | 1.6× |
| 1 | send, 16 concurrent callers | 164k | 708k | 4.3× |
| 128 | send | 6.5M | 8.6M | 1.3× |
| 128 | receive + commit | 4.0M | 5.4M | 1.4× |
| 128 | send, 16 concurrent callers | 15.6M | 25.5M | 1.6× |
This measures only the boundary cost. It matters most for small batches and many concurrent routes; a real broker’s latency hides most of it at large batches. Rebuilding a plugin against 1.2 is enough to get the gain.
Your tracing events and metrics samples reach the host as well. On load the
SDK installs a subscriber and a recorder inside the plugin that forward to the
host’s own. The host re-emits every plugin event under the target
mq_bridge::plugin (mq_bridge::plugin::PLUGIN_LOG_TARGET), with your module
path in the module field, so RUST_LOG=mq_bridge::plugin=debug filters them.
Metrics keep their names and labels; forwarding them needs the metrics feature
of mq-bridge in the plugin and in the host. A plugin that installs its own
global subscriber or recorder first keeps it, and nothing is forwarded.
The SDK wires all of this up; there is nothing new to implement. A plugin built against 1.0 or 1.1 publishes without responses, has a reply acknowledged like a plain ack, reports the default status, runs its calls on the blocking pool, and keeps its logs and metrics to itself.
Limits of ABI v1
- The export macro defines the discovery symbols, so a crate uses it once and 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.
For an endpoint that talks HTTP, test_support::StubHttpServer stands in for the
remote system without Docker. StubHttpServer::start(|request| (200, body))
listens on a free local port, answers every request through the closure, and
requests() returns what it received: method, target, headers and body.
Writing one in C or C++
The ABI is plain C, so a plugin does not have to be Rust. The usual reason is an
existing C library — a parser for a proprietary wire format, say — that should
run as a middleware without being rewritten.
include/mq_bridge_plugin.h declares the ABI;
include/mq_bridge_plugin_helpers.h
fills in every entry a plugin does not implement. A complete middleware that
drops heartbeat messages:
#include <stdlib.h>
#include <string.h>
#include "mq_bridge_plugin_helpers.h"
static MqbStatus apply(MqbMiddlewareHandle middleware, const MqbMessage *messages, size_t len,
MqbFilterHandle *out_result, const MqbMessage **out_messages,
const uint8_t **out_kept, MqbBuffer *err) {
uint8_t *kept = malloc(len + 1);
if (kept == NULL) {
mqb_set_error(err, "out of memory");
return MQB_ERR_RETRYABLE;
}
for (size_t i = 0; i < len; i++) {
MqbSlice p = messages[i].payload;
int ping = p.len == 4 && memcmp(p.ptr, "ping", 4) == 0;
kept[i] = ping ? MQB_MESSAGE_DROPPED : MQB_MESSAGE_KEPT;
}
*out_result = kept;
*out_messages = messages; /* kept messages pass through unchanged */
*out_kept = kept;
return MQB_OK;
}
static const MqbPluginVTable table = {
MQB_TABLE_HEADER("drop_heartbeats", "0.1.0", MQB_CAP_MIDDLEWARE),
MQB_DEFAULT_FACTORY,
MQB_NO_CONSUMER,
MQB_NO_PUBLISHER,
MQB_STATELESS_MIDDLEWARE,
.middleware_apply = apply,
.middleware_result_free = free,
};
const MqbPluginVTable *mq_bridge_plugin_v1(void) { return &table; }
cc -shared -fPIC -I include examples/c-plugin/minimal.c -o libdrop_heartbeats.so
middleware_apply writes back two arrays as long as the input: the messages,
and one MQB_MESSAGE_KEPT / MQB_MESSAGE_DROPPED flag each. Both stay valid
until the host passes the result handle to middleware_result_free. The output
may point into the input, so an unchanged message — or a rewritten one’s id and
metadata — needs no copy. The rules the Rust SDK enforces for you are yours to
keep:
- Every function pointer must be set. The host never checks for null; the helper macros cover what you don’t implement.
- Other arguments are borrowed for the call only; copy what you keep.
- Error text goes into the
errbuffer (mqb_set_error), released through the table’sbuffer_free. - Calls can arrive concurrently from several threads.
- A
*_asyncentry that returnsMQB_OKmust call its completion exactly once, errors included. The host waits without a deadline, since the plugin may still write the out-parameters, so a missed callback stalls that endpoint. - In C++, never let an exception escape: catch it and return
MQB_ERR_PERMANENT. The helper macros are C only; C++ fills the table in declaration order.
A plugin need not implement the non-blocking (*_async), per-message-outcome,
response or status entries. Where one answers MQB_ERR_UNSUPPORTED, the host
falls back to the plain blocking call, on a thread of its own, and reports a
healthy default status. MQB_BLOCKING_PUBLISHER fills in exactly those entries,
so a publisher sets only publisher_create, _send_batch, _flush, _close
and _free.
examples/c-plugin/plugin.c shows both sides
in one library, wrapping two unchanged “legacy” libraries. Its middleware turns
fixed-width records into JSON and logs the ones it drops (mqb_log). Its output
appends each message to a ledger file, taking the path from its config and a
mutex around the non-thread-safe library.
mq_bridge_plugin.h is generated from src/support/plugin_abi.rs, so it always
matches the host, and its static_asserts refuse to compile if the table layout
ever does not.
A C or C++ program that embeds mq-bridge through the
C library does not need a separate plugin library:
mqb_register_plugin(&table) registers the same table from the program itself.
When it crashes
A plugin has no main() in which to install a SIGSEGV handler, and a handler
it installs itself would replace the host’s and every other plugin’s. So the
host installs one when the first plugin library loads (Linux and macOS). On
SIGSEGV, SIGBUS, SIGILL, SIGFPE or SIGABRT it writes to stderr:
mq-bridge: fatal signal 11 (SIGSEGV), fault address 0x8, pc 0x7f3a1c2b4f10
mq-bridge: pc is in /opt/plugins/liblegacy_payments.so at offset 0x1f10
mq-bridge: plugin libraries (load address, path, build id):
mq-bridge: 0x7f3a1c2b3000 /opt/plugins/liblegacy_payments.so build-id 3c9e…
mq-bridge: backtrace (symbolize with `addr2line -e <library> <offset>` or `atos -o <library> -l <load address> <address>`):
…
A stripped plugin is still symbolizable: whoever holds its debug info matches
the build id (the UUID on macOS) and resolves the offsets offline, e.g.
addr2line -f -e liblegacy_payments.so.debug 0x1f10. After the dump the host
hands the signal to whichever handler was installed before (Rust’s stack
overflow message, Python’s faulthandler), then dies of it, so core dumps and
debuggers work as usual. MQB_PLUGIN_CRASH_REPORT=0 turns the dump off.
A process that brings its own crash reporter (Sentry, Crashpad, a Python or Node
host) can set MQB_PLUGIN_CRASH_HANDLER=0 before the first plugin loads. The
host then installs no signal handler at all, and register_crash_handler
answers MQB_ERR_UNSUPPORTED.
A plugin can add its own report through register_crash_handler on the host
table (mqb_register_crash_handler in the helpers). A closed-source plugin
might print where to send the output, or write a minidump.
plugin.c registers one from its own
plugin_init, using MQB_FACTORY_WITHOUT_INIT. The handler runs inside the
signal handler, after the dump. It may only make async-signal-safe calls
(write, backtrace_symbols_fd), with no malloc, printf or locks. The
process dies afterwards.
Limits: a thread the plugin created itself has no alternate signal stack, so a
stack overflow on it ends the process without a report. Windows has no crash
handling, and register_crash_handler returns MQB_ERR_UNSUPPORTED there. For
memory bugs that don’t crash where they happen, build the plugin with
-fsanitize=address and preload the ASan runtime
(LD_PRELOAD=$(cc -print-file-name=libasan.so)).
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[plugin-packaging]"
python -m mq_bridge.plugin_packaging --package python/my_plugin --out dist
mq-bridge-package-plugin --package node --pack --out npm
examples/plugin-template is a complete plugin
repository to copy: a small working endpoint, the npm and Python packages, the
conda recipe, the Homebrew formula script, and the CI and release workflows. Its
README lists the accounts and secrets the release workflow needs.
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
Both forms need a build of the app without Pulsar compiled in; the released
mqb has it built in and refuses the library, see
Replacing an endpoint mqb already has.
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”.
Neither is needed for a plugin installed under its conventional file name: the endpoint name alone resolves it, in a config or in a URI scheme. See Installing a plugin by name.
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.
Minor versions so far:
| Minor | Added |
|---|---|
| 1.0 | The initial table. |
| 1.1 | publisher_requires_ordered_publish, so a plugin sink can ask the route to keep its sends in source order; publisher_send_batch_outcomes, so a partly failed batch reports which messages failed; and factory_config_schema, so a plugin describes its configuration as a JSON Schema for a host to render and to map a URI onto. |
| 1.2 | publisher_send_batch_responses and responses_free, so publish responses reach the route; batch_commit_replies with MQB_DISPOSITION_REPLY, so a plugin consumer receives the reply to send; consumer_status / publisher_status, so status() reports the plugin’s own state; and *_async twins of receive, commit, send and flush that finish through an MqbCompletion callback instead of blocking a host thread; plugin_init hands the plugin an MqbHostVTable, through which its logs and metrics reach the host and it registers a crash handler; the optional mq_bridge_plugin_v1_at symbol exports several tables from one library; factory_delivery answers idempotent_sink / acknowledges per config. |
| 1.3 | publisher_disconnect, which hands a publisher the route’s outcome (MQB_DISCONNECT_COMPLETED, _STOPPED, _FAILED) before it is closed, so disconnect_outcome() works inside a loaded plugin. |
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
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.
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-targets -- -D warningsmust pass (whatapp.ymlruns). Not--all-features: it turns on the engine’slink-dynamic, which needs a system librdkafka via pkg-config.- 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 in the repository under docs/book/. To build it locally,
see docs/book/README.md.
See also
- Engine
CONTRIBUTING.md— the full, authoritative guide. - Custom endpoints and Custom middleware — extend without forking.