Tuning under load
StoatFlow ships with defaults that work for most topologies, and the parts that need to adapt to your workload — commit cadence and epoch size — self-tune within the bounds you set. This page covers the knobs you do set: lane count, the commit-cadence bounds, the memory and uncommitted-state limits, and RocksDB sizing. Each section starts from a symptom so you know when to reach for it.
For the why behind these mechanics, see Lanes and parallelism, Exactly-once, and State and thread safety. For where each value comes from and how it's merged, see the Configuration model and Defaults and presets.
Before you tune
Tune one thing at a time, and tune against a representative load, not a synthetic burst. The defaults are deliberately conservative; most workloads need at most one or two of the knobs below. Always confirm the symptom first — reach for /metrics and the /debug/barriers endpoint (see REST API) rather than guessing.
stoatflow.* and an environment-variable override (STOATFLOW__<SECTION>__<KEY>). The full list — with types, ranges, and overrides — is in the Configuration reference.Lane count vs cores
Knob: stoatflow.lanes.count — default max(2, CPU cores).
Lanes are the unit of per-key parallelism: every record is routed to a lane by its key, and each lane is a virtual thread that processes its keys in order. More lanes means more keys processed concurrently, at the cost of more queues and a little more dispatch overhead.
stoatflow:
lanes:
count: 16
How to size it:
- CPU-bound processing (pure transforms, local aggregation): start at the number of CPU cores. Going much higher just adds scheduling overhead without more throughput, because the work is already saturating the cores.
- I/O-bound processing (enrichment calls, blocking lookups, external APIs): go well above core count — virtual threads park cheaply while blocked, so 4×–8× cores keeps cores busy while many lanes wait on I/O. This is the case where raising the lane count pays off most.
- Few distinct keys: lanes only help if keys spread across them. If your traffic is dominated by a handful of hot keys, extra lanes sit idle — the hot keys still serialize onto their assigned lanes. Fix the key distribution first.
| Symptom | Likely cause | Reach for |
|---|---|---|
| Cores underused, throughput plateaus, processing involves blocking calls | Too few lanes for an I/O-bound topology | Raise lanes.count (4×–8× cores) |
| High CPU, lots of context switching, no throughput gain from more lanes | Over-provisioned lanes for CPU-bound work | Lower lanes.count toward core count |
| A few keys dominate; most lanes idle | Skewed key distribution, not a lane-count problem | Re-key upstream; lane count won't help |
Lane queue capacity
Knob: stoatflow.lanes.queue-capacity — default 300.
Each lane has a bounded queue; when a lane fills, the dispatcher applies backpressure. Raise it if you see bursty input stalling dispatch and you have memory headroom; the default is sufficient for steady-state load.
stoatflow:
lanes:
queue-capacity: 500
Commit cadence
StoatFlow commits in epochs — the work between two commit barriers becomes one Kafka transaction plus one state-store flush (see Exactly-once). The cadence — how often a barrier fires — is self-tuning: the engine adapts it within the bounds you configure, so you set the envelope, not the exact interval. The algorithm itself stays in the source; what you control is the floor and ceiling.
The bounds you set:
| Knob | YAML key | What it does |
|---|---|---|
| Seed interval | commit-barrier.interval-ms | Starting interval before the cadence settles |
| Interval factor | commit-barrier.interval-factor | Target interval as a multiple of measured commit duration |
| Minimum interval | commit-barrier.min-interval-ms | Floor — barriers never fire closer together than this |
| Maximum interval | commit-barrier.max-interval-ms | Ceiling — barriers never fire further apart than this |
Defaults and StreamsConfig field names for every commit-barrier.* knob are in Engine configuration → Commit-barrier cadence.
stoatflow:
commit-barrier:
min-interval-ms: 150
max-interval-ms: 5000
interval-factor: 1.5
The trade-off is the classic latency-vs-throughput one:
- Tighter, more frequent commits (lower
max-interval-ms, lowerinterval-factor) reduce end-to-end latency and bound how much work a crash replays — at the cost of more transaction and flush overhead per record. - Looser, less frequent commits (higher bounds) amortize commit overhead over more records, raising throughput — at the cost of higher latency and a larger replay window after a restart.
| Symptom | Likely cause | Reach for |
|---|---|---|
| End-to-end latency too high; output lags input | Cadence too loose; records wait for the next barrier | Lower max-interval-ms (and/or interval-factor) |
| Throughput capped; commit overhead dominates a profile | Cadence too tight for the volume | Raise max-interval-ms |
| Output is bursty in lockstep with commits | Working as designed — output is released at barriers | Tighten cadence if the burstiness hurts downstream |
commit-barrier.timeout-ms is a safety bound, not a cadence knob. It caps how long a commit may take; if a commit stalls past it, the engine aborts the in-flight transaction and the process restarts rather than freezing silently. Don't lower it to force faster commits — see Probes.Epoch size
You normally don't touch epoch size — it self-tunes within the bounds above. Two knobs exist for the edges:
| Knob | YAML key | When to use |
|---|---|---|
| Hard cap on records per epoch | commit-barrier.max-epoch-records | Unset by default (no cap) — set only if you need a deterministic upper bound on epoch size regardless of self-tuning |
| Initial cap before warmup | commit-barrier.initial-max-epoch-records | Lower for conservative startup on heavy-state topologies; -1 for an unlimited first epoch |
stoatflow:
commit-barrier:
initial-max-epoch-records: 1024 # conservative startup for heavy-state topologies
# max-epoch-records: 25000 # optional deterministic ceiling
Memory and uncommitted state
Between commits, a stateful topology accumulates uncommitted state in memory — the writes buffered since the last barrier, held until the transaction commits (KIP-892 transactional state stores). On topologies where one input record fans out into many state writes (multi-way joins, foreign-key joins, high-cardinality aggregations), this is the value most likely to threaten container memory.
Knob: stoatflow.state.uncommitted-max-bytes — default 268435456 (256 MB).
When buffered uncommitted bytes approach this limit, the engine triggers an early commit barrier to flush state and bound peak memory. This is the backstop that keeps a high-fan-out epoch from running the container out of memory.
stoatflow:
state:
uncommitted-max-bytes: 268435456 # 256 MB (default)
The global ceiling has two per-store companions under stoatflow.caching: max-estimated-bytes (default 256 MB per caching store) and max-entries (default unbounded). Exceeding a per-store cap fires the same early commit barrier — tagged CACHE_PRESSURE on the barrier-trigger metric — so one hot store can't monopolise the buffer. In practice uncommitted-max-bytes measures a superset and fires first; leave max-entries unbounded unless a specific store must bound its per-epoch key cardinality.
Sizing it against the container:
| Container memory limit | uncommitted-max-bytes | Rationale |
|---|---|---|
| 4 GiB | ~1 GiB | Leaves headroom for heap, RocksDB, Kafka client buffers, and GC |
| 8 GiB | 1–2 GiB | More room for GC and producer buffers |
| 16 GiB | 2–4 GiB | Production sizing for heavy stateful topologies |
uncommitted-max-bytes is one of several memory consumers in the process — it does not account for the JVM heap, the RocksDB memory budget (next section), or the Kafka producer's send buffer. On small-heap deployments with both heavy state writes and heavy sink output, leave generous headroom and watch container memory before raising it.| Symptom | Likely cause | Reach for |
|---|---|---|
| Container memory spikes near the limit; OOMKills under load | Uncommitted state per epoch too large for the container | Lower uncommitted-max-bytes, or raise the container limit if you have hardware |
| Very frequent commits; throughput suffers on a stateful topology | Early barriers firing on memory pressure (MEMORY_PRESSURE trigger) or a per-store cache cap (CACHE_PRESSURE trigger) — the barrier-trigger metric tells you which | Raise uncommitted-max-bytes, or the tripping store's caching.max-estimated-bytes (only if the container has the headroom) |
| Slow time-to-first-commit on a heavy-state topology at startup | First epoch buffering too much before warmup | Lower commit-barrier.initial-max-epoch-records |
Segmented-store checkpoint cadence
Time-windowed and session stores are segmented, and their RocksDB segments run with the write-ahead log off — so they only become durable on a clean close(). A periodic checkpoint (flush dirty segments, then the offsets) makes them durable between commits and caps how much changelog a pod replays after an ungraceful restart, and how much a hot-standby standby drains on promotion — to at most one checkpoint interval.
Knobs: stoatflow.state.segment-checkpoint-records (default 100000) and stoatflow.state.segment-checkpoint-ms (default 30000), whichever fires first.
The defaults are deliberately coarse: a checkpoint flushes SST files, so a tight cadence trades a smaller recovery window against more SST/compaction churn in steady state. Lower them only if your recovery-time objective needs a shorter ungraceful-restart catch-up than ~30 s / 100k records of windowed writes; raise them if you see checkpoint-driven write amplification and can absorb a longer catch-up.
RocksDB for large state
Persistent state lives in RocksDB. Unlike Kafka Streams' unbounded default, StoatFlow caps RocksDB memory by default to keep the process within its container. You pick a preset; the framework wires the block cache and memtable budget for you.
Knob: stoatflow.rocks-db.preset — default DEFAULT.
| Preset | Total RocksDB memory | Use for |
|---|---|---|
LOW_MEMORY | 64 MB | Constrained containers, small state |
DEFAULT | 256 MB | Most workloads |
HIGH_PERFORMANCE | 1 GB | Large state, read-heavy access patterns, high throughput |
stoatflow:
rocks-db:
preset: HIGH_PERFORMANCE
When state grows large (millions of keys, hot read paths), DEFAULT's 256 MB block cache becomes a bottleneck: working-set reads start missing the cache and hit disk. HIGH_PERFORMANCE widens both the block cache and the memtable budget. Budget for it — it is real resident memory on top of the JVM heap and uncommitted-max-bytes, so size the container accordingly.
Confirm the diagnosis before you resize. Enable the RocksDB metrics (rocks-db.metrics.enabled + statistics-enabled) and watch stoatflow_rocksdb_block_cache_hit_ratio under load: a ratio falling toward the floor while stoatflow_rocksdb_shared_block_cache_usage_bytes sits at capacity is the signal that the cache is too small — the metric that turns the symptom below from a guess into a measurement.
| Symptom | Likely cause | Reach for |
|---|---|---|
Read latency climbs as state grows; disk I/O high (falling block.cache.hit.ratio) | Block cache too small for the working set | rocks-db.preset: HIGH_PERFORMANCE |
| Memory tight on a small container with modest state | RocksDB budget larger than needed | rocks-db.preset: LOW_MEMORY |
| Need a budget between the presets | Preset granularity too coarse | Set a custom total via a programmatic RocksDBConfig (see below) |
Backend selection
Knob: stoatflow.rocks-db.backend — default AUTO.
AUTO picks each runtime's faster path: the FFM (Foreign Function & Memory API) backend on the JVM — lower per-call overhead than JNI and no virtual-thread pinning during native RocksDB calls — and JNI under a native image, where FFM's critical-downcall advantage doesn't apply. Leave it on AUTO unless you have a specific reason to pin a backend; if you force JNI on the JVM, also set stoatflow.lanes.thread-type: PLATFORM to avoid pinning carrier threads.
Custom RocksDB budget (programmatic)
The YAML preset is the supported path for most deployments. If you need a memory budget between the presets, set RocksDBConfig directly when building the engine config — RocksDBConfig.withTotalMemory(...) splits the budget evenly between block cache and memtables:
import io.stoatflow.core.config.StreamsConfig
import io.stoatflow.core.state.rocksdb.RocksDBConfig
val config = StreamsConfig.builder("my-app", "localhost:9092")
.rocksDbConfig(RocksDBConfig.withTotalMemory(512L * 1024 * 1024)) // 512 MB total
.build()
import io.stoatflow.core.config.StreamsConfig;
import io.stoatflow.core.state.rocksdb.RocksDBConfig;
var config = StreamsConfig.builder("my-app", "localhost:9092")
.rocksDbConfig(RocksDBConfig.withTotalMemory(512L * 1024 * 1024L)) // 512 MB total
.build();
Watching the effect
Tune against signals, not hunches. After each change, watch:
/metrics— throughput, consumer lag, commit timing, and JVM/container memory, scraped by Prometheus. See Metrics and Observability./debug/barriers— the live commit-pipeline state, which tells you whether barriers are firing on schedule and what's triggering them. See REST API./health/ready— readiness flips DOWN if the engine can't keep up or a commit stalls. See Probes.
A clean tuning loop: change one knob → run representative load → read the metric that maps to the symptom → keep or revert. Resist changing several knobs at once; you won't know which one moved the needle.
Next steps
- Defaults and presets — where every default comes from and how layers merge.
- Configuration reference — every key, type, range, default, and environment override.
- Production checklist — the pre-flight list before you take a topology to production.
- Observability — wiring metrics and dashboards so the symptoms above show up before users notice.
Observability
Operating-time visibility for a single-instance app — scrape /metrics into Prometheus and Grafana, the meters worth alerting on, correlating logs to lanes and barriers via opt-in MDC, and the /debug/threads and /debug/barriers endpoints for diagnosing a frozen or slow pipeline.
Production checklist
A go-live checklist for StoatFlow — replica count, license, probes, metrics, error policies, tuning, and thread safety — each item linking to the canonical page that covers it in full.