Skip to content

Stateless Filter + Enrich

Per-record filter keeping European regions (eu-*), then enrich each kept row with a derived latency_ratio and an ok/breach SLA decision. No state — measures the raw I/O + serde + per-record compute ceiling (fed above each consumer's capacity so CPU saturates).

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")]
  src --> s0
  s0 --> s1
  s1 -->|"at-least-once"| sink
Bench characteristics

In-process computation — Per record: filter region LIKE 'eu-%', then derive latency_ratio = latency_ms / 80 and an ok/breach SLA flag. Stateless.

product delivery processing engine scaling on one partition decode
turbine at-least-once (idempotent, acks=all) batch (Arrow columnar; 100k rows / 500 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 at-least-once (checkpointed) batch (vectorized SQL, Arrow / DataFusion; source batches of 100k rows / 500 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)
faststream at-least-once (idempotent, acks=all; fire-and-forget publish) batch I/O (getmany 100k rows / 500 ms), per-row compute in-process · pure-Python async (aiokafka; orjson JSON codec) none (one async consumer) full record (19 of 19 fields, orjson dict)
flink at-least-once (checkpointed) 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 at-least-once (idempotent, acks=all) 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)
risingwave at-least-once batch (vectorized SQL) external server · Rust (single-node: meta+compute+frontend) hash exchange to N stream actors (SET streaming_parallelism, in-process) schema projection (5 of 19 fields)

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. All products run at-least-once.

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.

Drain ceiling (--prefill: static backlog, no producer running)

product backlog drained/s cpu_avg % rows/s per core rows/s per GB µs CPU / record output/s run
turbine 0.3.5a1 120M 868,640 ████████ 281.3 308,762 ██████░░ 629,067 ████████ 3.24 273,407 2026-11-10
arroyo 0.15.0 120M 558,567 █████░░░ 141.3 395,352 ████████ 366,046 █████░░░ 2.53 183,220 2026-10-27
faststream 0.7.1 120M 75,223 █░░░░░░░ 99.8 75,367 ██░░░░░░ 138,897 ██░░░░░░ 13.27 21,943 2026-10-27
flink 2.2.1 120M 549,936 █████░░░ 223.0 246,641 █████░░░ 382,122 █████░░░ 4.05 171,977 2026-10-20
quix 3.23.6 120M 123,779 █░░░░░░░ 105.8 116,965 ██░░░░░░ 172,750 ██░░░░░░ 8.55 39,922 2026-10-20
risingwave v3.1.0 120M 355,484 ███░░░░░ 126.4 281,168 ██████░░ 404,187 █████░░░ 3.56 111,089 2026-10-20
How to read this table

gendata wrote the backlog and exited BEFORE the consumer started, so the window measures a drain with no producer competing for the box — the product's consume ceiling free of gendata's CPU and 10 Hz burstiness. Not comparable with a live table (different regime); ⚠ exhausted = the backlog ran out mid-window, rate understated.

Resources (raw)

product cpu_avg % cpu_p95 % mem_avg MB mem_max MB samples instrument
turbine 0.3.5a1 281.3 292.0 1,414.0 1,519.5 59 /proc Δ, pss
arroyo 0.15.0 141.3 163.0 1,562.6 1,772.8 59 /proc Δ, pss
faststream 0.7.1 99.8 101.1 554.6 655.7 59 /proc Δ, pss
flink 2.2.1 223.0 242.7 1,473.7 1,509.4 59 /proc Δ, pss
quix 3.23.6 105.8 111.7 733.7 740.0 59 /proc Δ, pss
risingwave v3.1.0 126.4 137.4 900.6 944.2 59 /proc Δ, rss
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

arroyo — arroyo run executes the SQL pipeline locally (the release binary; Python only writes the SQL): its controller and one worker process, both in the sampled process tree, the worker running parallelism = the run's cores (3). A per-record job is chained into the one source subtask that reads the partition (no exchange, as in Flink's stateless SQL); librdkafka's consumer and producer threads take the rest. The Kafka source commits its offsets to the group at each checkpoint barrier (10 s, Arroyo's default) — the offset of the last record read, which the harness counts + 1 — so consumed/s is a real reading.

faststream — Pure-Python async (aiokafka): CPU-bound on one core, at about half of quix's per-core throughput (quix's librdkafka is a C extension). JSON is decoded with orjson — FastStream auto-detects it once it is installed — which MATCHES quix, whose default JSON deserializer is also orjson; on stdlib json its ceiling was ~22 % lower, so orjson both levels the comparison and lifts it. Benched with FastStream's batch=True subscriber (getmany) + fire-and-forget publish (no_confirm=True, so aiokafka batches the sends): the default per-record path awaits the broker ack on every message and is round-trip-bound (~8k/s, CPU idle), not how FastStream is run at throughput. FastStream has no built-in windowing, so it has no windowed bench: one would measure hand-rolled timer / state code, not FastStream.

flink — PyFlink submits a pure Table API / SQL job to a LOCAL MiniCluster whose parallelism is the run's cores — the source stays one subtask on the single Kafka partition and rebalances the records over the slots in-process, the bench's KPI (3 since 2026-10-20, 1 before). CPU/RSS are summed across the process tree INCLUDING the JVM child, so the figure carries JVM overhead (GC, checkpoints, network / produce threads). At-least-once checkpointing commits the consumed offset to the group each checkpoint, so consumed/s is a real reading like quix's. Benched version from flink/uv.lock; the Kafka SQL connector JAR is fetched from Maven on first run.

risingwave — Single-partition bound: RisingWave runs one source reader per Kafka partition, so on the bench's single partition its CPU stays far below the box's cores (sink coupled). It cannot trade its idle cores for more throughput; scaling needs more Kafka partitions (≈3× at 4), not more cores. consumed/s is read from RisingWave's source state (the Kafka offset its source has read; it commits no Kafka group offset); the sink is coupled to the dataflow (sink_decouple = false), so that offset is processed and produced, not merely buffered; CPU/RSS are the whole single-node container, re-created before each run and stopped after it.

Sanity — ✓ matches theory

Are we benching the same thing?

product selectivity (out/consumed) expected ✓ breach_rate enrichment sample
turbine 0.3.5a1 0.3148 0.3055 ✓ 0.202 ✓ 500
arroyo 0.15.0 0.3180 0.3250 ✓ 0.186 ✓ 500
faststream 0.7.1 0.2917 0.3105 ✓ 0.180 ✓ 500
flink 2.2.1 0.3118 0.3015 ✓ 0.200 ✓ 500
quix 3.23.6 0.3216 0.3375 ✓ 0.198 ✓ 500
risingwave v3.1.0 0.3125 0.3105 ✓ 0.178 ✓ 500

Verdict: ✓ matches theory — every row's selectivity is within 25% of the expected value (output ÷ consumed).

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 · drain 0.3.5a1 1 drain — 868,640 308,762 3.24 629,067 - +1.0% +1.2% 2026-11-10
turbine · drain 0.3.4a1 1 drain — 858,095 305,769 3.27 606,962 - -4.7% -6.5% ⚠ 2026-11-10
turbine · drain 0.3.3a1 1 drain — 917,847 320,840 3.12 655,815 - -1.5% +0.3% 2026-11-06
turbine · drain 0.3.2a1 1 drain — 915,217 325,783 3.07 627,097 - +1.3% -0.5% 2026-11-04
turbine · drain 0.3.1a2 1 drain — 920,019 321,570 3.11 640,596 - +2.4% +0.6% 2026-11-01
turbine · drain 0.3.1a1 1 drain — 914,509 313,976 3.18 637,396 - +0.7% +0.8% 2026-10-28
turbine · drain 0.2.2a2 1 drain — 907,031 311,682 3.21 640,773 - -2.5% -2.7% 2026-10-20
turbine · drain 0.2.2a1 1 drain — 932,497 319,814 3.13 656,165 - -2.1% +55.9% 2026-10-19
turbine · drain 0.2.1a4 1 drain — 598,255 326,625 3.06 417,118 - +0.9% +2.5% 2026-10-19
turbine · drain 0.2.1a3 1 drain — 583,688 323,552 3.09 401,651 - +0.2% -1.8% 2026-10-19
turbine · drain 0.2.1a2 1 drain — 594,525 322,946 3.10 425,849 - -17.8% ⚠ +32.6% 2026-10-19
turbine · drain 0.2.0a2 1 drain — 448,465 392,949 2.54 366,332 - - - 2026-10-19
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@DE1, ovh:c3-32@SBG5, ovh:c3-32@UK1) · 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.