End-to-end Latency (iso-rate) — exactly-once
The stateless filter + enrich app of every product, fed the SAME modest rate every product keeps up with, with a probe consumer tailing the output topic: latency = record published − gendata's timestamp (p50 / p99 / p99.9). Prices each product's batching / commit / epoch model rather than its ceiling — the regime where a serving or alerting pipeline actually runs. Only meaningful in the keep-up regime.
Last updated: 2026-11-10 · same-machine, relative comparison.
flowchart LR
src[("bench-input<br/>1 partition")]
s0[\"<b>filter</b><br/>region = eu-*"/]
s1["<b>map</b><br/>latency_ratio, sla_status"]
sink[("bench-out")]
probe>"latency probe<br/>arrival − timestamp"]
src --> s0
s0 --> s1
s1 -->|"exactly-once"| sink
sink -.-> probe
Latency (end-to-end — record published minus gendata's timestamp)
| product | regime | p50 ms | p90 ms | p99 ms | p99.9 ms | max ms | records | setup floor p50 / p99 ms | added p50 / p99 ms |
|---|---|---|---|---|---|---|---|---|---|
| turbine-eos 0.3.5a1 | keep-up | 131.9 | 215.8 | 233.6 | 239.2 | 240.5 | 1147515 | 106.4 / 204.6 | 25.4 / 29.0 |
| arroyo-eos 0.15.0 | keep-up | 983.0 | 1,552.9 | 1,698.8 | 1,850.3 | 1,852.1 | 1532591 | 106.4 / 204.6 | 876.6 / 1,494.2 |
| flink-eos 2.2.1 | keep-up | 121.2 | 207.0 | 225.1 | 229.1 | 232.3 | 1468634 | 106.4 / 204.6 | 14.9 / 20.4 |
| quix-eos 3.23.6 | keep-up | 161.6 | 263.9 | 315.4 | 320.5 | 323.7 | 1440689 | 106.1 / 204.5 | 55.5 / 110.8 |
How to read this table
Measured by a probe consumer tailing the output topic (C client, 10 ms max fetch wait — the probe adds ≲10 ms, the same for every product). Only shown for keep-up runs: under saturation the figure is the backlog's age and grows without bound. setup floor is a harness producer writing one small record every 10 ms (acks=all, 5 ms linger) to its own topic during the same run, measured by the same probe: the broker's produce→visible latency at the products' output shape (on Redpanda, small produces surface on its flush cadence), which every product inherits. added = product − floor, the part the product is responsible for (fetch, batching, commit/epoch cadence, produce). The floor is a low-volume topic's (100 small records/s, surfacing on Redpanda's 100 ms flush.ms): an output topic carrying several MB/s — every record scored and republished — surfaces sooner: a percentile under the floor's reads below, added does not apply. Colours: each percentile against the best (lowest) of its column — green under 2×, yellow from 2×, orange from 5×, red from 10× (the ratio on hover).
Bench characteristics
In-process computation — Same as stateless (filter eu-*, derive latency_ratio + SLA flag); the output additionally carries gendata's timestamp for the probe.
| product | delivery | processing | engine | scaling on one partition | decode |
|---|---|---|---|---|---|
| turbine-eos | exactly-once (Kafka transaction per batch; crash recovery: replay) | batch (Arrow columnar; 10k rows / 10 ms) | in-process · Rust core + free-threaded CPython 3.14t | intra-partition shards (parallelism=N, hash of partition_key, in-process) |
schema projection (6 of 19 fields, Arrow) |
| arroyo-eos | exactly-once (checkpoints every 1 s; Kafka transaction per checkpoint, 2PC sink) | batch (vectorized SQL, Arrow / DataFusion; source batches of 10k rows / 10 ms) | in-process · Rust (arroyo run: controller + one worker process) |
none for a per-record job (chained into the subtask reading the partition) | schema projection (5 of 19 fields) |
| flink-eos | exactly-once (EXACTLY_ONCE checkpoints every 10 ms; Kafka transaction per checkpoint, 2PC sink) | batch (pipelined, JVM code-gen SQL) | in-process · JVM (local MiniCluster; Python only submits the job) | rebalance to N slots (parallelism.default, in-process local channels; source stays 1 subtask) |
schema projection (5 of 19 fields, Jackson); connector-default fetch |
| quix-eos | exactly-once (Kafka transaction per commit, every 10 ms) | row (per-record StreamingDataFrame) | in-process · pure-Python over librdkafka (C client) | none (one consumer per partition, GIL) | full record (19 of 19 fields, orjson dict) |
Input is a single Kafka partition (lz4). Each product may spread it over several cores through its own in-process mechanism (column scaling on one partition; what it actually used is cpu_avg); an in-process exchange is not a broker hop — the extra rec/rec column counts records that DID cross the broker on other topics. Every line runs exactly-once: its output becomes visible to a read_committed consumer at its transaction's commit (column delivery).
Consumer fetch: Turbine's core hard-codes librdkafka fetch.min.bytes=64 KB, fetch.wait.max.ms=10, max.partition.fetch.bytes=50 MB and a 500k-msg / ~2 GB prefetch queue (not adjustable), so the SAME client gets the SAME settings: quix and RisingWave (librdkafka) run with them, and so does FastStream (aiokafka, whose equivalent knobs lifted it ~14%). Flink runs at its Java connector's defaults: an A/B (2026-09-10) showed the identical numbers throttled the Java consumer (373k/s vs 543k/s at the same ~285k rows/s per core) — identical numbers are not identical behaviour on a different client. Memory at saturation is mostly this prefetch queue, for every librdkafka product.
Throughput / efficiency (live) — capped by the machine: gendata produces ~430k/s at most, so read capacity in the drain table
| product | offered/s | consumed/s | regime (lag drift) | cpu_avg % | rows/s per core | rows/s per GB | µs CPU / record | output/s | extra rec/rec | run |
|---|---|---|---|---|---|---|---|---|---|---|
| turbine-eos 0.3.5a1 | 19,782 | 19,782 | keep-up (+0.0%) | 11.5 | 172,635 | 91,487 | 5.79 | 5,698 | 0 | 2026-11-10 |
| arroyo-eos 0.15.0 | 19,776 | 20,172 | keep-up (-2.0%) | 9.3 | 216,179 | 40,185 | 4.63 | 8,576 | 0 | 2026-11-08 |
| flink-eos 2.2.1 | 19,771 | 19,767 | keep-up (+0.0%) | 41.5 | 47,613 | 20,787 | 21.00 | 7,504 | 0 | 2026-11-08 |
| quix-eos 3.23.6 | 19,790 | 19,761 | keep-up (+0.2%) | 18.4 | 107,199 | 200,286 | 9.33 | 8,163 | 0 | 2026-11-08 |
How to read this table
consumed/s is what the product actually drained (its consumer-group offset or engine counter), offered/s what gendata produced; a dotted underline marks the median of a repeated batch — hover it for the run count and the min – max. regime (lag drift): keep-up = consumed ≈ offered with a flat backlog (a sustainable point); saturated = fed beyond capacity, so consumed/s IS the ceiling and latency is unbounded; draining = still eating warmup backlog (consumed > offered; CPU is catch-up, not steady state — lengthen the warmup, or raise the rate if the ceiling is what you want). The lag drift in parentheses = backlog change over the window ÷ records consumed — the evidence for the regime (≈ 0 at keep-up, positive when saturated, negative while draining). cpu_avg is the whole process tree, 100 = one core busy. rows/s per core = consumed ÷ (cpu_avg/100); rows/s per GB = consumed ÷ memory; extra rec/rec = records written to topics other than the input and output per record consumed (0 = no broker hop).
Cost of exactly-once
| line | vs | regime | rows/s per core | µs CPU / record | consumed/s | latency p50 ms | measured |
|---|---|---|---|---|---|---|---|
| turbine-eos | turbine | keep-up → keep-up | -14.3% | 4.96 → 5.79 | - | 123 → 132 | interleaved |
| arroyo-eos | arroyo | keep-up → keep-up | -19.6% | 3.72 → 4.63 | - | 154 → 983 | interleaved · ≠ config |
| flink-eos | flink | keep-up → keep-up | -66.6% | 7.02 → 21.00 | - | 113 → 121 | interleaved · ≠ config |
| quix-eos | quix | keep-up → keep-up | -3.3% | 9.02 → 9.33 | - | 123 → 162 | interleaved · ≠ config |
How to read this table
Each exactly-once line against its at-least-once line — the same product, workload, mode and cores, the newest batch of each. rows/s per core: the change in efficiency (the inverse of µs CPU per record, shown at-least-once → exactly-once); consumed/s: the change in capacity, only when both runs were fed beyond it (drain or saturated — at a keep-up point it is the offered rate); the latency p50 when both kept up — an exactly-once line's output becomes visible to the probe, a read_committed consumer, at its commit (a batch's transaction for Turbine, a checkpoint's for Flink and Arroyo, a commit's for Quix: the interval is in the characteristics' delivery and processing columns — on the latency bench every exactly-once line commits at Turbine's batch timeout, a transaction per batch, the others through BENCH_COMMIT_INTERVAL_MS (Arroyo every 1 s, the shortest checkpoint interval it accepts), which their at-least-once lines, publishing without waiting for a commit, do not set: ≠ config there). measured: interleaved = both lines in one interleaved batch (one host, run by run); separately = two sessions, the difference then carrying the host-to-host spread too (a few %; a Flink VM once read 30 % slower, before the session gate). · ≠ config / ≠ host / ≠ bench: the two runs differ there too — read the figures with it. Output records/s read from offsets include one transaction marker per transaction that wrote to the output — noise on the per-record workloads (≲ 1 per 10k records), up to ~1 in 16 panes on windowed-lowcard's 0.53 panes/s.
Resources (raw)
| product | cpu_avg % | cpu_p95 % | mem_avg MB | mem_max MB | samples | instrument |
|---|---|---|---|---|---|---|
| turbine-eos 0.3.5a1 | 11.5 | 16.7 | 221.4 | 222.2 | 60 | /proc Δ, pss |
| arroyo-eos 0.15.0 | 9.3 | 11.8 | 514.0 | 519.7 | 59 | /proc Δ, pss |
| flink-eos 2.2.1 | 41.5 | 74.4 | 973.7 | 997.0 | 59 | /proc Δ, pss |
| quix-eos 3.23.6 | 18.4 | 19.7 | 101.0 | 101.1 | 60 | /proc Δ, pss |
How to read this table
CPU is summed over the whole process tree (JVM, UDF servers, the RisingWave container's processes) as utime+stime deltas per 1 s sample — 100 = one core. Memory is PSS (shared pages counted once) where readable, RSS otherwise (instrument). Rows marked ⚠ legacy ps were measured with the old ps %CPU lifetime average and are not comparable: re-run them.
Product notes
turbine-eos — A Kafka transaction per batch (output + input offsets): at this rate every batch closes on its timeout, so a transaction commits at that timeout — the interval the other exactly-once lines are given.
arroyo-eos — sink.commit_mode = 'exactly_once': a Kafka transaction per checkpoint and sink subtask, committed once the checkpoint completes — checkpoints every 1 s (BENCH_COMMIT_INTERVAL_MS), the shortest Arroyo accepts ("between 1 second and 1 day"), not Turbine's batch timeout the others get; 5 s on the other per-record workloads.
flink-eos — Its two-phase-commit Kafka sink commits a transaction per checkpoint per sink subtask, once the checkpoint completes: checkpoints at Turbine's batch timeout (execution.checkpointing.interval, BENCH_COMMIT_INTERVAL_MS — 10 ms is Flink's minimum; aligned barriers, as EXACTLY_ONCE requires), 5 s on the other per-record workloads.
quix-eos — A Kafka transaction per commit, the consumed offsets inside it: commit_interval at Turbine's batch timeout (BENCH_COMMIT_INTERVAL_MS; quixstreams' default 5 s, kept on the other workloads).
Sanity — ⚠ off theory
Are we benching the same thing?
| product | selectivity (out/consumed) | expected | ✓ | breach_rate | enrichment | sample |
|---|---|---|---|---|---|---|
| turbine-eos 0.3.5a1 | 0.2879 | 0.2880 | ✓ | 0.198 | ✓ | 499 |
| arroyo-eos 0.15.0 | 0.4251 | 0.4380 | ✓ | 0.200 | ✓ | 499 |
| flink-eos 2.2.1 | 0.3795 | 0.3765 | ✓ | 0.208 | ✓ | 491 |
| quix-eos 3.23.6 | 0.4127 | 0.2885 | ⚠ | 0.207 | ✓ | 497 |
Verdict: ⚠️ off theory — selectivity more than 25% from the expected value for: quix-eos (0.413 vs 0.288). Either the filter differs or the consumed reading is off.
Turbine releases
| line | version | bench | regime | offered/s | consumed/s | rows/s per core | µs CPU / record | rows/s per GB | p99 ms | Δ efficiency | Δ capacity | run |
|---|---|---|---|---|---|---|---|---|---|---|---|---|
| turbine-eos | 0.3.5a1 | 2 | keep-up | 19,782 | 19,782 | 172,635 | 5.79 | 91,487 | 233.6 | +2.8% | - | 2026-11-10 |
| turbine-eos | 0.3.4a1 | 2 | keep-up | 19,795 | 19,785 | 167,988 | 5.95 | 86,508 | 233.5 | - | - | 2026-11-10 |
How to read this table
The Turbine lines measured on several releases, newest first: a regression check, not part of the cross-product comparison above (which shows the pinned release only). Δ compares each release with the one below it: efficiency = rows/s per core (the inverse of µs CPU per record), capacity = consumed/s, only when both runs were fed beyond capacity (saturated or drain — at a keep-up point consumed/s is just the offered rate). ⚠ = worse by more than 5%, about the single-run noise: confirm with --repeat 3. Two releases measured in two sessions also differ by the host-to-host spread — same flavor, another physical host: −0.7 % consumed / −3.5 % rows/s per core in the c3 control of 2026-10-07 —, which leaves little headroom under the 5 %: re-measure both releases in ONE session (suite.py run … --version X) before acting on a ⚠. No delta is computed across a change of bench version (bench, from bench.yaml), host or run config; ⚠ deprecated rows were measured on an older bench definition — re-run that release with suite.py run turbine WORKLOAD --version X.
Host: ovh:c3-32 (ovh:c3-32@SBG5) · AMD EPYC-Milan Processor (2400 MHz) · 16 cores / 16 threads · 31345 MB · kernel 6.12.100+deb13-cloud-amd64.
Redpanda: rpk version: v26.2.2. Redpanda, gendata and the product under test share this host.