Latency, checkpoint cost, throughput
A latency distribution, the cost of a checkpoint as state grows, and a throughput table per workload shape.
Two emit modes: latency, or per-window atomic visibility
Every engine byte-identical to the DuckDB oracle first, then measured back to back on one pinned box. Ubik ships two Kafka emit modes. Transactional wraps each window in a Kafka transaction, so a read_committed consumer sees it all-or-nothing. Idempotent drops the transaction and keeps exactly-once through the (window, group) upsert key and a durability barrier: the fast path.
Idempotent lands a median of 10.2 ms, under Proton and one to two orders of magnitude under the cluster engines, in one process at 41 MB. The default transactional mode is second to Proton by design, the price of atomic visibility, and it stays the default.
| Engine | Mode | p50 | p90 | p99 | Footprint |
|---|---|---|---|---|---|
| Ubik | idempotent | 10.2 | 14.4 | 18.6 | 41 MB / 1 proc |
| Proton | default delivery | 11.9 | 13.1 | 13.5 | 203 MB / 4 proc |
| Ubik | transactional | 33.1 | 45.9 | 50.5 | 41 MB / 1 proc |
| Flink | default delivery | 212.5 | 258.9 | 328.8 | 1406 MB / 6 proc |
| Spark | micro-batch | 595.9 | 785.2 | 969.9 | 1357 MB / 6 proc |
| RisingWave | emit on close | 2646.9 | 2655.5 | 2666.6 | 528 MB / 4 proc |
The unit is milliseconds. Ubik carries exactly-once in both modes and a single process; the tail on the transactional row is the transaction commit round-trip.
Same box and session, cardinality dialled down to 100 keys: a whole window's emission lands inside a millisecond and the microsecond-resolution dump resolves the floor. Idempotent runs sub-millisecond at the fastest. This is a best-case floor, not a production shape; the 10 000-key table above is the distribution to plan against.
| Ubik mode | p50 | p90 | p99 | Fastest |
|---|---|---|---|---|
| idempotent | 1.15 | 2.19 | 3.32 | 0.65 |
| transactional | 16 | 27.5 | 27.9 | 11.2 |
Intel i9-11900K, clock pinned at 3.5 GHz (host-pin: SMT off, governor performance, turbo off). 2026-07-27. 50 000 events/s from a paced live producer, 10 000 keys, TUMBLE 5 seconds, one partition, 120 seconds per engine, the first (warmup) window excluded symmetrically. Every engine byte-identical to the DuckDB oracle on tumble and hop before any latency was trusted. Pinned and same-session, so these are cited figures. The default (transactional) mode is second to Proton by design, the price of per-window atomic visibility; idempotent drops the transaction and keeps exactly-once.
What a checkpoint costs as state grows
The distribution above was measured at ten thousand keys, where a checkpoint costs under eight milliseconds. It does not stay there. A snapshot writes about 72 bytes per open group at about 8.8 ms per MiB, linearly. If a million open groups is your cardinality, plan for a six hundred millisecond pause.
| Open groups | State on disk | Checkpoint pause |
|---|---|---|
| 10 000 | 0.69 MiB | 7.8 ms |
| 100 000 | 6.95 MiB | 61 ms |
| 500 000 | 34.8 MiB | 306.4 ms |
| 993 194 | 69.14 MiB | 605.5 ms |
The pause sits on the driver loop, once per checkpoint. On the default cadence that is roughly every two hundred thousand rows.
Intel i9-11900K, 6 workers, powersave governor. 2026-07-25. The real windowed operator, bound by the real planner, snapshotted at maximum open state. Swept by group cardinality. The write is CPU-bound rather than disk-bound: the same state to tmpfs and to encrypted btrfs differ by 8%. The pause is the serializer, and it is being worked on.
Throughput, per workload shape
A mono-node ceiling is a number per shape. Stateless shapes run at millions to tens of millions of events/s, windowed aggregates in the hundreds of thousands to low millions. Sliding windows and the stream-stream interval join are the known low end: if that is the workload, measure it first. Measured on six pinned cores of one machine.
| Workload | Events/s | What saturates next |
|---|---|---|
| Filter and project, from a file | 15 006 047 | decode fans out across workers |
| SESSION window, gap-based | 4 824 104 | scales radix-by-key |
| Count, from a Kafka topic | 2 035 904 | this is what the source itself delivers |
| TUMBLE window, from a file | 1 352 510 | the windowed-aggregate operator |
| TUMBLE window, from a Kafka topic | 1 037 481 | the shape most jobs actually run |
| TUMBLE window, state spilled to NVMe | 833 539 | state larger than the memory budget |
| HOP window, sliding | 652 202 | the sliding-window emit; measure first if this is your workload |
| Interval join, two streams on a time band | 431 176 | band match plus retention over two streams, the heaviest shape; scales with workers |
Intel i9-11900K, 6 of 8 physical cores pinned at 3.5 GHz (host-pin: SMT off, governor performance, boost off), 31 GiB RAM, Linux 7.0.9 (Fedora 44). 2026-07-31. 5M rows, 3 repetitions, best-of wall clock, a real Redpanda broker for the Kafka rows. Each figure is the committed baseline, and a same-box regression run over 5 percent fails the merge gate. Each row is the ceiling for one workload shape. Pinned to base clock with SMT and boost off, so each cell ran at a spread under 2.5 percent: measurements, not samples of whatever the boost algorithm decided. Lower absolute numbers than an unpinned box, higher trust.
Measure it on your machine
The ceilings above come from the pinned bench host. Your own number takes a minute: generate five million JSON events, aggregate them, time it. No broker, no config, and the parse is included. The transcript below is one current laptop; yours is the number that matters.
awk 'BEGIN{for(i=0;i<5000000;i++)printf("{\"merchant\":\"m%d\",\"amount\":%d}\n",i%100,i%500)}' > events.ndjson time ubik --from file://events.ndjson \ "SELECT merchant, count(*) AS n, sum(amount) AS total FROM events GROUP BY merchant" {"merchant":"m98","n":50000,"total":14900000}{"merchant":"m99","n":50000,"total":14950000}real 0m5.9s