Migration carrying state
This page is about stateful topologies — aggregations, joins, windows, anything with a state store — and what happens to that state when you move to StoatFlow. There are two supported ways to bring the state across: rebuild it by reprocessing the input topics, or translate it with the stoatflow-migration-tool CLI, which reads your Kafka Streams changelog topics and seeds byte-translated StoatFlow changelogs plus the carried-over input offsets. A third path — engage us — remains for the shapes the tool deliberately does not cover.
If your topology has no state stores, you don't need any of this — see Migration without state.
How StoatFlow carries its own state
StoatFlow's durability model is the same family as Kafka Streams: every state-store write is logged to a compacted changelog topic named {application-id}-{store-name}-changelog, local state lives in RocksDB (or in memory), and on every start the runtime decides per store whether to reuse, delta-restore, or full-restore. The full model — including the per-store restore decision — is on Architecture: state and durability and Lifecycle.
The property that makes a state-carrying migration possible: a first start over pre-seeded changelog topics is just a forced full restore. The engine needs no migration mode — the migration tool writes StoatFlow-format changelogs, and the first start rebuilds RocksDB from them exactly as it would after losing a disk.
The supported-path matrix
| Path | When | What it involves |
|---|---|---|
| Reprocess | State is derivable from input still in retention, and you can afford the catch-up window | Fresh application-id, earliest reset, validate, cut over — see Migration without state |
| Translate | State is too large, too old, or too expensive to rebuild — long windows, KTables over sources with lost history, high-volume sources with short retention | The stoatflow-migration-tool: plan → translate → seed-offsets → verify inside one quiesce window, then first StoatFlow start = full restore |
| Engage | Suppress-heavy topologies, versioned stores at scale, custom partitioners, or anything you're unsure how to classify | Get in touch — we plan the cutover with you |
Direct reuse of a Kafka Streams changelog — pointing StoatFlow at the KS topics — remains unsupported. The two engines' on-wire changelog formats differ for several store types (window-key seqnums, versioned timestamp placement, the LEFT/OUTER join outer store, timestamped value envelopes), and the offset bookkeeping differs. That byte gap is exactly what the migration tool's translation closes — offline, verifiably, and outside the engine.
What the translate path supports
Per store type (the tool's type values in parentheses):
| Store type | Verdict | Notes |
|---|---|---|
Key-value, plain (kv) | ✅ Supported | Byte passthrough |
Key-value, timestamped (kv-timestamped) | ✅ Supported | The stripped timestamp is re-wrapped into the value |
Window, incl. timestamped (window, window-timestamped) | ✅ Supported | The KS seqnum suffix is handled either way |
Window with duplicates (window-duplicates) | ✅ Supported | Join window stores; seeded duplicates are collision-safe |
Session (session) | ✅ Supported | Byte-identical on both sides |
FK-join subscription store (fk-subscription) | ✅ Supported | Migrated foreign-key rows keep re-joining on foreign-table updates |
LEFT/OUTER join outer store (outer-join) | ✅ Supported | Boundary-unmatched records still emit their null-side finals after cutover |
| Headers-aware stores (KIP-1271) | ✅ Supported | recordHeaders: true carries the native record headers; timestamped-KV/window + session families |
Emit-frontier companion (emitFrontier: true) | ✅ Seeded on request | Prevents OnWindowClose aggregations from re-emitting every restored closed window on the first watermark tick |
Versioned (KIP-889) (versioned) | ⚠️ Experimental | Translation is verified; history-retention semantics around the restore boundary await real-world validation |
| Suppress buffer | ❌ Not in v1 | The KS buffer envelope is not byte-translatable — drain it at quiesce (see caveats) or accept the loss |
| Source KTables | Conditional | Compacted source → free (StoatFlow rebuilds from the source topic); see the classification below |
Classifying your stores
The tool's config declares each store's type, and the classification follows the StoatFlow-side store kind, not just the KS operation. Two things matter:
- Name your stores explicitly on both sides (
Materialized.as(...),StreamJoined.withStoreName(...),TableJoined.as(...)). KS auto-generated names (KSTREAM-AGGREGATE-STATE-STORE-0000000007) are not guaranteed to match the ported topology's generated names; the config'sksName → sfNamemapping is the escape hatch, not the plan. - Get the
kvvskv-timestampedsplit right — it is byte-silent if wrong. Both engines' changelogs carry bare value bytes for KV stores (KS strips the timestamp into the record timestamp), so thetypetells the tool whether the StoatFlow store expects a timestamp-wrapped value.
| KS DSL operation | KS store | StoatFlow store | type |
|---|---|---|---|
count / reduce / aggregate (KTable result) | timestamped KV | timestamped KV | kv-timestamped |
builder.table(...) source materialization | timestamped KV | plain KV | kv |
| FK-join result materialization | timestamped KV | plain KV | kv |
windowedBy(TimeWindows/SlidingWindows).count/… | timestamped window | timestamped window | window-timestamped |
windowedBy(SessionWindows).count/… | session | session | session |
Stream-stream join window stores ({name}-this-join-store, {name}-outer-other-join-store with StreamJoined.withStoreName("{name}")) | plain window, duplicates | plain window, duplicates ({name}-left-store / {name}-right-store) | window-duplicates |
LEFT/OUTER join shared outer store ({name}-left-shared-join-store / -outer-shared-join-store) | list-valued KV | per-entry outer store ({name}-outer-store) | outer-join |
FK-join subscription store ({join-name}-subscription-store with TableJoined.as("{join-name}") — identical on both sides) | subscription KV | subscription KV | fk-subscription |
Versioned KTable (Stores.persistentVersionedKeyValueStore) | versioned | versioned | versioned |
Headers-aware stores (Stores.persistent*WithHeaders) | headers-aware | headers-aware | family type + recordHeaders: true |
Source KTables resolve per the compaction of their source topic:
| Source topic | KS app | Path |
|---|---|---|
| Compacted | any | Free — StoatFlow rebuilds from the source topic; omit the store from the tool config |
| Non-compacted | unoptimized (a KS -changelog exists) | Translate the KS changelog as kv |
| Non-compacted | source-optimized (no KS changelog) | Force Consumed.materializeFromSourceTopic(true) in the ported code — accepts the same retention gap KS itself had |
The tool's plan command resolves and prints this path per store, and structurally sanity-checks every declared type against sampled changelog records — see the migration tool.
Semantics caveats at the cutover boundary
- Suppress final emissions. StoatFlow's suppress buffer is changelog-backed and restorable — the boundary risk exists only because the KS buffer is not translated. Any final result still sitting in the KS suppress buffer at shutdown is lost to the StoatFlow side. Mitigation: cut over at a windows-closed point — quiesce input, let stream time advance past window-end + grace so KS emits, then stop. Note stream time only advances with records; a hard input stop before the drain leaves the buffer full forever. See the suppress notes on Windowing.
- OnWindowClose duplicate re-emission. With the
{store}-emitfrontiercompanion left unseeded, StoatFlow's first watermark tick past a window close re-emits a final for every restored closed window within KS retention — duplicates of results KS already emitted, in one burst. Harmless for idempotent-upsert consumers; disruptive for incremental/append consumers. Fix: setemitFrontier: truein the tool config sotranslateseeds the frontier with the store's own max changelog timestamp. - ALO clean-shutdown requirement. Under
at_least_once, only a clean KS shutdown aligns changelog state with committed input offsets — a crashed ALO app's snapshot inherits at-least-once duplication into migrated state (double-counting on resume). Exactly-once snapshots are crash-safe. The tool warns loudly; never migrate a crashed ALO app's snapshot. - LEFT/OUTER join boundary. Unmatched records buffered at cutover migrate via the
outer-joinrule and emit their null-side finals from StoatFlow's watermark scan once it passes window-end + grace. Optionally drain first (quiesce past join-window close) to minimize translated volume; INNER joins are unaffected. See Joins. - Stream time vs watermark. StoatFlow's stream time is the watermark; at resume it rebuilds from live records with the configured strategy (e.g. bounded out-of-orderness lag) — window closure and expiry around the boundary can differ from KS by up to the out-of-orderness bound. See Event time and watermarks.
- Retention boundaries. Restored window/session records older than the StoatFlow store's retention are expired on the first post-restore watermark tick — same as native state, but worth stating.
- Logging-disabled KS stores have no changelog to translate from — reprocess or accept the loss.
What is and is not supported
| Scenario | Supported | Notes |
|---|---|---|
| StoatFlow → StoatFlow restart, RocksDB directory preserved | ✅ | Local state reused; fast delta replay tops it up |
| StoatFlow → StoatFlow restart, RocksDB directory gone | ✅ | Full rebuild from StoatFlow's own changelog topics |
| Translating KS changelogs into StoatFlow changelogs with the migration tool | ✅ | The supported state-carry path — offline, verifiable, re-runnable |
| Carrying KS committed input offsets into the StoatFlow consumer group | ✅ | The tool's seed-offsets step; the app resumes exactly where KS stopped |
| StoatFlow reading a Kafka Streams changelog topic directly | ❌ | Encodings and offset bookkeeping differ; translation exists precisely for this |
Reusing the Kafka Streams application.id for the StoatFlow app | ❌ | StoatFlow forces group.id = application-id AND derives changelog names from it — reuse collides on both |
| Copying RocksDB files from a Kafka Streams deployment onto a StoatFlow node | ❌ | The on-disk store layout is StoatFlow's; there is no file-level import |
| Mixed KS + StoatFlow operation on one consumer group | ❌ | Cut over atomically inside the quiesce window |
Where to go next
- The migration tool — config reference, the four commands, the full cutover runbook, and the rollback line.
- Migration without state — the reprocess path when state is rebuildable.
- Architecture: state and durability — the changelog + RocksDB model and how restoration works.
- Reusing your Kafka Streams dashboards — KS-compatible metrics for watching the cutover on the dashboards you already have.
- Suppress-heavy topology, versioned stores at scale, or unsure how to classify? Get in touch — real people read every email.
Migration without carrying state
Green-field cutover from Kafka Streams — point StoatFlow at the same source topics with a fresh consumer group, let stateful operators rebuild from changelog/source, then switch traffic. The dependency, build, and config swap, with the before/after grounded in the map-filter example.
The migration tool
How to run stoatflow-migration-tool — the offline Kafka Streams changelog translator. Config reference, the plan / translate / seed-offsets / verify commands, the cutover runbook, and the rollback line.