Fraud Scoring Latency (iso-rate) — exactly-once
The online-scoring case — fraud detection, where a payment waits for its score and must get exactly one: the inference app of every exactly-once line, the same frozen MLP on every record, run in the engine by Turbine (its infer= operator, ONNX Runtime once per batch), Flink (a Java UDF) and Arroyo (a Rust UDF) — ONNX Runtime once per record for those two — and as eager PyTorch once per record in Quix's Python. Every line is fed the SAME modest rate, every batch closes on the latency setting's timeout and every transaction commits at it (Arroyo every 1 s, the shortest it accepts); a read_committed probe consumer tails the output topic: latency = score visible − gendata's timestamp (p50 / p99 / p99.9). Only meaningful in the keep-up regime. Not comparable with the latency page: its app republishes ~31 % of the records (~1.3 MB/s), which surface on the broker's 100 ms flush timer (its setup floor); this one republishes every record (~3.5–5 MB/s), which surfaces sooner — below that floor.
Last updated: 2026-11-12 · same-machine, relative comparison.
flowchart LR
src[("bench-input<br/>1 partition")]
s0["<b>map</b><br/>4 features scaled to [0, 1]"]
s1(["<b>model</b><br/>MLP 4-16-1 → risk"])
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 | 33.3 | 40.0 | 44.6 | 50.1 | 52.5 | 3610000 | 107.4 / 205.9 | below / below |
| arroyo-eos 0.15.0 | keep-up | 1,029.7 | 1,586.9 | 1,736.5 | 1,881.6 | 1,884.2 | 3586998 | 106.8 / 205.2 | 922.9 / 1,531.3 |
| flink-eos 2.2.1 | keep-up | 27.2 | 32.3 | 37.5 | 44.2 | 46.9 | 3640000 | 107.9 / 206.0 | below / below |
| quix-eos 3.23.6 | keep-up | 147.2 | 165.8 | 168.1 | 186.5 | 195.8 | 3610000 | 107.0 / 205.6 | 40.2 / below |
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 inference (four gendata metrics scaled to [0, 1] → the frozen 4-16-1 MLP → risk, every record scored and republished); 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 (9 of 19 fields, Arrow) |
| arroyo-eos | exactly-once (checkpoints every 1 s; Kafka transaction per checkpoint, 2PC sink) | vectorized SQL around a Rust UDF on ONNX Runtime (one ORT run per record) | in-process · Rust (arroyo cluster: API + compiler + controller + one worker process) |
none for a per-record job (chained into the subtask reading the partition) | schema projection (8 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 (8 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 / inference | output/s | extra rec/rec | run |
|---|---|---|---|---|---|---|---|---|---|---|
| turbine-eos 0.3.5a1 | 19,790 | 19,780 | keep-up (+0.0%) | 12.7 | 155,824 | 72,819 | 6.42 | 19,817 | 0 | 2026-11-12 |
| arroyo-eos 0.15.0 | 19,791 | 19,698 | keep-up (+0.5%) | 18.4 | 106,952 | 33,963 | 9.35 | 19,700 | 0 | 2026-11-12 |
| flink-eos 2.2.1 | 19,794 | 19,794 | keep-up (+0.0%) | 72.8 | 27,186 | 16,878 | 36.78 | 19,877 | 0 | 2026-11-12 |
| quix-eos 3.23.6 | 19,794 | 19,778 | keep-up (+0.0%) | 75.6 | 26,167 | 69,928 | 38.22 | 19,931 | 0.08 | 2026-11-12 |
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).
Resources (raw)
| product | cpu_avg % | cpu_p95 % | mem_avg MB | mem_max MB | samples | instrument |
|---|---|---|---|---|---|---|
| turbine-eos 0.3.5a1 | 12.7 | 18.6 | 278.1 | 280.2 | 59 | /proc Δ, pss |
| arroyo-eos 0.15.0 | 18.4 | 22.5 | 593.9 | 612.9 | 59 | /proc Δ, pss |
| flink-eos 2.2.1 | 72.8 | 104.8 | 1,200.9 | 1,224.6 | 59 | /proc Δ, pss |
| quix-eos 3.23.6 | 75.6 | 81.2 | 289.6 | 289.7 | 59 | /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 — ✓ matches theory
Are we benching the same thing?
| product | selectivity (out/consumed) | expected | ✓ |
|---|---|---|---|
| turbine-eos 0.3.5a1 | 1.0027 | 1.0000 | ✓ |
| arroyo-eos 0.15.0 | 1.0001 | 1.0000 | ✓ |
| flink-eos 2.2.1 | 1.0041 | 1.0000 | ✓ |
| quix-eos 3.23.6 | 1.0076 | 1.0000 | ✓ |
Verdict: ✓ matches theory — every row's selectivity is within 25% of the expected value (output ÷ consumed).
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.