# ETL / CDC benchmark harness (run through mq-bridge-app)
This harness measures how fast the mq-bridge engine moves data **when it is driven
the way a no-code user drives it**: through `mq-bridge-app`, configured by CLI
arguments or a YAML file. No scenario uses a Criterion micro-harness or hand-written
Rust. Competing ETL/CDC tools publish "config in → data moved out" numbers; these are
the same kind of number.
**This file is the single source of truth for every measured ETL/CDC number.** Change
a number here and nowhere else. The book's
[tuning page](../../../../docs/book/operations/tuning.md) and
[MCP page](../../../../docs/book/MCP.md) include their tables from this file by
anchor, so they follow automatically.
Contents:
1. [How to read the numbers](#how-to-read-the-numbers)
2. [Results at a glance](#results-at-a-glance)
3. [Scenario index](#scenario-index)
4. [Setup and teardown](#setup-and-teardown)
5. [Scenarios](#scenarios) — one section each, all with the same layout
6. [Typed vs. untyped: how to read the Sling ratios](#typed-vs-untyped-how-to-read-the-sling-ratios)
7. [Published benchmarks these line up against](#published-benchmarks-these-line-up-against)
## How to read the numbers
These rules hold for every table on this page unless a cell says otherwise.
| Item | Value |
| --- | --- |
| mq-bridge version | **0.4.20**, measured 2026-10-06 |
| Build | default release build (`full` features, **mimalloc** allocator) — what Homebrew and cargo-binstall ship |
| Machine | Apple M1, 8 cores, 8 GB RAM, in ordinary desktop use |
| Dataset | 1,000,000 rows, seed 42, 7 mixed-type columns (scenarios 5–11); padded 256 B / 4 KiB JSON rows (scenarios 1–3) |
| Throughput | rows ÷ median wall-clock of the whole process, start to exit |
| Runs | one discarded warm-up, then the timed runs; run counts are next to each number |
| Peak RSS | `/usr/bin/time -l`, measured alongside throughput |
| Correctness | every timed run must land the full row count or the run fails |
| Baseline versions | Sling 1.5.21 · Meltano 4.4.0 (`tap-csv` 1.3.2, `tap-postgres` 0.9.0, `target-jsonl` 0.1.4) · DuckDB 1.5.6 · Arroyo 0.15.0 · Sea Streamer 0.5.2 · Redpanda Connect 4.112.0 (mq-bridge-connect plugin 0.1.1) · Vector 0.59.0 |
- **Numbers are hardware-dependent.** Treat them as shape, not guarantees. Differences
of a few percent are noise. Ratios against a baseline on the same machine are the
portable figures.
- **Never quote a number without its mq-bridge version.**
- **`--concurrency 1` does not mean one thread.** It is one route *worker*. CSV
decoding is spread across a shared pool, so the untyped CSV run uses ~2.9 cores
(0.66 s user + 0.15 s sys against 0.28 s wall) and the typed run ~4.5. Any comparison
against a baseline pinned to one thread has to say so.
- **Every mq-bridge-app number is a mimalloc number.** mimalloc is a cargo feature,
on by default and also pulled in by the `bench` feature. To measure the system
allocator instead, build with
`--no-default-features --features mq_bridge_app/bench`. Baseline tools are
unaffected.
## Results at a glance
### mq-bridge-app on its own
| 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 |
The IPC row is a sustained rate sampled from the receiver's transport log, not a
wall-clocked job. It has no RSS column and is not comparable cell-for-cell with the
other rows.
Three things the table shows:
- **Typing has a real but modest cost.** A `transform` that coerces `id` to an integer
and decodes an embedded JSON document costs ~0.30 µs/row. CSV → JSONL drops from
3.13M to 1.63M rows/s (about 1.93x), but every output record is fully typed.
- **Postgres → JSONL is bound by the Postgres read**, not by the file sink: four route
workers add ~22% over one, far from 4x.
- **Peak RSS does not scale with dataset size**, because rows stream in batches rather
than being buffered whole: ~29 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 `transform` raises it to ~71 MiB through per-row
JSON decode and buffering.
### Against other tools
| Job | mq-bridge-app | Other tool | Result | Read it with this attached | § |
| --- | ---: | ---: | --- | --- | --- |
| CSV → JSONL, typed | 1,626,016 rows/s | Sling: 111,358 | **~14.6x** faster | equal work; outputs asserted identical | [6](#6--csv--jsonl-vs-sling-and-meltano) |
| CSV → JSONL, untyped | 3,134,796 rows/s | Meltano: 9,771 | **~321x** faster | equal work; both emit strings | [6](#6--csv--jsonl-vs-sling-and-meltano) |
| CSV → JSONL, untyped | 3,134,796 rows/s | DuckDB: 2,109,704 | **~1.49x** faster, ~18x less memory | DuckDB is a ceiling, not an ETL tool | [6](#duckdb-as-a-ceiling) |
| Postgres → JSONL | 413,907 rows/s | Sling: 102,743 | **~4.0x** faster | *not* equal work: Sling types, mq-bridge-app does not | [5](#5--postgres--jsonl-vs-sling-and-meltano) |
| Postgres → JSONL | 413,907 rows/s | Meltano: 8,759 | **~47x** faster, ~11x less memory | default Singer config | [5](#5--postgres--jsonl-vs-sling-and-meltano) |
| Postgres → CSV | 495,540 rows/s | `psql \copy`: 587,199 | psql **~1.18x** faster | psql is a byte pump; expected | [8](#8--postgres--file-vs-the-tools-that-ship-with-postgres) |
| Kafka → JSONL, projection | 599,031 rows/s | Arroyo: 566,991 | within noise, half the memory | both in containers; mq-bridge-app image 0.4.19; delivery guarantees differ | [9a](#9a--kafka--jsonl-vs-arroyo) |
| Kafka → file, passthrough | 878,105 rows/s | Sea Streamer: 483,800 | **~1.82x** faster (1.78x vs. its mimalloc build) | sink formats differ | [9b](#9b--kafka--file-vs-sea-streamer) |
| CSV → JSONL, typed | 1,594,896 rows/s | Redpanda Connect: 81,893 | **~19.5x** faster | equal work; outputs asserted identical; same-session native figure | [10a](#10a--csv--jsonl) |
| Kafka → JSONL, projection | 766,280 rows/s | Redpanda Connect: 65,741 | **~11.7x** faster, under half the memory | both on the host; identical sink bytes | [10b](#10b--kafka--jsonl) |
| CSV → JSONL, typed | 1,564,945 rows/s | Vector: 56,670 | **~27.6x** faster | equal work; outputs asserted identical; Vector is bound by its sink | [11a](#11a--csv--jsonl) |
| Kafka → JSONL, projection | 722,337 rows/s | Vector: 57,431 | **~12.6x** faster, about half the memory | both on the host; identical sink bytes; Vector is bound by its sink | [11b](#11b--kafka--jsonl) |
| Kafka → Kafka, passthrough | 281,489 rows/s | Vector: 154,414 | **~1.8x** faster, about half the memory | both on the host; a sink Vector is built for; record counts asserted | [11c](#11c--kafka--kafka) |
## Scenario index
Every scenario section below has the same four parts: **Measures**, **Run**,
**Result**, **Notes**.
| § | Job | Reports | Runner | Headline |
| --- | --- | --- | --- | --- |
| [1 & 3](#1--3--postgres-table--table-batched-vs-unbatched) | Postgres table → Postgres table | rows/s over a payload × batch × concurrency matrix | `run_throughput.sh` | 110,448 rows/s (256 B, batch 128, conc. 4); 251,955 with `bulk_copy` at batch 32,768 |
| [2](#2--cdc-commit-to-sink-latency) | Postgres CDC → file | latency from `COMMIT` to sink line | `run_cdc_latency.sh` | p50 ~0.2 ms, p99 < 0.8 ms |
| [4](#4--local-ipc-throughput) | process → process over a Unix socket | sustained rows/s | `run_ipc_throughput.sh` | 1,419,025 rows/s |
| [5](#5--postgres--jsonl-vs-sling-and-meltano) | Postgres → JSONL | rows/s, peak RSS vs. Sling, Meltano | `run_meltano_bench.sh` | 413,907 rows/s, 54.9 MiB |
| [6](#6--csv--jsonl-vs-sling-and-meltano) | CSV → JSONL | rows/s, peak RSS vs. Sling, Meltano, DuckDB | `run_csv_to_jsonl.sh` | 3,134,796 rows/s, 28.7 MiB |
| [7](#7--mcp-server-tool-call-latency-throughput-token-cost) | CSV → JSONL through an MCP tool call | latency, rows/s, agent tokens | `run_mcp_bench.sh` | 2,466,515 rows/s, ~86 ms fixed cost |
| [8](#8--postgres--file-vs-the-tools-that-ship-with-postgres) | Postgres → file | rows/s vs. `psql \copy`, `pg_dump`; seven output formats | `run_pg_vendor.sh` | 495,540 rows/s to CSV |
| [9](#9--kafka--file-vs-arroyo-and-sea-streamer) | Kafka → file | rows/s, peak RSS vs. Arroyo, Sea Streamer | `run_kafka_stream.sh` | 878,105 rows/s, 160 MiB |
| [10](#10--redpanda-connect-and-the-connect-plugin) | CSV → JSONL and Kafka → JSONL | rows/s, peak RSS vs. Redpanda Connect; native endpoints vs. the Connect plugin | `run_csv_connect.sh`, `run_kafka_stream.sh connect` | ~19.5x (CSV, typed), ~11.7x (Kafka, projection) |
| [11](#11--vector) | CSV → JSONL, Kafka → JSONL, Kafka → Kafka | rows/s, peak RSS vs. Vector | `run_csv_vector.sh`, `run_kafka_stream.sh vector`, `run_kafka_stream.sh kafka-sink` | ~27.6x (CSV, typed), ~12.6x (Kafka → JSONL), ~1.8x (Kafka → Kafka) |
Scenarios 5 and 6 are the two headline ETL jobs. Scenario 3 (the batching lever) is
the `batch=1` vs. `batch=128` rows of scenario 1's matrix, which is why the two share
a section.
## Setup and teardown
Commands are run from `apps/mq-bridge-app`. Results land in `benches/etl/results/`.
```bash
# 1. Release binary (the default `full` build; every published number uses it).
cargo build -p mq-bridge-app --release
# 2. Postgres 16 with logical replication (mirrors the library's CDC compose).
benches/etl/seed.sh up
# 3. Optional: the Sling CLI baseline for scenarios 5 and 6 (a compiled Go EL tool).
# Kept repo-local so a run never depends on PATH; the runners skip Sling if absent.
mkdir -p benches/etl/bin && curl -sL \
"https://github.com/slingdata-io/sling-cli/releases/latest/download/sling_darwin_arm64.tar.gz" \
| tar -xz -C benches/etl/bin sling
# 4. Optional: Redpanda Connect and the mq-bridge-connect plugin for scenario 10.
# The runners skip whichever is absent.
curl -sL "https://github.com/redpanda-data/connect/releases/download/v4.112.0/redpanda-connect_4.112.0_darwin_arm64.tar.gz" \
| tar -xz -C benches/etl/bin redpanda-connect
brew install marcomq/tap/mq-bridge-connect
# 5. Optional: Vector for scenario 11. Skipped if absent.
curl -sL "https://github.com/vectordotdev/vector/releases/download/v0.59.0/vector-0.59.0-arm64-apple-darwin.tar.gz" \
| tar -xz -C benches/etl/bin --strip-components 3 ./vector-arm64-apple-darwin/bin/vector
```
Requirements: Docker, `curl`, `python3` (sub-second timing) and `uv` (runs
`gen_bench_data.py` and `cdc_latency.py`) on PATH. A host `psql` is used if present;
otherwise the harness runs `psql` inside the compose container. Scenario 8 is the
exception and needs host `psql` and `pg_dump`.
Teardown:
```bash
benches/etl/seed.sh down
```
Lean build — faster to compile, enough for the Postgres scenarios
A lean build avoids the heavy `full` dependencies (rdkafka/librdkafka, grpc/protoc,
ibm-mq). Do not mix numbers from the two builds in one table. Give the lean build its
own `CARGO_TARGET_DIR` so it cannot overwrite the default binary, and pass the same
value when running the benchmarks (the runners resolve `BIN` from it):
```bash
CARGO_TARGET_DIR=target-lean cargo build -p mq-bridge-app \
--no-default-features --features bench --release
CARGO_TARGET_DIR=target-lean benches/etl/run_throughput.sh
```
For `run_cdc_latency.sh` use `--features bench-cdc` instead: CDC needs the Postgres
logical-replication endpoint, which pulls aws-lc-sys (a slow C build) and is therefore
not in plain `bench`.
### Fixed parameters
| Parameter | Value |
| --- | --- |
| Payload | 256 B and 4 KiB JSON rows (scenarios 1–3); the 7-column `bench` row (scenarios 5–11) |
| Message count | 1,000,000 per run unless a table says otherwise |
| Batch sizes | 1 / 128 (scenarios 1 & 3); 1024 (scenarios 4–7 and 9–11); 32768 (scenario 8) |
| Concurrency | 1 and 4 route workers |
| Postgres | `postgres:16-alpine`, `wal_level=logical` |
| Warm-up | one discarded run per cell, plus a 5,000-row pre-roll when seeding scenarios 1 & 3 |
| Environment | CPU model, cores, RAM, mq-bridge(-app) version are printed next to every result |
Payload rows for scenarios 1–3 are `{"id":,"pad":"xxx…"}` padded to exactly 256 /
4096 bytes.
## Scenarios
### 1 & 3 — Postgres table → table, batched vs. unbatched
**Measures.** `copy` reads a seeded Postgres table and inserts into a fresh table,
wall-clocked. `rows/s = rows / elapsed`. Scenario 3 is the batching lever: compare
the `batch=1` and `batch=128` rows.
**Run.** The runner sweeps the payload × batch × concurrency matrix and re-seeds
before each timed run:
```bash
benches/etl/run_throughput.sh # -> benches/etl/results/throughput.csv
```
The single underlying command, as a user would type it:
```bash
mqb copy \
--from 'postgres://testuser:testpass@localhost:5432/testdb?table=src_256&cursor_column=id&sslmode=disable' \
--to 'postgres://testuser:testpass@localhost:5432/testdb?table=dst_256&columns=auto&sslmode=disable' \
--drain --batch-size 128 --concurrency 4
```
**Result.** 1 warm-up, median of the timed runs:
| Payload | Batch | Conc. | Rows | Runs | Median | rows/s |
| --- | ---: | ---: | ---: | ---: | ---: | ---: |
| 256 B | 1 | 1 | 100,000 | 2 | 65.553 s | 1,525 |
| 256 B | 1 | 4 | 100,000 | 2 | 34.221 s | 2,922 |
| 256 B | 128 | 1 | 1,000,000 | 3 | 19.055 s | 52,479 |
| 256 B | 128 | 4 | 1,000,000 | 3 | 9.054 s | 110,448 |
| 4 KiB | 1 | 1 | 100,000 | 2 | 79.854 s | 1,252 |
| 4 KiB | 1 | 4 | 100,000 | 2 | 73.679 s ±27.008 | 1,357 |
| 4 KiB | 128 | 1 | 100,000 | 3 | 8.446 s | 11,839 |
| 4 KiB | 128 | 4 | 100,000 | 3 | 5.355 s | 18,674 |
**Notes.**
- **Batching is the lever.** At 256 B, `batch=128` is ~34x `batch=1` at concurrency 1
and ~38x at concurrency 4.
- **The 4 KiB, batch 1, concurrency 4 cell is not reliable**: its two runs were ~47 s
and ~101 s. Every other cell stayed within ±1.3 s.
- **`columns=auto` writes the row's fields into the table's columns** (`id`, `payload`).
The other write modes are measured below.
- **Row counts differ per cell.** `batch=1` cells and the 4 KiB cells use 100,000 rows
(`MSG_COUNT=100000`); the 256 B `batch=128` cells use 1,000,000. The 4 KiB cells are
bound by Postgres writing ~4 GB per million rows on this 8 GB machine.
- **`cursor_column=id` reads the source non-destructively**, paging on the monotonic
`id` column (the sqlx cursor reader, an incremental-sync read like Airbyte's). Each
moved record is the source row as JSON (`{"id":N,"payload":"…"}`).
- The library's Criterion harness additionally covers the `memory` backend.
#### Larger batches and the write path
**Measures.** The same 256 B table → table copy at batch sizes above the matrix, once
per way the Postgres sink can write. Only the `columns=auto` column is what
`run_throughput.sh` runs; the other cells are the command above with a different `--to`:
| Variant | `--to` parameters | What lands in `dst_256.payload` |
| --- | --- | --- |
| envelope | `auto_create_table=true`, no `columns` or `insert_query` | the whole source row, hex-encoded (see notes) |
| envelope, `bytea` | same, table created by the sink instead of `seed.sh` | the whole source row as bytes |
| token `INSERT` | `insert_query=INSERT INTO dst_256 (payload) VALUES (${payload:payload})` | the 256 B payload |
| token `COPY` | the same `insert_query` plus `bulk_copy=true` | the 256 B payload |
| `columns=auto` | `columns=auto` | `id` and the 256 B payload |
**Result.** 1,000,000 rows, 1 warm-up + 3 timed runs, median rows/s (0.4.20):
| Batch | Conc. | envelope | envelope, `bytea` | token `INSERT` | token `COPY` | `columns=auto` |
| ---: | ---: | ---: | ---: | ---: | ---: | ---: |
| 1,024 | 1 | 102,791 | 116,431 | 136,757 | 128,759 | 89,843 |
| 1,024 | 4 | 201,038 | 211,126 | 233,744 | 215,668 | 185,923 |
| 8,192 | 1 | 116,068 | 136,092 | 147,278 | 151,642 | 101,845 |
| 8,192 | 4 | 161,539 | 197,463 | 240,182 | **251,128** | 197,796 |
| 32,768 | 1 | 100,392 | 122,268 | 136,232 | 147,095 | 93,107 |
| 32,768 | 4 | 140,549 | 180,091 | 193,037 | **251,955** | 182,608 |
Postgres on its own, same table and machine, three runs each:
| Path | rows/s |
| --- | ---: |
| `psql -c 'COPY (SELECT payload FROM src_256) TO STDOUT' \| psql -c 'COPY dst_256 (payload) FROM STDIN'`, inside the container | 218,752–253,371 |
| `INSERT INTO dst_256 (payload) SELECT payload FROM src_256` | 308,053–431,848 |
**Notes.**
- **The server is the limit, not the protocol.** The best cell (~252k rows/s) equals the
`psql` COPY pipe. `INSERT … SELECT` never leaves the server, so it marks what no
client can beat; the 413,907 rows/s of scenario 5 is a read into a local file and
not a target for a table sink.
- **Batch size.** 128 → 1,024 is worth ~1.5–1.8x; beyond that multi-row `INSERT` gains
little and loses at 32,768 with concurrency 4. `COPY` is ahead from 8,192 up (3–30%)
and slightly behind below. Which mode and batch size to pick is in the book's
[Performance tuning](../../../../docs/book/operations/tuning.md#writing-to-a-postgres-table) page.
- **The envelope cell writes hex.** Without `columns` or an `insert_query` the sink binds the message
as bytes, for the `payload BYTEA` queue table it creates itself. `seed.sh` creates
`dst_256.payload` as `text`, so Postgres stores `\x7b22…` (~570 characters instead of
256). The sink's own table also carries a second index, on `locked_until`.
- **One table shape only**: a `text` column and a `bigserial` primary key, source and
sink on the same Postgres in the Docker VM. Wide tables were not measured.
### 2 — CDC commit-to-sink latency
**Measures.** How long after a committed Postgres transaction its change event is in
the sink file. The route is `postgres_cdc → file`
([`cdc_latency.yaml`](cdc_latency.yaml)) at `batch_size: 1`.
**Run.**
```bash
benches/etl/run_cdc_latency.sh # -> benches/etl/results/cdc_latency.csv
```
The runner boots the app with `--config cdc_latency.yaml`, then starts the route via
`POST /consumer-start`, the zero-code equivalent of clicking *Start* in the UI.
[`cdc_latency.py`](cdc_latency.py) then commits single-row transactions at a fixed
rate and tails the sink file, timing every event.
**Result.** A 20 s window per cell after a 2 s pre-roll; every committed row must
reach the sink or the cell fails:
| Payload | Commits/s | Events | p50 | p95 | p99 |
| --- | ---: | ---: | ---: | ---: | ---: |
| 256 B | 200 | 4,000 | 0.220 ms | 0.423 ms | 0.577 ms |
| 256 B | 1,000 | 20,000 | 0.163 ms | 0.246 ms | 0.297 ms |
| 4 KiB | 200 | 4,000 | 0.338 ms | 0.579 ms | 0.770 ms |
| 4 KiB | 1,000 | 20,000 | 0.176 ms | 0.281 ms | 0.367 ms |
**A committed row is in the sink file about 0.2 ms later, and within 0.8 ms at p99**,
for both payload sizes.
**Notes.**
- **The clock starts when the client sees the `COMMIT` acknowledged** and stops when
the tailing reader sees the complete line in the sink file. Both timestamps are
taken by the same process on one monotonic clock, so nothing is compared across the
Docker boundary. In between lie WAL decoding in Postgres, the replication stream,
the route and the file write.
- **The commit itself is not included.** It costs 0.3–1.7 ms on this Postgres (median
0.33 ms at 1,000 commits/s, 1.5 ms at 200), which is more than the CDC path adds.
Counted from the moment the `INSERT` is sent, p99 is 1.3–3.7 ms; the results CSV
carries both.
- **The higher rate reads lower** because the route and the replication connection
stay warm between events; at 200 commits/s each event finds them idle.
- **It is an open-loop measurement at a modest rate**, on one machine with Postgres in
a local container. It is not a saturation test and not comparable with a Debezium
figure measured through Kafka across a network. For CDC *throughput*, run `copy`
with a `postgres-cdc://…` source and wall-clock it.
- **Use a permanent replication slot** (`temporary_slot: false`, as in the YAML). A
temporary slot is dropped when its creating connection closes and races the
streaming connection (`replication slot "…" does not exist`). The runner drops the
slot before each run for a clean start.
- **Consumers do not auto-start headless**, and `POST /config` only validates and
saves; it does not start routes. Hence the explicit `POST /consumer-start`.
- **Do not read latency off the `metrics` middleware on an input endpoint.** Its
`queue_message_processing_duration_seconds` there is the time `receive_batch` took,
which is mostly the wait for the next event: it tracks the arrival rate, not the
engine.
### 4 — Local IPC throughput
**Measures.** Sustained rows/s from one process to another over a real Unix domain
socket. A `static` load generator (process A) forwards to a receiver (process B)
through a `memory:` endpoint with a `unix://` topic
([`ipc_sender.yaml`](ipc_sender.yaml) / [`ipc_receiver.yaml`](ipc_receiver.yaml)), so
this is genuine cross-process IPC, not an in-process channel. Both sides run with
`batch_size: 1024, concurrency: 1`.
**Run.**
```bash
benches/etl/run_ipc_throughput.sh # 15 s window -> results/ipc_throughput.csv
RUN_SECONDS=300 benches/etl/run_ipc_throughput.sh # the published 5-minute window
```
**Result. 1,419,025 rows/s**, sustained over a 5-minute window.
**Notes.**
- **How the rate is taken.** Both routes are started via `POST /consumer-start`. After
a 2-second settle window, `rows/s` is the delta of the receiver's own per-batch
transport log (`count=`/`bytes=` from `ipc_unix.rs`) over the sampling window. No
metrics middleware and no metrics crate are involved.
- **Raising `concurrency` does not help.** Both sides talk over a single Unix socket
connection, serialized through one mutex-guarded stream, so extra workers only add
channel-handoff overhead.
### 5 — Postgres → JSONL vs. Sling and Meltano
**Measures.** A one-shot full-table sync of the `bench` table (1,000,000 rows, 7
mixed-type columns: `id, first_name, country, amount, created_at, active, attributes`)
to a local JSONL file. Same Postgres instance, same machine, same job for all three
tools. This is the same dataset scenario 6 reads as CSV.
**Run.** All three tools run through one script (1 warm-up + timed runs each, output
row count verified):
```bash
benches/etl/run_meltano_bench.sh # -> results/meltano_pg_to_jsonl.csv
```
| Tool | Command |
| --- | --- |
| mq-bridge-app | the `mqb copy` below |
| Sling | `sling run --src-conn … --src-stream public.bench --tgt-object file://…` with `{"format":"jsonlines","file_max_rows":0}`, defaults otherwise |
| Meltano | `meltano run tap-postgres target-jsonl` in `benches/etl/meltano_project/bench` (`tap-postgres` selects only `public-bench.*`) |
```bash
mqb copy \
--from 'postgres://testuser:testpass@localhost:5432/testdb?table=bench&cursor_column=id&sslmode=disable' \
--to 'file:///tmp/mqb_bench_out.jsonl?format=raw' \
--drain --batch-size 1024 --concurrency 1
```
**Result.** 3 timed runs per row:
| Tool | Config | rows/s | Median wall-clock | Peak RSS |
| --- | --- | ---: | ---: | ---: |
| mq-bridge-app `copy` | batch 1024, concurrency 1 | **413,907** | 2.416 s ±0.139 | 54.9 MiB |
| mq-bridge-app `copy` | batch 1024, concurrency 4 | **505,050** | 1.98 s | 57.0 MiB |
| Sling | defaults | 102,743 | 9.733 s ±0.174 | 114.1 MiB |
| Meltano (`tap-postgres` → `target-jsonl`) | default Singer config | 8,759 | 114.157 s ±0.282 | 599.7 MiB |
**mq-bridge-app is ~4.0x faster than Sling and ~47x faster than Meltano**, at about
half of Sling's peak memory and ~11x less than Meltano's.
**Notes.**
- **The Sling ratio does not compare equal work.** mq-bridge-app passes values through
untyped here, while Sling does schema inference and type conversion. Part of the
~4.0x is mq-bridge-app doing less. This scenario has no typed column yet; see
[Typed vs. untyped](#typed-vs-untyped-how-to-read-the-sling-ratios).
- **Reseed before quoting.** The figure is measured against a freshly seeded `bench`
table on a freshly created container.
- **Concurrency adds ~22%, not 4x**, because the Postgres read is the limit.
- **No `metrics` middleware is on the path.** The `copy` command never attaches one.
### 6 — CSV → JSONL vs. Sling and Meltano
**Measures.** A one-shot conversion of a 1,000,000-row CSV file to one local JSONL
file, one JSON object per input row. mq-bridge-app is measured **twice**, because the
two configurations do different work and each has a baseline it belongs against:
- **typed** runs a `transform` middleware that reproduces Sling's typing exactly. The
harness fails the run unless all 1,000,000 output records match Sling's. **Read this
column against Sling.**
- **untyped** runs no middleware: every field stays the string the CSV reader
produced, as Meltano's `tap-csv` also emits it. **Read this column against
Meltano.**
**Run.** The fixture is generated on first run if missing:
```bash
benches/etl/run_csv_to_jsonl.sh # all tools -> benches/etl/results/csv_to_jsonl.csv
benches/etl/run_csv_duckdb.sh # DuckDB ceiling, appended to the same results CSV
```
Each tool is also a standalone script, so one number can be re-measured without
re-running the matrix. Each replaces only its own row in the results CSV:
```bash
benches/etl/run_csv_mqb.sh # mq-bridge-app, typed (transform)
benches/etl/run_csv_mqb.sh --untyped # mq-bridge-app, untyped (no middleware)
benches/etl/run_csv_sling.sh
benches/etl/run_csv_meltano.sh
benches/etl/run_csv_parity.sh # asserts typed output == Sling's
```
The two mq-bridge-app configurations differ only in the middleware suffix on the
output endpoint:
```bash
# untyped: every field stays the string the CSV reader produced
mqb copy \
--from 'file:///…/benches/etl/data/bench.csv?format=csv' \
--to 'file:///tmp/mqb_csv_out.jsonl?format=raw' \
--drain --batch-size 1024 --concurrency 1
# typed: reproduces Sling's typing
mqb copy \
--from 'file:///…/benches/etl/data/bench.csv?format=csv' \
--to 'file:///tmp/mqb_csv_out.jsonl?format=raw|transform?schema_file=…/schemas/bench.json' \
--drain --batch-size 1024 --concurrency 1
```
| Tool | Command |
| --- | --- |
| Sling | `sling run --src-stream file://…bench.csv --tgt-object file://…` with `{"format":"jsonlines","file_max_rows":0}`, defaults otherwise |
| Meltano | `meltano run tap-csv target-jsonl` in `meltano_project/bench`. Install the plugin once: `(cd meltano_project/bench && ../.venv/bin/meltano install extractor tap-csv)` |
**Result.**
| Tool | Config | rows/s | Median wall-clock | Runs | Peak RSS |
| --- | --- | ---: | ---: | ---: | ---: |
| mq-bridge-app `copy` (typed) | batch 1024, concurrency 1, `transform` (schemas/bench.json) | **1,626,016** | 0.615 s ±0.043 | 5 | 71.4 MiB |
| mq-bridge-app `copy` (untyped) | batch 1024, concurrency 1, no middleware | **3,134,796** | 0.319 s ±0.004 | 5 | 28.7 MiB |
| Sling | defaults | 111,358 | 8.980 s ±0.003 | 2 | 112.4 MiB |
| Meltano (`tap-csv` → `target-jsonl`) | default Singer config | 9,771 | 102.340 s ±3.520 | 3 | 443.8 MiB |
**Typed vs. Sling: ~14.6x**, equal work. **Untyped vs. Meltano: ~321x**, both emit
strings. Those are the two defensible ratios.
**Notes.**
- **Do not cross the columns.** Quoting the untyped figure against Sling would
overstate the margin (it would read ~28x); quoting the typed figure against Meltano
understates it. See
[Typed vs. untyped](#typed-vs-untyped-how-to-read-the-sling-ratios).
- **The typing cost is explicit.** The transform adds 0.30 s per 1,000,000 rows
(~0.30 µs/row, ~48% of the typed wall-clock).
- **Memory.** The transform's per-row JSON decode and buffering add ~43 MiB. The typed
path is still 41 MiB under Sling and well under Meltano.
- **The number does not scale with batch size or concurrency.** A `file://` source is
a single sequential reader, so extra route workers cannot parallelize the read. The
bottleneck is per-row CSV → JSON conversion, not I/O or batching.
#### Workload definition
This is an existing, independently published 1,000,000-row CSV → JSONL workload
against Meltano's `tap-csv` → `target-jsonl`, not a variant of it. The generator, seed
and row count are unchanged, so the input is **byte-identical** to that fixture. The
SHA-256 is the check: any fixture generated the same way has the same hash.
| Item | Value |
| --- | --- |
| Fixture | `data/bench.csv`, from [`gen_bench_data.py`](gen_bench_data.py) `--rows 1000000 --seed 42` |
| Rows | 1,000,000 data rows + 1 header line |
| Size | 121,981,421 bytes (116.3 MiB) |
| SHA-256 | `a84894e01d4c3fccfe89949ae169cef2e2d755770512bf2fe14e84360c45b221` |
| Columns | `id` (int), `first_name`, `country` (strings), `amount` (float), `created_at` (RFC 3339), `active` (bool), `attributes` (nested JSON) |
| Job | one-shot, full file, CSV → one local JSONL file, one JSON object per input row |
| Transformation | none in the untyped run: every field stays a string, as `tap-csv` also emits it |
| Baseline | Meltano `tap-csv` → `target-jsonl`, default Singer config, same file |
| Timing boundary | wall-clock of the whole process, start to exit: startup, read, parse, serialize, write, flush and close |
| Runs | 1 discarded warm-up, then timed runs; median ± stddev |
| Correctness | every timed run must land exactly 1,000,000 output rows or the run fails |
| Reported | throughput (rows ÷ median wall-clock) and peak RSS |
What is *not* shared with the other published run of this workload is the machine
(here an Apple M1, 8 cores, 8 GB) and the number of timed runs. Absolute rows/s
compare across publications only with that attached; the ratio against Meltano on the
same machine is the portable figure.
#### DuckDB as a ceiling
DuckDB, on the same file in the same session, shows how fast this machine can turn
this CSV into JSON *at all*. It is not an ETL tool and offers none of the delivery
semantics mq-bridge does (no at-least-once, no checkpointing, no arbitrary sinks), so
this is a throughput ceiling, not a product comparison. `all_varchar=true` matches the
untyped mq-bridge column: strings in, strings out. Run with
[`run_csv_duckdb.sh`](run_csv_duckdb.sh); `run_csv_to_jsonl.sh` does not chain it.
| Tool | Threads / cores used | Throughput | Peak RSS |
|---|---|---|---|
| mq-bridge-app `copy`, untyped | `--concurrency 1`, ~2.9 cores | **3,134,796 rows/s** (0.319 s ±0.004) | ~29 MiB |
| DuckDB 1.5.6, all cores (default) | ~4.0 cores | 2,109,704 rows/s (0.474 s ±0.007) | ~522 MiB |
mq-bridge was **~1.49x faster than DuckDB running on all of this machine's cores**,
while using fewer cores and ~18x less memory. Both sides are multi-threaded, so the
core counts belong next to the rates.
### 7 — MCP server: tool-call latency, throughput, token cost
**Measures.** What it costs to drive the engine the way an **LLM agent** does: through
the MCP server, over its real stdio transport. The job is the **same CSV → JSONL work
as scenario 6, on the same dataset**, on purpose. The question is not how fast the MCP
server is in isolation, but whether routing a job through a tool call costs anything
per row compared with the `copy` CLI.
**Run.**
```bash
benches/etl/run_mcp_bench.sh # -> results/mcp_bench.json (+ a row in results/csv_to_jsonl.csv)
REPEATS=3 LATENCY_CALLS=1000 benches/etl/run_mcp_bench.sh
```
[`mcp_bench.py`](mcp_bench.py) is a genuine MCP client. It spawns
`mqb mcp --transport stdio` and speaks JSON-RPC to it exactly as Claude Code would, so
the numbers include the framing, serialization and process-boundary cost an agent
actually pays. Nothing reaches into the process.
**Result.**
| 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 |
**Notes.**
- **The MCP interface costs one round-trip, not a per-row tax.** Client wall-clock is
0.405 s against the CLI's 0.319 s on the same dataset in the same session: a fixed
**~86 ms**, not a rate difference. Most of it is the client's own polling: it asks
`route_status` every 50 ms (`POLL_INTERVAL_S` in `mcp_bench.py`), so completion is
seen 0–50 ms late, ~25 ms on average. The two round trips that bracket the job cost
~0.05 ms each at p50. The comparison is in fact tilted *toward* MCP: the harness
spawns a fresh `mqb copy` process for every CLI run, while the MCP server is spawned
once and reused, so the CLI figure carries process startup the MCP path never pays.
Scale the dataset and the ~86 ms stays put.
- **Run the MCP scenario with enough repeats.** `mcp_bench.py` discards no warm-up.
Every run is timed, including the cold first one (2.08M rows/s here, against
2.44M–2.51M for the five after it), so a short series reads low. Use 6 or more.
- **Ignore the server's own `average_messages_per_second` here.** The route sampler
ticks every 200 ms and the whole job finishes in about two ticks, so that field
quantises: it reported anything from 2.6M to 4.2M rows/s across runs of nearly
identical wall-clock. Client wall-clock is the only meaningful figure at this speed.
- **Agent token cost is flat in the number of rows moved.** This is the one row with
no CLI counterpart. An agent moves the dataset with three tool calls (`start_route`,
one `route_status`, `stop_route`) totalling 1,541 bytes of JSON-RPC. The rows never
enter the model's context, so the same ~1.5 KB moves 1M rows or 1,000; only the
digits of the counters differ. Passing the 116.3 MiB through a context window
instead would cost ~30.5M tokens (~4 bytes/token, an estimate), which no context
window holds at any price.
Methodology notes — so nothing here is quotable out of context
- The throughput number is **client-side wall-clock** from the `start_route` call to
observing `finished: true`, including route startup and completion-detection lag. It
is the pessimistic figure of the two, which is why it is the one quoted.
- The server-side figure is `average_messages_per_second` (total messages over the
span in which they moved), **not** the instantaneous `messages_per_second`, which
decays to ~0 within a second of a drain finishing and cannot describe a completed
job. That distinction is why the average was added to `route_status`; without it a
sub-second-to-few-second job is unmeasurable through the tools.
- The completion poll is 50 ms. At 200 ms the poll lag dominated the wall-clock; at
20 ms the polls measurably perturbed the run they were measuring.
- The **latency** row is `route_status` with nothing running: a map lookup over an
empty map, so what is measured is the interface, not work.
- The **token** row counts only the three calls an agent makes. The harness itself
polls ~8 times (~5 KB); publishing *that* as the agent's cost would be an artifact
of the measurement, not a property of the server.
- `mcp_bench.py` calls `server_info` first and **aborts on a debug build**, so a stale
debug binary cannot silently invalidate a measurement.
- No `metrics` middleware anywhere on the path. The per-route counters `route_status`
reports are the same ones the web UI uses and are not the metrics crate.
### 8 — Postgres → file vs. the tools that ship with Postgres
**Measures.** The same `bench` table written to a local file, against what a Postgres
user **already has installed**. The peer is
**`psql \copy … TO … (FORMAT csv, HEADER true)`**: both sides read the same table and
write the same CSV, so the ratio is meaningful. **`pg_dump`** is in the table as a
floor rather than a peer: it writes a restore format and never decodes a row, so it
marks the cost of getting bytes out of Postgres at all.
**Run.** A host `psql` and `pg_dump` are required (see
[8d](#8d--methodology)):
```bash
benches/etl/seed.sh up && benches/etl/seed.sh bench 1000000
# macOS:
brew install libpq && export PATH="$(brew --prefix libpq)/bin:$PATH"
BATCH=32768 CONC=4 benches/etl/run_pg_vendor.sh # -> results/pg_vendor.csv
```
All of scenario 8 runs at `--batch-size 32768` / `--concurrency 4`, with 1 warm-up and
3 timed runs per cell and a landed-row check on each. `psql \copy` and `pg_dump` are
the libpq 18 clients. Everything, including the read column in 8b, was measured in one
session.
#### 8a — vs. `psql \copy`
| Tool | rows/s | Median | On disk | Writes |
| --- | ---: | ---: | ---: | --- |
| `pg_dump` (floor, not a peer) | 747,943 | 1.337 s | 111 MB | COPY-block SQL script |
| `psql \copy` → csv | 587,199 | 1.703 s | 129 MB | csv |
| mq-bridge-app → csv | 495,540 | 2.018 s | 129 MB | csv |
- **`psql \copy` is ~1.18x faster, and being faster is the expected result.** It is a
byte pump: the server serializes CSV in C inside the backend and psql copies bytes
from the socket to a file, never parsing a row, in one query. mq-bridge-app issues
one keyset query per batch, decodes every value into a typed message, and
re-serializes it: a full decode/encode round trip per row. The two CSVs are
identical (parity-checked by value: 1,000,000 rows equal, 10,091 differing only in
float spelling).
- **The CSV sink is told to keep nested JSON in one cell** (`csv={"nested":"json"}`,
URL-encoded in the target URI). Its default since 0.4.19 flattens a nested object
into `parent.child` columns, which is a different file from the one `\copy` writes.
- **Batch size is a minor lever here.** At `--batch-size 1024` the same job measured
483,505 rows/s (2.068 s ±0.040), with the per-batch query cost paid ~977 times
instead of ~31.
- **What that cost buys** is that the same command targets any other sink (a broker, a
second database, object storage, compressed or encrypted), where `\copy` writes one
local CSV and stops. For a comparison against tools doing the *same* typed-pipeline
work, see scenarios 5 and 6.
#### 8b — Output formats
The same read, written seven ways. The read column is the reverse trip: that file back
in through mq-bridge-app, same parameters, written to a `raw` sink.
| Format | Write rows/s | On disk (bytes) | Read rows/s |
| --- | ---: | ---: | ---: |
| `format=csv` | 495,540 | 128,982,268 | 2,746,007 |
| `format=normal` | 516,262 | 292,982,210 | 2,706,088 |
| `format=json` | 530,503 | 272,980,514 | 3,288,554 |
| `format=text` | 505,305 | 306,982,210 | 2,665,278 |
| `format=raw` | 530,785 | 194,980,514 | 4,839,635 |
| `format=normal&compression=lz4` | 489,476 | 71,195,347 | 1,204,913 |
| `format=normal&compression=zstd` | 511,770 | 44,190,932 | 1,124,402 |
- **Writes are source-bound; reads are not.** All seven write cells land within ~8% of
each other (489,476–530,785) because the Postgres cursor is the limit, not the sink.
The read column, where the same sinks are fed by a file instead, spans **4.3x** and
every cell in it beats the write column. Treat the write column as a floor for the
sink, not a measurement of it; the format only starts to matter once the source
stops being the constraint.
- **`normal` is the interchange format**, the whole message envelope as JSON, and it
is the one to reach for. A UTF-8 payload is written as a plain string; only a
non-UTF-8 payload is base64-encoded, into a separate `payload_base64` field
(mutually exclusive with `payload`, mirroring the CloudEvents `data`/`data_base64`
split). `text` (payload as a string) and `json` (payload as a JSON value) carry the
same envelope including `message_id`. `raw` writes the payload alone: smallest and
by far the fastest to read, but no envelope, so no `message_id`.
- **`normal` is smaller than `text`** (293 MB against 307 MB), so `text` is the right
choice only when the consumer requires the payload as an opaque string field.
- **CSV cannot be compressed.** The endpoint rejects the combination, so the
compressed cells use `normal`, which is the right pairing anyway.
- **zstd for size, lz4 for speed.** zstd is **1.6x smaller** (44.2 MB against 71.2 MB;
6.6x smaller than uncompressed `normal`, and *2.9x smaller than the CSV* while
carrying strictly more). On this source-bound write the two are within noise of each
other (511,770 vs. 489,476); on read lz4 is ~7% faster. zstd is the default
recommendation: 1.6x on disk is worth a few percent of CPU for almost any at-rest or
transfer use. Pick lz4 when the pipeline is CPU-bound and the bytes are transient.
#### 8c — Round trip
Does the interchange format survive a full trip out and back? The zstd file from 8b is
read in again, written out as uncompressed `normal`, and compared byte-for-byte
against the *same* file decoded by the external `zstd` CLI.
| | |
| --- | --- |
| Records restored | 1,000,000 |
| Elapsed | 0.941 s |
| Result | byte-identical, **including `message_id`** |
- **Compare against that same file, not a re-read.** Comparing the restore against a
separately written `normal` file would be wrong, and is a mistake worth naming: that
file is a different read of Postgres, and Postgres rows carry no `message_id`, so
the sink mints a fresh one per ingestion. Two independent reads disagree on
`message_id` by construction, which looks exactly like "the sink regenerates ids on
write" — a bug that is not there.
- **Row counts come from the external `zstd`/`lz4` CLIs**, not from mq-bridge.
Verifying a writer with its own reader would hide a bug in both, and this doubles as
a check that the concatenated members decode as one stream.
- **Restore does not speed up with `--concurrency`.** The file source does not
parallelize: one reader thread owns decode, delimiter splitting and message
construction. That is also why the read column in 8b is flat in `--concurrency`.
#### 8d — Methodology
Methodology notes for all of scenario 8 — the host-client guard, pg_dump, and how parity is checked
- **This scenario refuses to run without a host `psql` and `pg_dump`**, and says why,
unlike every other scenario. `lib.sh` otherwise falls back to the client inside the
compose container, which would invalidate the comparison twice over: `\copy` writes
client-side, so the CSV would land on container overlayfs instead of host disk, and
the client would reach the server over a container-local socket rather than the TCP
connection mq-bridge-app uses. Different disk, different network path: a faster
number that is not a comparison. The guard exists because that failure is silent.
- **`pg_dump` is published as a floor, not as a peer.** A dump is a *restore format*
(a COPY-block SQL script, not interchange data), and it skips the `ORDER BY` this
comparison needs, skips CSV quoting, and writes less (111 MB against 129 MB). So it
is not a head-to-head that anyone won or lost: it is the cost of getting bytes out
of Postgres at all, and therefore the bound no extraction tool can beat. Read the
row that way. `mongodump` is the same category; `mongoexport --type=json` would be
the fair Mongo peer and is not wired up yet.
- **Parity is checked by value, not by bytes.** The two outputs are not byte-identical
and should not be: a `double precision` holding a whole number renders as `7535.0`
from the sink (it serializes an f64) and as `7535` from Postgres — 10,091 of
1,000,000 rows. The runner compares numeric fields as numbers and everything else
exactly, and reports `1000000 rows equal`. Casting the baseline with `to_char`, or
reseeding to avoid whole-valued floats, would make a byte comparison pass by shaping
the data to fit the claim.
### 9 — Kafka → file vs. Arroyo and Sea Streamer
**Measures.** How fast a four-partition, 1,000,000-row Kafka topic lands in a local
file. The topic holds the usual seven-column JSON row and is filled from the same
`bench` table. These are two deliberately narrow streaming comparisons, not headline
ETL scenarios: 9a against a stream processor, 9b against a streaming library.
**Run.**
```bash
docker compose -f benches/etl/docker-compose.kafka.yml up -d --wait # Kafka + Arroyo
benches/etl/seed.sh up && benches/etl/seed.sh bench 1000000
benches/etl/run_kafka_stream.sh seed # fill the topic
export MQB_CONSUMER_OPTIONS='[["fetch.queue.backoff.ms","10"]]'
benches/etl/run_kafka_stream.sh mqb # passthrough + projection
benches/etl/run_kafka_stream.sh arroyo
benches/etl/run_kafka_stream.sh sea # mq-bridge-app format=normal + Sea Streamer
```
All tools share one stopwatch ([`stream_bench.py`](stream_bench.py)): start the job
and poll the sink until all 1,000,000 rows have landed. Batch 1024, four-way
parallelism, topic seeded with `linger.ms=1`.
> **The mq-bridge-app rows set one consumer option:
> `consumer_options: [["fetch.queue.backoff.ms", "10"]]`**, through
> `MQB_CONSUMER_OPTIONS`. With librdkafka's default (1000 ms) the consumer pauses
> fetching for a full second whenever its local queue fills. The same cells then
> measured 139,119 rows/s (9a passthrough) and 126,633 rows/s (9a projection) in the
> container, and 326,828 rows/s in 9b.
#### 9a — Kafka → JSONL vs. Arroyo
Both sides project the same four columns (`id`, `first_name`, `country`, `amount`) and
write newline-delimited JSON. Arroyo is `ghcr.io/arroyosystems/arroyo:0.15.0`.
| Tool | Median wall-clock | Throughput | Startup | Peak RSS |
| ---- | ----------------: | ---------: | ------: | -------: |
| mq-bridge-app passthrough (no transform) | 1.830 s | 546,337 rows/s | 0.145 s | 233 MiB |
| mq-bridge-app projection (+ `transform`) | 1.669 s | 599,031 rows/s | 0.123 s | 186 MiB |
| Arroyo projection | 1.764 s | 566,991 rows/s | 0.548 s | 362 MiB |
The two projection rows are within noise of each other (~6%); mq-bridge-app uses about
half the memory.
- **Both tools run as containers here**, in the same Docker VM (4 CPUs), reaching the
broker over the compose network and writing to the same named volume. mq-bridge-app
is the published `ghcr.io/marcomq/mq-bridge-app:0.4.19` image
(`stream_bench.py --tool mqb-docker`), so this table is a **0.4.19** measurement.
- **Runs.** mq-bridge-app: 1 warm-up + 3 timed runs. Arroyo: 1 warm-up + 5 timed runs.
Startup (container start for mq-bridge-app, pipeline scheduling for Arroyo) is
reported separately. Both projected sinks are 65,615,161 bytes.
- **The delivery guarantees are not equivalent.** Arroyo provides **exactly-once
processing within its checkpointed pipeline**; mq-bridge-app provides
**at-least-once delivery** for the Kafka route. It resumes from the source's
committed consumer offset, so a failure can replay records. The `transform`
measurement does not add deduplication or upgrade that guarantee. Arroyo's stateful
features are intentionally not exercised.
#### 9b — Kafka → file vs. Sea Streamer
Both tools relay the original Kafka payload without a transform. mq-bridge-app writes
its default `format=normal` file encoding: a JSON `CanonicalMessage` envelope per
record. Sea Streamer writes its native framed and indexed `.ss` file, using the pinned
official `0.5.2` crates.
| Tool | Median wall-clock | Throughput | Peak RSS | Sink bytes |
| ---- | ----------------: | ---------: | -------: | ---------: |
| mq-bridge-app (`format=normal`, mimalloc) | 1.139 s | 878,105 rows/s | 160 MiB | 363,982,210 |
| Sea Streamer 0.5.2 relay (native `.ss`, system allocator) | 2.067 s | 483,800 rows/s | 726 MiB | 230,989,222 |
| Sea Streamer 0.5.2 relay (native `.ss`, mimalloc) | 2.031 s | 492,465 rows/s | 894 MiB | 230,989,222 |
mq-bridge-app is **1.82x faster** than Sea Streamer's default-allocator result and
**1.78x faster** than its mimalloc result.
- **Both tools run on the host here**, each row 1 warm-up + 3 timed runs.
- **The two file formats are not byte-for-byte equivalent.** The data supports a
Kafka-to-file throughput comparison, not a claim of identical sink encoding,
delivery, or checkpoint semantics.
- **Row counts are verified externally.** Each Sea Streamer run is checked with
`sea-streamer-count` to contain exactly 1,000,000 messages.
- **The backoff option applies to mq-bridge-app only**, because Sea Streamer's relay
exposes no librdkafka options.
- **The mimalloc row is an application-level allocator measurement**, not a Sea
Streamer crate feature.
The Sea Streamer helper is committed in [`benches/etl/sea_streamer`](sea_streamer). It
contains the relay and count programs and pins the `0.5.2` dependencies:
```bash
cargo build --manifest-path benches/etl/sea_streamer/Cargo.toml \
--release --target-dir target
```
For the allocator row, rebuild the same helper in the same target directory with
`--features mimalloc`, then run
`SEA_STREAMER_LABEL=sea-streamer-mimalloc REPEATS=3 ./benches/etl/run_kafka_stream.sh sea`.
### 10 — Redpanda Connect, and the Connect plugin
**Measures.** Two jobs this page already has, run against
[Redpanda Connect](https://github.com/redpanda-data/connect) 4.112.0, and run again
with Redpanda Connect components *inside* mq-bridge-app through the
[Connect plugin](../../../../docs/book/connectors/connect.md) (mq-bridge-connect
0.1.1). Each table therefore answers two questions: how mq-bridge-app compares with
Redpanda Connect, and what a route pays for using a Connect component instead of a
native endpoint or the native `transform`.
All rows of one table were measured in one session, the native rows included, so the
native figures here are a few percent off the headline ones in scenarios 6 and 9.
Compare within a table.
#### 10a — CSV → JSONL
The scenario 6 job on the same fixture. "Typed" is the scenario 6 typing (`id` to an
integer, `attributes` decoded into an object); on the Connect side it is this Bloblang
mapping:
```coffee
root = this
root.id = this.id.int64()
root.attributes = this.attributes.parse_json()
```
**Run.**
```bash
benches/etl/run_csv_mqb.sh && benches/etl/run_csv_mqb.sh --untyped # native rows
benches/etl/run_csv_connect.sh # Redpanda Connect + the plugin rows
benches/etl/run_csv_connect.sh parity # typed outputs == mq-bridge-app's typed output
```
Redpanda Connect runs [`connect/csv_untyped.yaml`](connect/csv_untyped.yaml) and
[`connect/csv_typed.yaml`](connect/csv_typed.yaml): a `file` input with the `csv`
scanner, the mapping, a `file` output, defaults otherwise. The plugin rows are `mqb
copy` with `--plugin`:
```bash
# native endpoints, Bloblang as a middleware
mqb copy --plugin …/libmq_bridge_connect.dylib \
--from 'file:///…/bench.csv?format=csv' \
--to 'file:///tmp/out.jsonl?format=raw|connect_mapping?mapping=' \
--drain --batch-size 1024 --concurrency 1
# Connect `file` input and output as the endpoints
mqb copy --plugin …/libmq_bridge_connect.dylib \
--from 'connect://?yaml=' \
--to 'connect://?yaml=' \
--drain --batch-size 1024 --concurrency 1
```
**Result.** 1 warm-up + 3 timed runs per row. `connect_mapping` is not part of
mq-bridge: it is Redpanda Connect's `mapping` processor, provided by the Connect
plugin. Every row marked **Connect plugin** needs the plugin loaded.
| Tool | Endpoints | Typing | rows/s | Median wall-clock | Peak RSS |
| --- | --- | --- | ---: | ---: | ---: |
| mq-bridge-app | native | none | **2,967,359** | 0.337 s ±0.008 | 30.6 MiB |
| mq-bridge-app | **Connect plugin** | none | 53,050 | 18.850 s ±0.195 | 137.1 MiB |
| Redpanda Connect | its own | none | 97,885 | 10.216 s ±0.113 | 171.4 MiB |
| mq-bridge-app | native | native `transform` (no plugin) | **1,594,896** | 0.627 s ±0.003 | 74.0 MiB |
| mq-bridge-app | native | **Connect plugin** middleware (`connect_mapping`, Bloblang) | 142,734 | 7.006 s ±0.019 | 218.4 MiB |
| mq-bridge-app | **Connect plugin** | **Connect plugin** (mapping in the input's `pipeline`) | 45,077 | 22.184 s ±0.464 | 138.5 MiB |
| Redpanda Connect | its own | mapping | 81,893 | 12.211 s ±0.182 | 171.2 MiB |
**Native mq-bridge-app is ~19.5x faster than Redpanda Connect typed and ~30x
untyped.** All four typed outputs hold the same 1,000,000 records.
**Notes.**
- **A Connect component costs one hop into Go per message, and that hop is the
price.** The same typing is ~11x slower as `connect_mapping` than as the native
`transform` (142,734 against 1,594,896), though still ~1.7x faster than Redpanda
Connect running the same mapping, because the read and the write stay native.
- **With Connect components at both ends the route is slower than Redpanda Connect
itself** (~1.85x): every message crosses the plugin boundary twice and Redpanda
Connect's own stream does not. This is why the book says to prefer a native
connector where one exists and to keep the plugin for systems mq-bridge has no
endpoint for.
- **Parity is checked after sorting by `id`.** Redpanda Connect runs its pipeline on
several threads, and the plugin keeps up to 64 batches in flight, so neither
preserves the input order. Records are compared as parsed JSON, as in scenario 6.
- **Redpanda Connect runs at its defaults**, like Sling and Meltano in scenario 6. Its
`file` output writes message by message; no batching was added on either side of
it.
- **Peak RSS is from a separate single run** per row under `/usr/bin/time -l`.
#### 10b — Kafka → JSONL
The scenario 9 job: the same four-partition, 1,000,000-row topic, the same stopwatch
([`stream_bench.py`](stream_bench.py)), the same four-column projection as 9a. The
Connect plugin links no Kafka component (see the book's
[licensing note](../../../../docs/book/connectors/connect.md#what-is-not-included)),
so the plugin row is the native Kafka source with the projection as a
`connect_mapping` middleware.
**Run.** Setup and seeding as in scenario 9, then:
```bash
export MQB_CONSUMER_OPTIONS='[["fetch.queue.backoff.ms","10"]]'
benches/etl/run_kafka_stream.sh mqb # native passthrough + projection
benches/etl/run_kafka_stream.sh connect # Redpanda Connect + the Bloblang middleware
```
**Result.** All tools on the host. Native rows: 1 warm-up + 5 timed runs; the others
1 warm-up + 3.
| Tool | Projection | Median wall-clock | Throughput | Peak RSS |
| ---- | ---------- | ----------------: | ---------: | -------: |
| mq-bridge-app | none (passthrough) | 1.080 s | **925,972 rows/s** | 136 MiB |
| Redpanda Connect | none (passthrough) | 9.792 s | 102,123 rows/s | 262 MiB |
| mq-bridge-app | native `transform` (no plugin) | 1.305 s | **766,280 rows/s** | 174 MiB |
| mq-bridge-app | **Connect plugin** middleware (`connect_mapping`, Bloblang) | 6.149 s | 162,618 rows/s | 292 MiB |
| Redpanda Connect | mapping | 15.211 s | 65,741 rows/s | 382 MiB |
**mq-bridge-app is ~9.1x faster on the passthrough and ~11.7x on the projection.**
Every passthrough sink is 194,980,514 bytes and every projected sink 65,615,161.
**Notes.**
- **Redpanda Connect's input is not at its defaults here, in its favour.** It is the
`redpanda` input with `unordered_processing` enabled and `batching.count: 1024`,
the batch size mq-bridge-app uses. With the ordered default the same job stalled for
seconds at a time and had not landed the topic after 40 s. This mirrors the
`fetch.queue.backoff.ms` option set on the mq-bridge-app side.
- **The plugin hop costs less here than in 10a** (~4.7x against the native
`transform` instead of ~11x) because the Kafka read, not the mapping, carries more
of the wall-clock. It is still ~2.5x faster than Redpanda Connect end to end.
- **Delivery is at-least-once on both sides**: each resumes from the committed
consumer-group offset.
- **The calibration window is 8 s for Redpanda Connect**, not the usual 2 s. Its
fetches can pause mid-backlog for longer than 2 s, which the harness would otherwise
take for the end of the data and fail on the row count.
### 11 — Vector
**Measures.** The same two jobs as scenario 10, and a topic-to-topic copy, against
[Vector](https://vector.dev) 0.59.0: a single Rust binary that, like mq-bridge-app,
runs a source → transform → sink pipeline from a config file. As in scenario 10, every
row of a table was measured in one session, so compare within a table.
**Read the JSONL figures (11a, 11b) with this attached: Vector's file sink is the
limit, not its engine.** All four of those cells land at ~57,000 rows/s whatever work
they do.
The same Kafka source into Vector's `blackhole` sink read the whole topic in about
four seconds, and a `console` sink redirected to a file was no faster than the `file`
sink. Vector is built to ship logs and metrics to network sinks; writing a local
JSONL file is not what it is tuned for. Those rows say that mq-bridge-app is much
faster *at that job*, not that its engine is 12–50x faster than Vector's. **11c is the
engine comparison**: the same source into a Kafka sink, where the gap is ~1.8x.
#### 11a — CSV → JSONL
The scenario 6 job on the same fixture. Vector has no CSV codec and its `file` source
is a tailer that never ends, so the file arrives on stdin and a `remap` parses each
line ([`vector/csv_untyped.yaml`](vector/csv_untyped.yaml),
[`vector/csv_typed.yaml`](vector/csv_typed.yaml)); Vector exits at end of input.
**Run.**
```bash
benches/etl/run_csv_mqb.sh && benches/etl/run_csv_mqb.sh --untyped # native rows
benches/etl/run_csv_vector.sh # vector-untyped, vector (typed)
benches/etl/run_csv_vector.sh parity # both outputs == mq-bridge-app's
```
**Result.** 1 warm-up + 3 timed runs per row:
| Tool | Typing | rows/s | Median wall-clock | Peak RSS |
| --- | --- | ---: | ---: | ---: |
| mq-bridge-app | none | **2,915,451** | 0.343 s ±0.009 | 30.6 MiB |
| Vector | none (`remap` with `parse_csv`) | 57,823 | 17.294 s ±0.083 | 158.5 MiB |
| mq-bridge-app | native `transform` | **1,564,945** | 0.639 s ±0.082 | 74.0 MiB |
| Vector | `remap` (`parse_csv`, `to_int`, `parse_json`) | 56,670 | 17.646 s ±0.094 | 168.0 MiB |
**mq-bridge-app is ~27.6x faster typed and ~50x untyped.** Both Vector outputs hold
the same 1,000,000 records as the matching mq-bridge-app output.
**Notes.**
- **Typing costs Vector 2%**, where it halves mq-bridge-app's rate. That is the sink
bound showing: the `remap` is not what Vector is waiting on.
- **Parity is checked after sorting by `id`**, because Vector runs `remap`
concurrently and does not keep the input order.
- **The fixture has CRLF line ends**, which Vector's stdin source keeps on the line;
the `remap` strips them. Without that every row fails to parse and is dropped
without a log line.
- **The config carries literal paths.** A sink path taken from an environment variable
(`path: ${OUT}`) wrote no file on Vector 0.59.0, so the runner fills in the paths.
- **mq-bridge-app's peak RSS is the 10a measurement** (same build, same job); Vector's
is a separate single run under `/usr/bin/time -l`.
#### 11b — Kafka → JSONL
The scenario 9 job with the scenario 9 stopwatch and projection. Vector's `kafka`
source is librdkafka, as mq-bridge-app's is, and gets the same
`fetch.queue.backoff.ms: 10` through `librdkafka_options`.
**Run.** Setup and seeding as in scenario 9, then:
```bash
export MQB_CONSUMER_OPTIONS='[["fetch.queue.backoff.ms","10"]]'
benches/etl/run_kafka_stream.sh mqb # native passthrough + projection
benches/etl/run_kafka_stream.sh vector # passthrough + remap projection
```
**Result.** All on the host, 1 warm-up + 3 timed runs per row:
| Tool | Projection | Median wall-clock | Throughput | Peak RSS |
| ---- | ---------- | ----------------: | ---------: | -------: |
| mq-bridge-app | none (passthrough) | 1.082 s | **924,485 rows/s** | 126 MiB |
| Vector | none (passthrough) | 17.203 s | 58,130 rows/s | 335 MiB |
| mq-bridge-app | native `transform` | 1.384 s | **722,337 rows/s** | 179 MiB |
| Vector | `remap` | 17.412 s | 57,431 rows/s | 346 MiB |
**mq-bridge-app is ~15.9x faster on the passthrough and ~12.6x on the projection.**
Every passthrough sink is 194,980,514 bytes and every projected sink 65,615,161.
**Notes.**
- **The passthrough and the projection cost Vector the same**, for the reason given at
the top of this scenario.
- **Delivery is at-least-once on both sides.**
#### 11c — Kafka → Kafka
The 11b source into a Kafka topic instead of a file: a job Vector is commonly deployed
for, and one that does not go through its file sink. Each run writes to a fresh
4-partition topic on the same broker; the stopwatch stops when that topic's end
offsets add up to the 1,000,000 source records.
**Run.** Setup and seeding as in scenario 9. The harness reads the end offsets with
`kafka-python`, which the runner pulls in through `uv`:
```bash
export MQB_CONSUMER_OPTIONS='[["fetch.queue.backoff.ms","10"]]'
benches/etl/run_kafka_stream.sh kafka-sink # both tools, passthrough + projection
```
**Result.** All on the host, 1 warm-up + 3 timed runs per row:
| Tool | Projection | Median wall-clock | Throughput | Peak RSS |
| ---- | ---------- | ----------------: | ---------: | -------: |
| mq-bridge-app | none (passthrough) | 3.553 s ±0.238 | **281,489 rows/s** | 180 MiB |
| Vector | none (passthrough) | 6.476 s ±0.066 | 154,414 rows/s | 337 MiB |
| mq-bridge-app | native `transform` | 3.827 s ±0.203 | **261,322 rows/s** | 184 MiB |
| Vector | `remap` | 6.856 s ±0.347 | 145,852 rows/s | 352 MiB |
**mq-bridge-app is ~1.8x faster on both, at about half the memory.**
**Notes.**
- **Producer settings are each tool's defaults.** Both are librdkafka with `acks=all`
and no compression; mq-bridge-app adds idempotence and lingers 1 ms, Vector 5 ms.
- **Only the record count is asserted** (exactly 1,000,000 in the destination topic on
every run); the destination records are not compared with each other.
- **Both sides are producing to a single broker in a Docker VM**, and the clock
includes process start and the consumer group join, which weigh more on a 3.5 s run
than on a 17 s one. Treat ~1.8x as the size of the gap, not as two digits.
## Typed vs. untyped: how to read the Sling ratios
Sling does schema inference and type conversion: given a CSV row it emits typed values
and re-parses nested JSON into real objects. mq-bridge-app's readers do not infer
types; they pass values through as strings. Comparing those two directly would time a
string passthrough against a tool that parses and re-types every row, which is not a
benchmark. The two Sling scenarios handle this differently:
| Scenario | mq-bridge-app does | Sling does | Ratio | Like-for-like? |
| --- | --- | --- | --- | --- |
| 6, typed column | types via `transform` | types | ~14.6x | **yes**, outputs asserted identical |
| 6, untyped column | passes strings through | types | ~28x | **no**, do not quote; this column belongs against Meltano |
| 5 | passes values through | types | ~4.0x | **no**, not yet equalised |
**Scenario 6 (CSV → JSONL): equal work.** The typed run carries a `transform`
middleware ([`schemas/bench.json`](schemas/bench.json)) that reproduces Sling's output
exactly: `coerce` widens the `id` string to an integer, and
`contentMediaType: application/json` decodes the embedded `attributes` document into a
nested object. Both tools then emit the same records:
```jsonc
// sling (defaults) and mq-bridge-app (+ transform) — identical records
{"id":1,"amount":"6767.32","attributes":{"score":2.501,"tier":"free"}}
```
This is asserted, not assumed. [`compare_jsonl.py`](compare_jsonl.py) diffs the two
outputs record-by-record and **fails the run** on any mismatch, so the typed number
cannot be published unless all 1,000,000 rows match. Records are compared as parsed
JSON: mq-bridge-app preserves the source column order inside `attributes` while Sling
alphabetizes it, which is a serialization difference, not a data one.
Making the work equal is not free, and the harness measures the cost directly by
running mq-bridge-app both ways in the same session: **3,134,796 untyped → 1,626,016
typed**, so the transform costs ~0.30 µs/row (~48% of wall-clock). The margin against
Sling is therefore ~14.6x, not the ~28x the untyped run would suggest.
Both configurations stay published on purpose. The untyped number is the correct
comparison against any tool that also does no transformation (Meltano's `tap-csv`
emits every field as a string), and it is what makes the transform's cost auditable
instead of baked invisibly into one figure. What it must *not* be used for is a
comparison against a type-inferring tool.
**Scenario 5 (Postgres → JSONL): not yet equalised.** That scenario runs mq-bridge-app
untyped against a type-inferring Sling, so part of its ~4.0x gap is mq-bridge-app
doing less. Read it with that attached. Applying the same treatment there needs a
Postgres-shaped schema (the driver already returns typed values for some columns, so
it is not a copy of `bench.json`) and a re-run.
## Published benchmarks these line up against
- **Debezium**: Postgres CDC latency and throughput → scenario 2.
- **OpenMessaging Benchmark**: payload sizes and latency-percentile reporting →
scenarios 1 & 3.
- **Airbyte**: records/s for a full-table sync → scenario 1.