Defaults, adaptivity, and presets

Why a minimal StoatFlow config runs well — the opinionated defaults, the parts that self-tune within bounds you set, and the RocksDB presets and tuning knobs you can change.

StoatFlow ships opinionated defaults so a near-empty application.yaml runs a correct, bounded-memory application out of the box. This page explains what those defaults give you, which behaviour adapts at runtime versus what you set explicitly, and the RocksDB presets and bound knobs available when you do need to tune. For the layered resolution rules behind these settings (env / system property / YAML / programmatic precedence), see Configuration model; for a workload-driven walkthrough, see Tuning.

A minimal config that works

The only required key is stoatflow.application-id. Everything else falls back to a default. A complete, runnable configuration can be as short as this:

stoatflow:
  application-id: my-app
  bootstrap-servers: localhost:9092

Add the runtime's HTTP and metrics endpoints and the license, and you have what Your first app uses:

stoatflow:
  application-id: word-count
  bootstrap-servers: ${KAFKA_BOOTSTRAP_SERVERS:-localhost:9092}
  license:
    key: ${STOATFLOW_LICENSE_KEY}

runtime:
  http:
    enabled: true
    port: 8080
  metrics:
    enabled: true

This runs because the defaults already make the right choices for most workloads. The ones worth knowing:

ConcernDefaultWhy
Processing guaranteeEXACTLY_ONCEAtomic commit of output, changelog, and offsets in one Kafka transaction.
Lane countmax(2, CPU cores)Parallelism scales with the machine you run on.
Lane queue capacity300Per-lane backpressure bound.
State store backendRocksDB (FFM)Persistent, on-disk, no virtual-thread pinning.
RocksDB memory~256 MB totalBounded by default — Kafka Streams is unbounded.
Changelog topicsenabled, auto-createdState survives restart; topics created with cleanup.policy=compact.
Explicit namingenforcedEvery operator and store needs a stable name (KIP-1111); fails fast otherwise.
State restorationenabledLocal state rebuilds from changelog on cold start.
Default serdesByteArraySet default-key-serde / default-value-serde (or per-operator serdes) for real types.
Bounded by default is the deliberate difference from Kafka Streams. RocksDB memory (~256 MB), total uncommitted state (256 MB), and lane queue depth (300) all have caps without you setting anything. The goal is that the default config does not OOM a modestly sized container under load — you raise the caps when you have headroom, rather than discovering an unbounded default the hard way.

What adapts, and what you set

Some runtime behaviour is adaptive: the engine measures its own commit cost and state-write rate and adjusts within bounds. The rest is fixed to what you configure. The split matters because you tune the two kinds differently — for adaptive behaviour you set the envelope, for fixed behaviour you set the value.

Commit cadence self-tunes within an interval band

The commit barrier interval is not a fixed sleep. The runtime estimates how long a commit takes and schedules the next barrier relative to that estimate, so a topology with fast commits barriers more often and a topology with slow commits backs off — both staying inside the band you configure. You set the band; the runtime picks the cadence inside it.

YAML keyDefaultWhat it sets
commit-barrier.interval-ms500Seed interval used before the runtime has measured a commit.
commit-barrier.min-interval-ms150Floor — barriers never fire closer together than this.
commit-barrier.max-interval-ms5000Ceiling — barriers never fire further apart than this.
commit-barrier.interval-factor1.5Cadence target as a multiple of measured commit duration.
commit-barrier.timeout-ms60000Hard timeout for a commit; also cascades to the Kafka producer's transaction.timeout.ms, max.block.ms, and delivery.timeout.ms.
stoatflow:
  commit-barrier:
    min-interval-ms: 100     # allow tighter commits for lower latency
    max-interval-ms: 2000    # but never let an epoch run longer than 2s
    interval-factor: 1.5

Lower the band for lower end-to-end latency (more frequent, smaller commits); raise it for higher throughput (fewer, larger commits). The interval-factor controls how aggressively the cadence tracks commit cost: 1.0 is back-to-back, higher values leave more idle gap between commits.

Epoch size self-tunes within memory and count bounds

An epoch is the batch of records between two commit barriers. The runtime sizes each epoch automatically, tracking the dispatch rate and the per-record state-write cost so it can keep uncommitted state bounded — small for high-fan-out stateful topologies (FK joins, multi-way joins, where one input record cascades into many state writes) and large for lightweight stateless ones. You don't set the epoch size; you set the bounds it operates within.

YAML keyDefaultWhat it sets
state.uncommitted-max-bytes268435456 (256 MB)Hard limit on total uncommitted state bytes across all stores. The dominant bound for stateful, high-fan-out topologies.
commit-barrier.max-epoch-recordsunset (no hard cap)Optional hard ceiling on records per epoch.
commit-barrier.initial-max-epoch-records4096Conservative cap for the first epoch(s), before the runtime has measured the topology. -1 disables it.
caching.max-estimated-bytes268435456 (256 MiB)Per-store cap on estimated emission-cache bytes; exceeding it fires an early cache-pressure commit. The global state.uncommitted-max-bytes ceiling measures a superset and normally fires first.
caching.max-entriesunbounded (opt-in)Optional per-store entry-count cap — a lever for high-cardinality workloads that the byte estimate can't express. Setting a finite value makes workloads exceeding it early-commit.
stoatflow:
  state:
    uncommitted-max-bytes: 268435456   # 256 MB — raise on large containers, lower on small
  commit-barrier:
    initial-max-epoch-records: 4096    # cap startup epochs before adaptation kicks in
    max-epoch-records: 50000           # optional hard ceiling
The adaptive sizing algorithm — how the runtime converges on an epoch size from its measurements — is an implementation concern and lives in the source. What you configure are the public bounds: the memory limit it must not exceed, the optional record ceiling, and the startup cap. Within those, it self-tunes; you do not hand-set epoch sizes per workload.

Sizing your uncommitted-max-bytes to the container is the main lever for stateful topologies. As a starting point, leave roughly half the container's available memory (after JVM heap, RocksDB, and non-heap) as headroom:

Container memoryuncommitted-max-bytesRationale
4 GiB1 GiB (1073741824)~2.5 GB available after heap/RocksDB; 1 GB leaves headroom.
8 GiB1-2 GiBMore room for GC and Kafka client buffers.
16 GiB2-4 GiBRecommended for heavy stateful topologies.

The /metrics endpoint exposes per-epoch counters — stoatflow.barrier.epoch.actual.records, stoatflow.barrier.epoch.memory.cap, and a stoatflow.barrier.trigger.total counter tagged by what ended each epoch (time, record count, memory pressure, or per-store cache pressure) — so you can see which bound is binding and adjust the right one. See Metrics.

Fixed knobs you set directly

These do not adapt; the value you set is the value used.

YAML keyDefaultNotes
lanes.countmax(2, CPU cores)Per-key parallelism. Higher = more concurrency, more queues.
lanes.queue-capacity300Per-lane backpressure bound.
processing-guaranteeEXACTLY_ONCESet AT_LEAST_ONCE for lower commit latency where duplicates are tolerable — see Exactly-once.
watermark.max-out-of-orderness-ms10000Event-time slack — see Event time and watermarks.

RocksDB presets and tuning

State stores default to RocksDB with a bounded ~256 MB memory budget (block cache + memtables), as opposed to Kafka Streams' unbounded RocksDB defaults. You select a memory profile with a single key.

Presets

stoatflow:
  rocks-db:
    preset: DEFAULT   # DEFAULT | LOW_MEMORY | HIGH_PERFORMANCE
    backend: AUTO     # AUTO | FFM | JNI
PresetTotal memoryCompositionUse for
LOW_MEMORY64 MB32 MB block cache + 32 MB memtablesDevelopment, CI, constrained containers. More frequent compaction and cache eviction.
DEFAULT256 MB128 MB block cache + 128 MB memtablesMost production workloads with moderate state.
HIGH_PERFORMANCE1 GB512 MB block cache + 512 MB memtablesHigh-throughput workloads with large state. Needs more memory.

The memory budget is shared across all stores — one block cache and one write-buffer manager bound total RocksDB memory regardless of how many state stores the topology declares.

Backend

backend: AUTO (default) picks each runtime's faster path: the Foreign Function & Memory API (FFM) on the JVM — lower per-call overhead, no virtual-thread pinning — and the rocksdbjni bindings (JNI) under a native image. Set FFM or JNI explicitly to force a specific backend. Both require the --enable-native-access=ALL-UNNAMED JVM flag (the io.stoatflow Gradle plugin sets it — see Installation).

Beyond the presets

The three presets cover the common cases. For finer control there are two escape hatches, neither exposed as additional YAML keys:

  • Custom total-memory budget (programmatic). When constructing the config in code rather than YAML, RocksDBConfig.withTotalMemory(bytes) produces a config with cache, memtables, and file sizes scaled proportionally to any budget (minimum 32 MB):
    import io.stoatflow.core.state.rocksdb.RocksDBConfig
    
    // 512 MB total, scaled proportionally
    val rocks = RocksDBConfig.withTotalMemory(512L * 1024 * 1024)
    
  • Per-store RocksDB options (config setter). For per-store tuning — different settings for write-heavy versus read-heavy stores, custom compression, compaction strategy — set rocks-db-config-setter to the fully qualified name of a class implementing RocksDBConfigSetter. Its configure(storeName, options) runs for every store with options pre-populated from the active preset, so you can branch on the store name and override per-store knobs such as options.cf.maxWriteBufferNumber, options.cf.writeBufferSize, or options.table.useRibbonFilter. Shared memory resources (the block cache and write-buffer manager) are framework-managed and cannot be overridden per store — set the total memory budget globally via the preset or RocksDBConfig.withTotalMemory(...).
    stoatflow:
      rocks-db-config-setter: com.example.MyRocksDBConfigSetter
    
Ribbon filters (useRibbonFilter, default on) only take effect on the FFM backend — the JNI bindings fall back to Bloom filters. If you switch to backend: JNI, expect a slightly larger filter memory footprint at the same false-positive rate.

Where to go next

  • Configuration model — how env vars, system properties, YAML, and programmatic config layer and override each other.
  • Tuning — workload-driven tuning walkthroughs for latency, throughput, and heavy-state topologies.
  • Configuration reference — every key, default, and env override in one table.
  • Metrics — the per-epoch and commit-cadence counters that show which bound is binding.