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.