Repository navigation
Kafka Streams: give a Streams topology PC's per-key parallelism #255
Description
Goal
Let a Kafka Streams topology process records with Parallel Consumer's parallelism, so a Streams app
gets per-key concurrency beyond its partition count instead of one record at a time per task.
Today Kafka Streams has no parallel processing of its own, and no prospect of it: KIP-311 is
explicitly abandoned and KIP-408's discussion thread has been dead since January 2019. PC is the only
thing on the JVM that does per-key concurrency cleanly. The two have never been joined up.
Why this, and why now
Nobody has ever filed an issue for this - upstream has 249 issues and a full-text sweep over title,
body and comments for "kafka streams", "streams integration", "topology", "DSL", "async processing",
"KIP-311", "KIP-408" and "stream processor" returns nothing. Upstream PR confluentinc#390 exists but
linked no issue and its body is the untouched template.
The demand is real, it just lives in Discussions, which is why an issue mirror never surfaced it:
| Discussion | Title |
|---|---|
confluentinc#596 |
Support State Store with Parallel consumer |
confluentinc#350 |
Parallel Consumer with KStream StateStore |
confluentinc#609 |
How to correctly close a StreamsApp with a parallel-consumer? |
confluentinc#477 |
Using PC with Spring Cloud Stream / Kafka Binder |
Read the titles: what people asked for is state stores with PC. That is worth noting, because
state is the single most expensive thing to build from scratch - and the approach below gets it for
free by staying inside Kafka Streams rather than reimplementing it.
In confluentinc#596 maintainer rkolesnev reached the same conclusion from the other side: "it would
be easier to do it the other way around and figure out a way to add processing parallelisation to
Kafka Streams instead."
Background
Full assessment, with source citations against Apache Kafka 3.9 and trunk:
docs/plans/2026-08-07-002-investigate-kafka-streams-on-pc-report.md.
Two things it settles up front:
- Upstream PR Features/streams confluentinc/parallel-consumer#390 is not this. It sketches a PC-native Streams-like DSL, never
touches Kafka Streams, and has never compiled. Useful as an API-shape sketch, not as a code port. - Swapping in a PC-backed
ConsumerviaKafkaClientSupplierdoes not work. Not an API problem -
Streams serialises work above the consumer.StreamTask.process()pulls one record at a time via
partitionGroup.nextRecord(). It also degrades to a silent no-op under KIP-1071's streams rebalance
protocol on Kafka 4.1+, whereStreamThreadbypasses the supplier and only logs a warning.
Approach
Cut the seam one layer lower: replace record selection with PC's WorkManager, and execute the
processor chain on PC's worker pool. This needs a fork of
streams/src/main/java/org/apache/kafka/streams/processor/internals/, but the diff is bounded.
The fit is better than expected:
- PC's
WorkManageralready exposes the right four methods with the right types -registerWork,
getWorkIfAvailable,onSuccessResult/onFailureResult, and
collectCommitDataForDirtyPartitions(), which already returns
Map<TopicPartition, OffsetAndMetadata>. - PC already guarantees at most one in-flight record per key under KEY ordering
(ProcessingShard:149-154). The per-key ordering queues comparable systems had to build are the
shard manager's core invariant. - Kafka already ships
SynchronizedPartitionGroup, selected atStreamTask.java:199-207behind an
internal flag. The selection side is solved upstream for free.
The diff
| Tier | Work | Size |
|---|---|---|
| 0 | Enable __processing.threads.enabled__ - gets SynchronizedPartitionGroup free |
config |
| 1 | Concurrent collections in RecordCollectorImpl + StreamTask; volatile on RocksDBStore handles and StreamsProducer.transactionInFlight; allocate recordInfo per record; hoist maybeBeginTransaction() out of send |
~50 lines, mechanical |
| 2 | Thread-confine AbstractProcessorContext.recordContext/currentNode; PC owns committable offsets; withCachingDisabled() on the parallel path; build-time reachability pass in InternalTopologyBuilder |
the real work |
| 3 | Make CachingKeyValueStore + ThreadCache genuinely concurrent |
do not attempt |
One change is load-bearing: AbstractProcessorContext.java:47-48 holds recordContext and
currentNode as a single mutable slot per task, non-volatile, read ambiently by every store wrapper.
Under concurrency the failure mode is silent corruption, not a crash. Make these thread-local and most
of the rest is mechanical.
The caching layer gets deleted from the parallel path, not fixed. CachingKeyValueStore has a
whole-store exclusive lock, ThreadCache's eviction budget is per-thread across every task, and
eviction runs downstream user topology inline via context.forward on whichever thread called put.
withCachingDisabled() removes the whole class of problem in one line. It costs a real semantic
change: the DSL emits every update rather than one per cache flush.
Honest costs
- Stays serial: punctuators, stream-stream joins (
sharedTimeTrackeris shared across both sides
of a join), and every operator-localobservedStreamTime- those are plain non-volatilelongs
doing read-modify-write, so concurrency corrupts them rather than merely reordering. - Changes semantics: windowed aggregations, suppress, segment retention -
windowCloseTimeis
derived from observed stream time, so reordering changes which records are dropped as late. - Cleanly safe: non-windowed aggregations -
count,reduce,KStreamAggregate,
KTableAggregate- do not read stream time at all. - The parallel/serial boundary is a build-time, per-source-node-DAG decision, not a mid-subtopology
split:forwardInternalcallschild.process(record)directly with no queue, and the only
buffering boundary Streams has is a repartition topic.InternalTopologyBuilderis the deciding
class. - It is a fork to maintain against upstream.
Suggested first step
A spike, not the full thing: Tier 1 plus the thread-local change, and find out whether it runs at all.
Nothing larger should be committed to before that answers.
Stretch goal
If the Web GUI (#215) has landed by then, extend it to show the Kafka Streams side: per-task
shard depth, which source-node DAGs were tagged parallel-safe versus left serial, and where records are
actually spending time. The GUI's whole point is showing what a running instance is doing, and "which
half of my topology is actually running in parallel" is exactly the question this feature creates and
nothing else can answer.
Not in scope
A PC-native Streams-like DSL (the other reading of confluentinc#390). Viable while stateless, but
reaching state parity means building the embedded-store-plus-changelog-plus-repartition subsystem from
scratch - and per the Discussions above, state is precisely what users asked for. Separate issue if
ever wanted.
Open question
RocksDB's own JNI guarantee for concurrent Get/Put on one handle is unverified. Kafka asserts
it nowhere - a thread-safety string search over RocksDBStore.java returns zero hits. Needs sourcing
from RocksDB upstream before anything leans on it.
Activity
docs(plan) #255: spike plan - can PC's work-shard manager drive a Str…
docs(plan) #255: re-cut the spike plan after review found two P0s
docs(plan) #255: generate the patched Kafka classes, never commit them
feat(streams-spike) #255: thread-confine the per-task record context …
feat(streams-spike) #255: PC's WorkManager drives the Streams chain (U5)
astubbs commentedon Aug 8, 2026
implementing in branch:
feats/ks-on-pc-spiketest(streams-spike) #255: Kafka's own 188 tests pass against the patc…
test(streams-spike) #255: PC-driven output matches genuinely stock St…
test(streams-spike) #255: R9 proven - the confinement is load-bearing…
87 remaining items
refactor(streams) #255: one drain for both arms, because two arms tha…
docs(inflight) #255: the lane's control-arm check fired on real data,…
docs(inflight) #255: second attempt at the stateful proof was green, …
merge(streams) #255: bring the classifier cache-warm down from the ma…
merge(streams) #255: bring the classifier cache-warm and the flake si…
merge(streams) #255: bring the classifier cache-warm down to the life…
docs(streams) #255: record the two review threads no rung answers
docs(inflight) #255: the invalid-timestamps flake on both cascaded ru…
test(streams) #255: record that the post-commit checkpoint arm passes…
fix(review) #255: the encoder never re-reads emptiness, decodeRider d…
ci(infer) #255: the ratchet drops the buffer-overrun sighting that th…
merge(streams) #255: bring the classifier cache-warm down to the erro…
docs(plans) #504: where a shape is open, take the one users already know