Skip to content

End-to-end Latency (iso-rate)

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-09-10 · same-machine, relative comparison.

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 at-least-once (idempotent, acks=all) batch (Arrow columnar) 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)
faststream at-least-once (idempotent, acks=all; fire-and-forget publish) batch I/O, 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 (PyFlink local MiniCluster) keyed exchange to N slots (parallelism.default, in-process local channels; source stays 1 subtask) schema projection (4 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 (4 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.

Throughput / efficiency (live)

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 0.2.0a1 19,795 19,663 ████████ keep-up +0.7% 6.7 291,658 ████████ 99,462 ███░░░░░ 3.43 6,666 0 2026-09-10
faststream 0.7.1 19,788 19,656 ████████ keep-up +0.7% 23.0 85,353 ██░░░░░░ 232,952 ████████ 11.72 5,967 0 2026-09-10
flink 2.2.1 19,785 19,522 ████████ keep-up +1.4% 9.9 198,143 █████░░░ 25,399 █░░░░░░░ 5.05 7,476 0 2026-09-10
quix 3.23.6 19,782 19,848 ████████ keep-up -0.3% 12.7 155,681 ████░░░░ 213,356 ███████░ 6.42 6,853 0 2026-09-10
risingwave v2.8.4 19,786 19,819 ████████ keep-up -0.2% 6.7 295,128 ████████ 44,788 ██░░░░░░ 3.39 6,349 0 2026-09-10

consumed/s is what the product actually drained (its consumer-group offset or engine counter), offered/s what gendata produced. regime: 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). lag drift = backlog change over the window ÷ records consumed. 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).

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 0.2.0a1 keep-up 130.4 230.9 239.4 242.6 243.9 404114 105.9 / 204.0 24.5 / 35.4
faststream 0.7.1 keep-up 125.6 206.2 225.7 228.4 229.9 361112 105.6 / 203.9 20.1 / 21.8
flink 2.2.1 keep-up 111.5 193.3 211.2 214.2 214.8 456429 106.0 / 204.2 5.4 / 6.9
quix 3.23.6 keep-up 117.3 198.1 217.4 224.3 225.0 414721 106.0 / 204.1 11.4 / 13.3
risingwave v2.8.4 keep-up 117.1 195.8 216.2 218.6 219.7 382512 106.0 / 203.9 11.1 / 12.3

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).

Resources (raw)

product cpu_avg % cpu_p95 % mem_avg MB mem_max MB samples instrument
turbine 0.2.0a1 6.7 7.8 202.4 206.6 59 /proc Δ, pss
faststream 0.7.1 23.0 24.5 86.4 87.9 59 /proc Δ, pss
flink 2.2.1 9.9 14.6 787.1 792.7 59 /proc Δ, pss
quix 3.23.6 12.7 13.7 95.3 95.3 59 /proc Δ, pss
risingwave v2.8.4 6.7 7.9 453.1 465.6 59 /proc Δ, rss

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.

turbine — Batch formation is size-or-timeout; at this rate every batch closes on the timeout, so BENCH_BATCH_TIMEOUT_MS=50 (vs 500 for the throughput workloads) is the operating point — its cost in throughput is what the stateless page measures at 500.

flink — Flink's Kafka sink writes as records arrive (no wait for a checkpoint under at-least-once); the pipeline's execution.buffer-timeout default (100 ms) bounds the network-buffer flush.

risingwave — RisingWave emits sink rows per barrier epoch (default 1 s barrier_interval_ms), so its floor is the epoch, by design.

Sanity — are we benching the same thing?

product selectivity (out/consumed) expected breach_rate enrichment sample
turbine 0.2.0a1 0.3390 0.3410 0.210 500
faststream 0.7.1 0.3036 0.2885 0.214 500
flink 2.2.1 0.3829 0.3740 0.200 500
quix 3.23.6 0.3453 0.3530 0.212 500
risingwave v2.8.4 0.3204 0.3285 0.172 500

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

Host: AMD Ryzen 5 7600X 6-Core Processor · 6 cores / 12 threads · 31241 MB · kernel 6.12.107+deb13-amd64.

Redpanda: rpk version: v26.1.6. Redpanda, gendata and the product under test share this host.