Skip to content

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.