State stores
State stores hold the data your stateful operators accumulate — running counts, window aggregates, join buffers, custom per-key state. This page covers the two factories you use to configure them (Stores and Materialized), how to choose between RocksDB-backed and in-memory variants, how to attach a store to a custom Processor, and how to read a store back from a running application.
For the conceptual model — global state, thread-safety under concurrent lanes, and changelog-backed durability — see State and thread safety.
RocksDB vs. in-memory
Every store type comes in two variants, and the choice is the same each time:
| Persistent (RocksDB) | In-memory | |
|---|---|---|
| Survives restart | Yes — restored from local disk + changelog | No — rebuilt from changelog on every start |
| Memory footprint | Bounded; data spills to disk | Whole store lives on the heap |
| Exactly-once | Yes | Yes (changelog still committed on the barrier) |
| Best for | Production, large state, fast restart | Tests, small bounded state, prototyping |
The Stores factory
Stores is a static factory (io.stoatflow.core.state.Stores) that returns a supplier — a deferred description of a store. Suppliers are handed to Materialized (for DSL operators) or to StreamsBuilder.addStateStore(...) (for custom Processors). The factory methods, grounded in Stores.kt:
| Store type | Persistent (RocksDB) | In-memory |
|---|---|---|
| Key-value | persistentKeyValueStore(name) | inMemoryKeyValueStore(name) |
| LRU cache | — | lruMap(name, maxCacheSize) |
| Window | persistentWindowStore(name, retention, windowSize) | inMemoryWindowStore(name, retention, windowSize) |
| Session | persistentSessionStore(name, retention, inactivityGap) | inMemorySessionStore(name, retention, inactivityGap) |
| Timestamped KV (KIP-258) | persistentTimestampedKeyValueStore(name) | inMemoryTimestampedKeyValueStore(name) |
| Timestamped window (KIP-258) | persistentTimestampedWindowStore(name, retention, windowSize) | inMemoryTimestampedWindowStore(name, retention, windowSize) |
| Versioned KV (KIP-889) | persistentVersionedKeyValueStore(name, historyRetention) | inMemoryVersionedKeyValueStore(name, historyRetention) |
All duration arguments are java.time.Duration. Window and session retention must be at least the window size (the factory enforces this and throws IllegalArgumentException otherwise). Store names must be non-empty and contain only [a-zA-Z0-9._-].
persistentVersionedKeyValueStore / inMemoryVersionedKeyValueStore) keep multiple versions of each value over time for point-in-time lookups and temporal joins. The single historyRetention argument is dual-purpose: it's both how long old versions are kept and the grace period — out-of-order records older than that window are rejected. Unlike Kafka Streams, which only ships a RocksDB-backed versioned store, StoatFlow offers an in-memory variant too.Materialized — stores behind DSL operators
Stateful DSL operators (count, reduce, aggregate, windowed counts, joins) take a Materialized to describe their store. The most common forms (grounded in Materialized.kt):
import io.stoatflow.core.state.StateStore
import io.stoatflow.core.state.StoreType
import io.stoatflow.core.topology.Materialized
import org.apache.kafka.common.serialization.Serdes
// Named store, default (RocksDB) type, with serdes
val counts =
Materialized.`as`<String, Long, StateStore>("word-counts")
.withKeySerde(Serdes.String())
.withValueSerde(Serdes.Long())
// Pick the store type explicitly (clears any explicit supplier)
val inMem =
Materialized.`as`<String, Long, StateStore>("counts")
.withStoreType(StoreType.IN_MEMORY)
// Auto-generated store name, store type only
val autoNamed = Materialized.`as`<String, Long, StateStore>(StoreType.ROCKS_DB)
import io.stoatflow.core.state.StateStore;
import io.stoatflow.core.state.StoreType;
import io.stoatflow.core.topology.Materialized;
import org.apache.kafka.common.serialization.Serdes;
// Named store, default (RocksDB) type, with serdes
var counts =
Materialized.<String, Long, StateStore>as("word-counts")
.withKeySerde(Serdes.String())
.withValueSerde(Serdes.Long());
// Pick the store type explicitly (clears any explicit supplier)
var inMem =
Materialized.<String, Long, StateStore>as("counts")
.withStoreType(StoreType.IN_MEMORY);
// Auto-generated store name, store type only
var autoNamed = Materialized.<String, Long, StateStore>as(StoreType.ROCKS_DB);
You can also hand Materialized.as(...) a supplier directly when you need parameters the DSL doesn't expose (window retention overrides, an explicit window/session/versioned supplier):
import io.stoatflow.core.state.Stores
import io.stoatflow.core.topology.Materialized
import java.time.Duration
// Explicit persistent window store supplier
val windowed =
Materialized.`as`<String, Long, StateStore>(
Stores.persistentWindowStore("hourly", Duration.ofHours(2), Duration.ofHours(1)),
)
// Explicit in-memory key-value supplier
val cache =
Materialized.`as`<String, Long, StateStore>(Stores.inMemoryKeyValueStore("cache"))
import io.stoatflow.core.state.Stores;
import io.stoatflow.core.topology.Materialized;
import java.time.Duration;
// Explicit persistent window store supplier
var windowed =
Materialized.<String, Long, StateStore>as(
Stores.persistentWindowStore("hourly", Duration.ofHours(2), Duration.ofHours(1)));
// Explicit in-memory key-value supplier
var cache =
Materialized.<String, Long, StateStore>as(Stores.inMemoryKeyValueStore("cache"));
Store type precedence
When more than one store source is set on a Materialized, resolution follows a fixed order (ADR-029):
withStoreType(...)(theDslStoreSuppliers/StoreType) — wins absolutely, and clears any explicit supplier.- An explicit supplier passed to
Materialized.as(supplier). - Default — RocksDB (matching Kafka Streams).
Materialized options
| Method | Effect |
|---|---|
withKeySerde(serde) / withValueSerde(serde) | Serdes for the store (and its changelog). Falls back to the configured defaults if unset. |
withStoreType(StoreType.IN_MEMORY | ROCKS_DB) | Selects store type; takes precedence over any explicit supplier. |
withRetention(duration) | Overrides retention for windowed stores. Must be at least window size + grace. |
withCachingEnabled() / withCachingEnabled(config) | Compacts multiple writes to the same key within a barrier interval; only the final value per key is emitted downstream via toStream(). |
withCachingDisabled() | Emits every intermediate update downstream. |
withLoggingEnabled() / withLoggingEnabled(topicConfig) | Forces the changelog topic on, optionally with custom topic config (e.g. retention.ms). |
withLoggingDisabled() | Disables the changelog for this store — state is then only locally persisted (RocksDB) and cannot be recovered from Kafka after a crash. Use only for state you can recompute. |
withRecordHeaders() / withoutRecordHeaders() | Persists the processing record's Headers alongside the value (KIP-1271/KIP-1285). See record headers in state stores. Explicit opt-in or opt-out always beats the global stoatflow.dsl.store-format. |
withCachingDisabled() is what you want when a downstream consumer needs to observe every intermediate update (as the word-count walkthrough shows — counts climbing 1, 2, 3 rather than only the final tally). When unset, caching follows the global default.Worked example: a counting KTable
import io.stoatflow.core.state.StateStore
import io.stoatflow.core.topology.Grouped
import io.stoatflow.core.topology.Materialized
import io.stoatflow.core.topology.Named
import io.stoatflow.core.topology.StreamsBuilder
import org.apache.kafka.common.serialization.Serdes
fun buildTopology(builder: StreamsBuilder) {
builder
.stream<String, String>("words")
.groupBy({ _, word -> word }, Grouped.`as`("group-by-word"))
.count(
Named.`as`("count"),
Materialized.`as`<String, Long, StateStore>("word-counts")
.withKeySerde(Serdes.String())
.withValueSerde(Serdes.Long()),
)
}
import io.stoatflow.core.state.StateStore;
import io.stoatflow.core.topology.Grouped;
import io.stoatflow.core.topology.Materialized;
import io.stoatflow.core.topology.Named;
import io.stoatflow.core.topology.StreamsBuilder;
import org.apache.kafka.common.serialization.Serdes;
static void buildTopology(StreamsBuilder builder) {
builder
.<String, String>stream("words")
.groupBy((key, word) -> word, Grouped.as("group-by-word"))
.count(
Named.as("count"),
Materialized.<String, Long, StateStore>as("word-counts")
.withKeySerde(Serdes.String())
.withValueSerde(Serdes.Long()));
}
Attaching a store to a custom Processor
For the Processor API you create a store builder yourself, register it on the StreamsBuilder with addStateStore(...), and connect it to the processor by naming it in the process(...) call — registering alone is not enough. Inside the processor you then look it up with context.getStateStore(name). The builder comes from Stores.keyValueStoreBuilder(supplier, keySerde, valueSerde) (and the windowStoreBuilder / sessionStoreBuilder / versionedKeyValueStoreBuilder equivalents).
The alternative is to declare the store from ProcessorSupplier.stores(), which connects it automatically — see Processor API.
import io.stoatflow.core.processor.Processor
import io.stoatflow.core.processor.ProcessorContext
import io.stoatflow.core.processor.Record
import io.stoatflow.core.state.KeyValueStore
import io.stoatflow.core.state.Stores
import io.stoatflow.core.topology.StreamsBuilder
import org.apache.kafka.common.serialization.Serdes
fun buildTopology(builder: StreamsBuilder) {
builder.addStateStore(
Stores.keyValueStoreBuilder(
Stores.persistentKeyValueStore("seen-counts"),
Serdes.String(),
Serdes.Long(),
),
)
builder
.stream<String, String>("events")
.process({ CountingProcessor("seen-counts") }, "seen-counts")
}
class CountingProcessor(
private val storeName: String,
) : Processor<String, String, String, Long> {
private lateinit var store: KeyValueStore<String, Long>
override fun init(context: ProcessorContext<String, Long>) {
store = context.getStateStore(storeName)
}
override fun process(record: Record<String, String>) {
val next = (store.get(record.key) ?: 0L) + 1
store.put(record.key, next)
}
}
import io.stoatflow.core.processor.Processor;
import io.stoatflow.core.processor.ProcessorContext;
import io.stoatflow.core.processor.Record;
import io.stoatflow.core.state.KeyValueStore;
import io.stoatflow.core.state.Stores;
import io.stoatflow.core.topology.StreamsBuilder;
import org.apache.kafka.common.serialization.Serdes;
static void buildTopology(StreamsBuilder builder) {
builder.addStateStore(
Stores.keyValueStoreBuilder(
Stores.persistentKeyValueStore("seen-counts"),
Serdes.String(),
Serdes.Long()));
builder
.<String, String>stream("events")
.process(() -> new CountingProcessor("seen-counts"), "seen-counts");
}
class CountingProcessor implements Processor<String, String, String, Long> {
private final String storeName;
private KeyValueStore<String, Long> store;
CountingProcessor(String storeName) {
this.storeName = storeName;
}
@Override
public void init(ProcessorContext<String, Long> context) {
store = context.getStateStore(storeName);
}
@Override
public void process(Record<String, String> record) {
long next = (store.get(record.key()) == null ? 0L : store.get(record.key())) + 1;
store.put(record.key(), next);
}
}
getStateStore(name) throws StoreNotConnectedException if name is not connected to that node — the Kafka Streams rule, and a fatal error rather than something processing.exception.handler can skip. Two exemptions need no declaration: global stores (addGlobalStore(...) and globalTable(...)) and read-only stores (addReadOnlyStateStore(...)).A global store reached without declaring it is handed to the processor read-only — put/delete throw UnsupportedOperationException, as in Kafka Streams. Its own state-update processor writes to it normally. This covers every store family: plain key-value, window and session stores, and equally the timestamped, versioned and headers-aware ones. The store keeps its declared type, so getStateStore<TimestampedKeyValueStore<K, V>>(name) still returns a TimestampedKeyValueStore; only its writes throw.
To write to a global store from your own processor, declare it — process(supplier, "my-global") or ProcessorSupplier.stores() — which opts into write access. That is a StoatFlow extension (Kafka Streams rejects the declaration itself): on a single instance all state is already global, so a "global" store is an ordinary store, and the read-only default exists for Kafka Streams portability rather than safety.
compute(key, fn) and merge(key, value, fn) for read-modify-write in a single call — atomic within a commit epoch. See State and thread safety for the full model, including the cross-key key-lock utility and the two shapes where key affinity does not hold: a many-to-one re-key into a Processor API node, which StoatFlow refuses to compile rather than let you lock your way around, and storing under a key that is not the record key, which is computed at runtime and so cannot be caught at build time.Record headers in state stores
A store can persist the processing record's Headers alongside the value — a schema id, a
traceparent, tenant or routing metadata, lineage — so it survives aggregation and comes back through
interactive queries and on the changelog. This is Kafka's KIP-1271 (storage) and KIP-1285 (DSL opt-in),
and StoatFlow implements both.
Opt in per store, or globally:
builder
.stream<String, String>("orders")
.groupByKey()
.count(
Materialized.`as`<String, Long, StateStore>("order-counts")
.withKeySerde(Serdes.String())
.withValueSerde(Serdes.Long())
.withRecordHeaders(),
)
builder
.stream("orders", Consumed.with(Serdes.String(), Serdes.String()))
.groupByKey()
.count(
Materialized.<String, Long, StateStore>as("order-counts")
.withKeySerde(Serdes.String())
.withValueSerde(Serdes.Long())
.withRecordHeaders());
stoatflow:
dsl:
store-format: HEADERS # every DSL aggregation persists headers by default
Headers-aware stores are timestamped-only (as in Kafka Streams), covering timestamped key-value,
versioned, timestamped window and session stores. Join buffers are out of scope in both engines. Query
them with the QueryableStoreTypes.*WithHeaders() views.
Turning record headers on
Flipping the flag on is a lazy in-place upgrade — no migration pause, no I/O spike. The store opens with a second RocksDB keyspace: reads check the new one and fall back to the old, converting an old-format hit on the way out (old entries read back with empty headers, which is correct — they predate the feature); writes land in the new keyspace and clear the old copy, so each key upgrades itself the first time it is touched. Windowed and session stores do this per time segment.
Turning record headers off again
Turning them off is not the mirror image, and it is worth knowing before you flip the flag.
The new keyspace is created eagerly the first time the store opens in headers mode, so it exists on disk even if nothing was ever written to it — and RocksDB refuses to open a database without naming every keyspace present. StoatFlow therefore classifies the mismatch at startup, before the store opens, and treats it as a declared format change, not corruption:
| Situation | What happens |
|---|---|
| Nothing was written in headers mode | The empty keyspace is dropped in place. Free: no acknowledgment, no restore, not a byte of data touched. |
| Data has migrated | Startup refuses with an actionable message. Nothing on disk is touched, so an accidental flag removal costs a restart, never data. |
| Data has migrated, and you acknowledge | Set stoatflow.state.format-downgrade: wipe-and-restore. That store's local state is deleted and rebuilt from its changelog — one full restore, bounded by changelog size. |
| Data has migrated, and the store has no recovery source | Refused unconditionally. The acknowledgment does not apply: with the changelog disabled (per store via withLoggingDisabled(), or globally) there is nothing to rebuild from, so wiping would be unrecoverable data loss. |
The outcome is metered as stoatflow.store.format.downgrade.total{outcome=dropped-empty|wiped|refused}.
If you take the rebuild, the restoration tuning guide covers making it fast.
Reading a store back
A store registered in your topology is queryable by name. The read-only views are typed via QueryableStoreTypes and selected with StoreQueryParameters.fromNameAndType(...):
| Store kind | QueryableStoreTypes factory | Read-only view |
|---|---|---|
| Key-value | keyValueStore() | ReadOnlyKeyValueStore<K, V> |
| Window | windowStore() | ReadOnlyWindowStore<K, V> |
| Session | sessionStore() | ReadOnlySessionStore<K, AGG> |
| Timestamped KV | timestampedKeyValueStore() | ReadOnlyKeyValueStore<K, ValueAndTimestamp<V>> |
| Timestamped window | timestampedWindowStore() | ReadOnlyWindowStore<K, ValueAndTimestamp<V>> |
ReadOnlyKeyValueStore gives you get(key), containsKey(key), all(), reverseAll(), range(from, to), reverseRange(from, to), prefixScan(prefix, prefixKeySerializer), and approximateNumEntries(). Iterators must be closed after use.
Range bounds follow Kafka Streams semantics (KIP-763): a null bound is open-ended (both null ≡ all()), reverseRange takes its bounds in the same ascending (low, high) order as range, and an inverted range (from > to on the serialized key bytes) returns an empty iterator with a WARN. The same null-bound rules apply to ReadOnlyWindowStore.fetch(keyFrom, keyTo, …) and the ReadOnlySessionStore key-range queries.
StoreQueryParameters has no withPartition(...); a query against any key resolves locally. Use enableStaleStores() to allow reads while the application is still restoring.Interactive queries against the running engine
The query entry point is store(...) on the core StoatFlow engine, which returns the requested read-only view:
import io.stoatflow.core.state.QueryableStoreTypes
import io.stoatflow.core.state.ReadOnlyKeyValueStore
import io.stoatflow.core.state.StoreQueryParameters
val store: ReadOnlyKeyValueStore<String, Long> =
stoatflow.store(
StoreQueryParameters.fromNameAndType(
"word-counts",
QueryableStoreTypes.keyValueStore(),
),
)
val count: Long? = store.get("hello")
import io.stoatflow.core.state.QueryableStoreTypes;
import io.stoatflow.core.state.ReadOnlyKeyValueStore;
import io.stoatflow.core.state.StoreQueryParameters;
ReadOnlyKeyValueStore<String, Long> store =
stoatflow.store(
StoreQueryParameters.fromNameAndType(
"word-counts",
QueryableStoreTypes.keyValueStore()));
Long count = store.get("hello");
store(...) throws IllegalStateException unless the application is RUNNING (or RESTORING / VALIDATING_STATE when enableStaleStores() was set), IllegalArgumentException if the named store does not exist, and IllegalArgumentException if the store's type does not match the requested QueryableStoreType. storeNames() lists every registered store name for discovery.
store(...) and storeNames() are defined on the core StoatFlow engine, so programmatic interactive queries need a handle to that engine. A :core application holds it directly. The :runtime wrapper (StoatFlowRuntime.fromConfig) currently keeps its engine instance private and exposes no public query accessor, so typed in-process interactive queries are not available from a :runtime deployment today — use the :core API directly if you need them.
Reading a store in a test
In TopologyTestDriver (the in-memory test driver), read stores back directly with getKeyValueStore(name) (and getWindowStore / getSessionStore). Call commitBarrier() first if the store has caching enabled, so cached emissions are flushed:
import io.stoatflow.testutils.TopologyTestDriver
val driver = TopologyTestDriver.fromBuilder(builder)
// ... pipe input ...
driver.commitBarrier() // flush cached writes before reading
val store = driver.getKeyValueStore<String, Long>("word-counts")
assertThat(store.get("the")).isEqualTo(3L)
import io.stoatflow.testutils.TopologyTestDriver;
import io.stoatflow.core.state.KeyValueStore;
var driver = TopologyTestDriver.fromBuilder(builder);
// ... pipe input ...
driver.commitBarrier(); // flush cached writes before reading
KeyValueStore<String, Long> store = driver.getKeyValueStore("word-counts");
assertThat(store.get("the")).isEqualTo(3L);
Related
- State and thread safety — global state, concurrent-access correctness, durability
- Aggregations and Windowing — operators that materialize stores
- Serdes — the serialization the store uses for keys, values, and changelog
- Testing — reading stores back from the test driver
- Kafka Streams compatibility matrix — store-type parity against Kafka Streams
Error handling and DLQ
Configure deserialization, processing, and production exception handlers — log-and-continue, log-and-fail, or dead-letter-queue — in code or YAML, and inspect the DLQ headers StoatFlow writes.
Testing topologies
Unit-test a StoatFlow topology in-memory with TopologyTestDriver — pipe records, advance time and watermarks, read state stores, and drive YAML config through StoatFlowTestDriver. No broker required.