<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: alginte</title>
    <description>The latest articles on DEV Community by alginte (alginte).</description>
    <link>https://dev.to/alginte</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Forganization%2Fprofile_image%2F14634%2Ff5ccfd61-b3c0-4563-8701-cdff25a6bd7c.png</url>
      <title>DEV Community: alginte</title>
      <link>https://dev.to/alginte</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/alginte"/>
    <language>en</language>
    <item>
      <title>Calling an external service from Kafka Streams: size the threads, budget every call</title>
      <dc:creator>alginte software</dc:creator>
      <pubDate>Sat, 31 Oct 2026 17:58:38 +0000</pubDate>
      <link>https://dev.to/alginte/calling-an-external-service-from-kafka-streams-size-the-threads-budget-every-call-nn6</link>
      <guid>https://dev.to/alginte/calling-an-external-service-from-kafka-streams-size-the-threads-budget-every-call-nn6</guid>
      <description>&lt;p&gt;You have a Kafka Streams application, and one step needs an answer from somewhere else. It might be an AI model's decision, a fraud score, an address to geocode or a status from a provider's API. The most direct way to write it is a blocking call inside a &lt;code&gt;mapValues&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="n"&gt;stream&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;mapValues&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;value&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="nc"&gt;HttpResponse&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;answer&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;http&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;send&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;requestFor&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;value&lt;/span&gt;&lt;span class="o"&gt;),&lt;/span&gt; &lt;span class="nc"&gt;BodyHandlers&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;ofString&lt;/span&gt;&lt;span class="o"&gt;());&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;withAnswer&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;value&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;answer&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;body&lt;/span&gt;&lt;span class="o"&gt;());&lt;/span&gt;
&lt;span class="o"&gt;});&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is where most applications start. It is kept bare on purpose: no timeout, no retries, no error handling.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://www.alginte.com/blog/what-max-poll-interval-ms-bounds-in-kafka-streams/" rel="noopener noreferrer"&gt;What max.poll.interval.ms bounds in Kafka Streams&lt;/a&gt; showed what such a call can do to a thread's place in the group, run by run. This post is the other half: what to do about it. A blocking call caps a stream thread at one record per call, so we start by sizing the threads for it. Then we give every call an upper limit, and keep the slow step from holding up the rest of the stream.&lt;/p&gt;

&lt;h2&gt;
  
  
  Size the threads for the call
&lt;/h2&gt;

&lt;p&gt;Kafka Streams gives each input partition a &lt;a href="https://kafka.apache.org/42/streams/architecture/#stream-partitions-and-tasks" rel="noopener noreferrer"&gt;&lt;em&gt;task&lt;/em&gt;&lt;/a&gt; and spreads the tasks over its threads, as &lt;a href="https://www.alginte.com/blog/what-max-poll-interval-ms-bounds-in-kafka-streams/" rel="noopener noreferrer"&gt;the previous post&lt;/a&gt; opens with. A thread processes its tasks' records one at a time, so a blocking call blocks the thread it runs on.&lt;/p&gt;

&lt;p&gt;Our runs called a stub, a small HTTP server of ours that stands in for the service. It waits a set time and answers. Everything else was real: a broker, a Kafka Streams application, HTTP calls over the network, and real vulnerability records, CVEs (&lt;a href="https://www.alginte.com/blog/calling-an-external-service-from-kafka-streams/#how-we-measured" rel="noopener noreferrer"&gt;how we measured&lt;/a&gt;). The first run had three partitions and one thread, so three tasks took turns on it. The arithmetic is short: at 300 ms a call, a thread does 3.3 records a second, however fast records arrive. With 600 records arriving at 20 a second, we measured 3.2 to 3.4 a second, and the stub never saw more than one call in flight.&lt;/p&gt;

&lt;p&gt;More partitions alone do not help. A repartition to 32 before the call gives the calling step 32 tasks, and so does an input topic of 32 partitions, which is what we measured. With the same single thread, it still did 3.3 records a second, one call at a time.&lt;/p&gt;

&lt;p&gt;It needs threads as well: 32 stream threads (&lt;a href="https://kafka.apache.org/42/configuration/kafka-streams-configs/#streamsconfigs_num.stream.threads" rel="noopener noreferrer"&gt;&lt;code&gt;num.stream.threads&lt;/code&gt;&lt;/a&gt;), one per task of the calling step, counted across all the application's instances. At 300 ms a call, we measured 3.3 records a second with one thread, 26 with eight and 106 with 32. With 32, the stub saw 32 calls in flight at once. Of a backlog of 3,393 records, 3,247 were done after 35 seconds. The rest took until 50 seconds. The records were spread unevenly over the 32 partitions, so as the emptier ones ran out, their threads went idle and the fullest finished alone.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fo2yjvpyxk0blo5mu2u1k.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fo2yjvpyxk0blo5mu2u1k.png" alt="A column chart of records a second at 300 milliseconds per call. One thread did 3.3, eight threads 26.4, and 32 threads over 32 partitions 105.6, each close to its predicted value." width="800" height="426"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;How many threads a stream needs follows from &lt;a href="https://en.wikipedia.org/wiki/Little%27s_law" rel="noopener noreferrer"&gt;Little's law&lt;/a&gt;: calls in flight equal the input rate times the call time. What a number of threads can do, and how fast they clear a backlog, is then one division and one subtraction. With the numbers of our runs:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;calls in flight = input rate × call time
                    = 25 a second × 0.3 s = 7.5, so at least 8 threads

capacity = threads ÷ call time
  8 threads = 8 ÷ 0.3 s ≈ 27 a second (measured 26.4)
  32 threads = 32 ÷ 0.3 s ≈ 107 a second (measured 106)

backlog drains = capacity − input
  8 threads = 26.4 − 25 = 1.4 a second
  32 threads = 106 − 25 = 81 a second

service paused 45 s = 45 s × 25 a second ≈ 1,100 records
  8 threads = 1,100 ÷ 1.4 a second ≈ 13 minutes
  32 threads = 1,100 ÷ 81 a second ≈ 14 s (measured about 15)

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The pause is from our &lt;a href="https://www.alginte.com/blog/calling-an-external-service-from-kafka-streams/#keep-the-slow-step-in-its-own-stream" rel="noopener noreferrer"&gt;last run&lt;/a&gt;. We paused the service for 45 seconds, and the backlog grew to about 1,100 records.&lt;/p&gt;

&lt;p&gt;Eight threads only just keep up. The threads beyond that are headroom. Headroom is what clears a backlog, whatever builds it: a pause in the service, as in our run, a slow minute of it, a spike in the input or a restart. Headroom also covers a slower service. At 600 ms a call:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;  8 threads = 8 ÷ 0.6 s ≈ 13 a second, below the input
  32 threads = 32 ÷ 0.6 s ≈ 53 a second, above it

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;With 8 threads, the lag would grow by about 12 records a second. With 32, it would still drain.&lt;/p&gt;

&lt;h2&gt;
  
  
  Is the stream keeping up?
&lt;/h2&gt;

&lt;p&gt;Once a stream runs, the consumer group's lag tells you whether that sizing still holds. With Kafka Streams the lag needs reading with care.&lt;/p&gt;

&lt;p&gt;Operators watch a consumer group's lag: the LAG column of &lt;span&gt;&lt;code&gt;kafka-consumer-groups --describe&lt;/code&gt;&lt;/span&gt;, or a lag exporter's line on a Grafana dashboard. That lag comes from the group's &lt;em&gt;committed&lt;/em&gt; offsets, and Kafka Streams commits only every &lt;a href="https://kafka.apache.org/42/configuration/kafka-streams-configs/#streamsconfigs_commit.interval.ms" rel="noopener noreferrer"&gt;&lt;code&gt;commit.interval.ms&lt;/code&gt;&lt;/a&gt;, 30 seconds by default. So a stream that keeps up does not show zero lag. Between commits the committed offset stays put while records keep arriving, so the lag climbs. At each commit it drops. A stream that kept up with 25 records a second showed a lag climbing to about 750, which is 25 a second times 30 seconds. It dropped to almost nothing at each commit, every 30 seconds.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F36h8nouknu9jfttc3cyu.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F36h8nouknu9jfttc3cyu.png" alt="A line chart over 140 seconds of a stream that keeps up with 25 records a second. The records still to do stay near zero throughout. The committed lag climbs steadily to about 750 and drops to almost nothing at each commit, every 30 seconds, a sawtooth. A dashed line joins the readings a dashboard taking one every 20 seconds would get: 350, 260, 755, 480, 234, 721 and 468." width="800" height="439"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;A dashboard reads this curve at its own interval. Read every 20 seconds, the dashed line, the same healthy stream jumps between about 230 and 750. A reading just after a commit could show almost zero, but most readings show hundreds.&lt;/p&gt;

&lt;p&gt;The next chart adds a slow stream. It had a 300 ms call and received the same 25 records a second as the healthy one. It got through only 3.3 of them a second, one thread's worth at 300 ms a call. Its committed lag climbed to about 3,000 in 135 seconds and never came back down.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F8oahj6c0e35mbtmzbxe5.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F8oahj6c0e35mbtmzbxe5.png" alt="A line chart of committed lag over 140 seconds for two streams under the same input of 25 records a second. The healthy stream, with no service call, swings between zero and about 750 in a sawtooth. The slow stream, with a 300 millisecond service call and three tasks on one thread, climbs steadily to about 3,000, each commit only a small dent." width="800" height="439"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Lowering &lt;code&gt;commit.interval.ms&lt;/code&gt; makes the teeth smaller, but each commit is work. The thread flushes its producer and its store caches and writes its offsets to the broker, and it processes nothing meanwhile. Raising it to commit less often has a cost of its own for slow calls, which &lt;a href="https://www.alginte.com/blog/what-max-poll-interval-ms-bounds-in-kafka-streams/#the-commit-interval-is-the-other-clock" rel="noopener noreferrer"&gt;What max.poll.interval.ms bounds in Kafka Streams&lt;/a&gt; measures.&lt;/p&gt;

&lt;h2&gt;
  
  
  Give every call an upper limit
&lt;/h2&gt;

&lt;p&gt;Sizing the threads used the typical call time. The rules in the next section start from a different number: the longest a call can take, every retry included. We call it the call’s &lt;em&gt;budget&lt;/em&gt;. Without one, no setting keeps a thread in its group. A single call that never ends outlasts any poll interval.&lt;/p&gt;

&lt;p&gt;The budget is the whole call, not one setting. It is the time to connect, the wait for the answer, the time to read the answer's body, every retry, and the backoff between retries. A client that tries four times with a 5-second timeout and a second of backoff between tries has a budget of about 23 seconds, not 5.&lt;/p&gt;

&lt;p&gt;A timeout on the request is not always that budget. We checked the JDK's own &lt;code&gt;HttpClient&lt;/code&gt;, on Java 25, against our stub, with a 5-second &lt;a href="https://docs.oracle.com/en/java/javase/25/docs/api/java.net.http/java/net/http/HttpRequest.Builder.html#timeout(java.time.Duration)" rel="noopener noreferrer"&gt;request timeout&lt;/a&gt;. When the service sent nothing for 10 seconds, the call ended at 5.01 seconds. When it sent its headers at once and its body over 20 seconds, the call did not end. It returned normally after 20.05 seconds, with the whole body. The Javadoc says only that the timeout fires "if the response is not received within the specified timeout".&lt;/p&gt;

&lt;p&gt;What ended that call was a deadline around the whole of it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="nc"&gt;HttpResponse&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;callWithin&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;HttpRequest&lt;/span&gt; &lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;Duration&lt;/span&gt; &lt;span class="n"&gt;budget&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="kd"&gt;throws&lt;/span&gt; &lt;span class="nc"&gt;Exception&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="nc"&gt;CompletableFuture&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;HttpResponse&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;call&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;
            &lt;span class="n"&gt;http&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;sendAsync&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;BodyHandlers&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;ofString&lt;/span&gt;&lt;span class="o"&gt;());&lt;/span&gt;
    &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;call&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;get&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;budget&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;toMillis&lt;/span&gt;&lt;span class="o"&gt;(),&lt;/span&gt; &lt;span class="nc"&gt;TimeUnit&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;MILLISECONDS&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch&lt;/span&gt; &lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;TimeoutException&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;call&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;cancel&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
        &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The caller was free at 5.02 seconds. The cancel also closed the connection: the stub's next write, a second later, failed.&lt;/p&gt;

&lt;p&gt;Retries go inside the same deadline. Each try gets what is left of the budget, so the budget stays one number.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;With another client.&lt;/strong&gt; We ran one client, one call per case, and no retries. Check two things. Does its timeout end a call whose body arrives slowly? If not, put a deadline around the call. Does cancelling stop the call, or only free the caller?&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  The arithmetic on the budget
&lt;/h2&gt;

&lt;p&gt;A stream thread has to call &lt;code&gt;poll()&lt;/code&gt; at least every &lt;a href="https://kafka.apache.org/42/configuration/consumer-configs/#consumerconfigs_max.poll.interval.ms" rel="noopener noreferrer"&gt;&lt;code&gt;max.poll.interval.ms&lt;/code&gt;&lt;/a&gt;, 300 seconds by default, or the group takes its tasks away in a rebalance. So two limits apply between two polls, and each is a number of calls times the budget:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;round = tasks per thread × budget
poll's worth = max.poll.records × budget

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A &lt;a href="https://www.alginte.com/blog/what-max-poll-interval-ms-bounds-in-kafka-streams/#the-unit-is-the-round" rel="noopener noreferrer"&gt;round&lt;/a&gt; is a call for each of the thread's tasks. One poll returns up to &lt;code&gt;max.poll.records&lt;/code&gt; records, &lt;a href="https://www.alginte.com/blog/what-max-poll-interval-ms-bounds-in-kafka-streams/#when-a-fast-threads-service-slows-down" rel="noopener noreferrer"&gt;a poll's worth&lt;/a&gt;. How many of them the thread works through before it polls again depends on its history: while the service is fast, Kafka Streams lets it take more. A cap on &lt;code&gt;max.poll.records&lt;/code&gt; bounds that, whatever the history. The consumer's &lt;a href="https://kafka.apache.org/42/javadoc/org/apache/kafka/clients/consumer/KafkaConsumer.html#failuredetection" rel="noopener noreferrer"&gt;Javadoc&lt;/a&gt; gives the setting this job: to bound "the maximum that must be handled within each poll interval". Keep both under half of the poll interval, 150 seconds by default. The other half is margin: when an instance goes away, its tasks move to the others, and their rounds grow.&lt;/p&gt;

&lt;p&gt;For the stream we measured, 25 CVEs a second at 300 ms a call on 8 threads, with a 2-second budget:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;round
  8 partitions = 1 × 2 s = 2 s, under 150
  32 partitions = 4 × 2 s = 8 s, under 150

poll's worth
  1,000, the default = 1,000 × 2 s = 2,000 s, far over 300
  75 = 75 × 2 s = 150 s, just half

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;So &lt;code&gt;max.poll.records&lt;/code&gt; can be at most 150 s ÷ 2 s, 75. Set it there, or lower.&lt;/p&gt;

&lt;p&gt;The bound assumes every call takes its whole budget. That is not rare: when the service slows down, it slows down for every call at once. And even at the typical 300 ms, the default 1,000 records take 300 seconds, the whole poll interval.&lt;/p&gt;

&lt;p&gt;Tasks per thread is the calling step's partitions over its threads, all instances counted. Leave &lt;code&gt;commit.interval.ms&lt;/code&gt; at its default. If you raise it above a round, keep the round under a third of the poll interval instead.&lt;/p&gt;

&lt;p&gt;The table adds two more services. Their numbers are calculated, not measured: we did not run them. Their threads come from the typical call, by &lt;a href="https://www.alginte.com/blog/calling-an-external-service-from-kafka-streams/#size-the-threads-for-the-call" rel="noopener noreferrer"&gt;Little's law&lt;/a&gt;. Their two limits come from the budget. In the CVE column the rate, the call time and the threads are our runs, and the budget is an example.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;Fraud score&lt;/th&gt;
&lt;th&gt;CVE severity, our runs&lt;/th&gt;
&lt;th&gt;AI model decision&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Records a second&lt;/td&gt;
&lt;td&gt;200&lt;/td&gt;
&lt;td&gt;25&lt;/td&gt;
&lt;td&gt;5&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Typical call&lt;/td&gt;
&lt;td&gt;50 ms&lt;/td&gt;
&lt;td&gt;300 ms&lt;/td&gt;
&lt;td&gt;3 s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Calls in flight, rate × typical call&lt;/td&gt;
&lt;td&gt;10&lt;/td&gt;
&lt;td&gt;7.5&lt;/td&gt;
&lt;td&gt;15&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Threads, one task each&lt;/td&gt;
&lt;td&gt;12&lt;/td&gt;
&lt;td&gt;8&lt;/td&gt;
&lt;td&gt;16&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Budget, retries included&lt;/td&gt;
&lt;td&gt;0.5 s&lt;/td&gt;
&lt;td&gt;2 s&lt;/td&gt;
&lt;td&gt;20 s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Round, with one task per thread&lt;/td&gt;
&lt;td&gt;0.5 s&lt;/td&gt;
&lt;td&gt;2 s&lt;/td&gt;
&lt;td&gt;20 s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Poll's worth: &lt;code&gt;max.poll.records&lt;/code&gt;, at most 150 s ÷ budget&lt;/td&gt;
&lt;td&gt;300&lt;/td&gt;
&lt;td&gt;75&lt;/td&gt;
&lt;td&gt;7&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Even the fast service needs &lt;code&gt;max.poll.records&lt;/code&gt; cut, from 1,000 to 300. The AI model allows only 7 records a poll. That is the price of a 20-second budget, short of raising the poll interval.&lt;/p&gt;

&lt;p&gt;Break either limit and the cost is not lag. The thread is evicted, its tasks start again from the last commit, and every call since then is made again. If the service charges per call, you pay each time. We ran three tasks on one thread with a 12-second call, against a poll interval scaled down to 30 seconds. The thread was evicted four times in 210 seconds and never committed, and the output held 17 records for 7 CVEs, where each CVE should give one. &lt;a href="https://www.alginte.com/blog/what-max-poll-interval-ms-bounds-in-kafka-streams/" rel="noopener noreferrer"&gt;What max.poll.interval.ms bounds in Kafka Streams&lt;/a&gt; shows each bound run by run, and &lt;a href="https://www.alginte.com/blog/what-max-poll-interval-ms-bounds-in-kafka-streams/#the-levers-and-what-each-costs" rel="noopener noreferrer"&gt;weighs the other levers&lt;/a&gt;, raising the poll interval among them.&lt;/p&gt;

&lt;h2&gt;
  
  
  Keep the slow step in its own stream
&lt;/h2&gt;

&lt;p&gt;Threads are shared by all the steps of one application. So a fast step whose task lands on a thread with a slow one waits behind its calls. Two applications keep them apart. The first does the fast work and writes the records that need an answer to a topic. The second reads that topic, makes the call and routes the result. In our example, the first stream keeps the new CVEs that arrive without a severity score from their CNA, the organisation that assigned the CVE. The second asks the service for one.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fbkhkph9xj8na5q9svntx.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fbkhkph9xj8na5q9svntx.png" alt="Two stream topologies side by side. Stream A, which does the fast work: a source reading the cve-published topic, a filter keeping the records where value.get('cna_score') == null, and a sink named Questions writing to severity-requests. A dashed arrow labelled severity-requests leads to stream B, which makes the call. Stream B has a source named Questions reading severity-requests, a repartition named Spread to 32 partitions, a step named Ask the service, and a sink named Decided writing to cve-decided. Ask the service calls the severity service and puts its answer under decision." width="800" height="835"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;The two-stream design.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The last run tested that, and the timeout, together. The first stream passed records on with no call. The second, with 32 threads, called the service. With records arriving at 25 a second, we paused the service for 45 seconds. With a 60-second timeout, the second stream simply waited out the pause, and its backlog grew to about 1,100. When the service came back, it caught up at about 100 a second in about 15 seconds. Nothing failed, and nothing rebalanced. With a 5-second timeout, 31 calls timed out together. The second stream went to &lt;code&gt;ERROR&lt;/code&gt;, the client state that means it has stopped for good: Kafka Streams does not restart it, even after the service is back. In both runs the first stream never noticed.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fdygevfa71p5i9y1zj7yb.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fdygevfa71p5i9y1zj7yb.png" alt="A line chart of records done over 150 seconds with a shaded 45-second pause. Stream A, with no service call, rises steadily throughout. Stream B with a 60-second timeout flattens during the pause and then climbs steeply to rejoin stream A. Stream B with a 5-second timeout flattens during the pause, goes to ERROR at 53 seconds and never recovers." width="800" height="452"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The 5-second run stopped because of a default: a single call that throws sends the whole client to &lt;code&gt;ERROR&lt;/code&gt;, with no retry, and a single connection error does the same. &lt;a href="https://cwiki.apache.org/confluence/display/KAFKA/KIP-1033:+Add+Kafka+Streams+exception+handler+for+exceptions+occurring+during+processing" rel="noopener noreferrer"&gt;KIP-1033&lt;/a&gt; added a handler in Kafka 3.9 that can change that policy. What to change it to, skipping the record, retrying it or sending it to a dead-letter topic, is a subject of its own. Until you have one, set the budget from what the service needs, not shorter.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why not a separate worker
&lt;/h2&gt;

&lt;p&gt;Another common way to handle a slow call is to take it out of Kafka Streams entirely. In our example, stream B would become a worker: a separate program of your own, built on a plain Kafka consumer rather than on Kafka Streams. It reads &lt;code&gt;severity-requests&lt;/code&gt;, calls the service with many requests in flight at once, and writes the decided records to &lt;code&gt;cve-decided&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Stream B needs a thread for every call in flight, because each call blocks its thread. A worker can use an asynchronous client instead: one thread sends many requests and handles each answer as it comes. For a very slow service that matters. At 25 records a second and 20 seconds a call, Little's law asks for 500 calls in flight: 500 threads and 500 partitions for stream B, a few threads for a worker.&lt;/p&gt;

&lt;p&gt;In exchange, the worker has to do itself what Kafka Streams does for stream B, and all of it comes from the many calls it holds open. They finish in any order. So it commits an offset only when every earlier record of the partition is done, and keeps the order within a partition where that matters. And when it stops, or hands a partition to another instance, it has to finish or abandon the calls still open. Abandoned calls are made again by whoever reads the partition next.&lt;/p&gt;

&lt;p&gt;It is also a second kind of application to watch. Its consumer group's lag shows like any other. But whatever shows you your streams, their state, threads and tasks, does not see the worker. Its health, its calls in flight and its failures need watching of their own.&lt;/p&gt;

&lt;p&gt;Our call sits at the edge: stream B ends with it. A call in the middle of a stream, between a join and an aggregation, is harder to move out. The answers have to come back into the stream, joined to their records by key within a window. An answer later than the window needs a path of its own.&lt;/p&gt;

&lt;p&gt;Two tools sit between stream B and a worker of your own: Confluent's &lt;a href="https://www.confluent.io/blog/introducing-confluent-parallel-message-processing-client/" rel="noopener noreferrer"&gt;Parallel Consumer&lt;/a&gt; and Responsive's &lt;a href="https://github.com/responsivedev/kafka-streams-archive/tree/main/blog-archive/async-processing-for-kafka-streams" rel="noopener noreferrer"&gt;async processor&lt;/a&gt;. We measured neither of them, nor a worker. The Parallel Consumer is the usual building block for a worker: more calls in flight than partitions, with order kept per key. The async processor keeps the call inside Kafka Streams and hands it to a thread pool, again with order kept per key.&lt;/p&gt;

&lt;h2&gt;
  
  
  The short version
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;A blocking call caps a stream thread at one record per call: throughput is threads over call time, up to the partitions.&lt;/li&gt;
&lt;li&gt;Partitions alone do not help: a repartition without the threads to match only gives each thread more tasks.&lt;/li&gt;
&lt;li&gt;Give every call a budget: the whole call, retries and backoff included. Without one, no setting is safe.&lt;/li&gt;
&lt;li&gt;Keep a &lt;em&gt;round&lt;/em&gt;, the tasks per thread times the budget, and a poll's worth, &lt;code&gt;max.poll.records&lt;/code&gt; times the budget, under half of &lt;code&gt;max.poll.interval.ms&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;An eviction before a commit repeats the calls and writes duplicates.&lt;/li&gt;
&lt;li&gt;With the defaults, one failed call stops the whole client.&lt;/li&gt;
&lt;li&gt;Give the slow step its own stream, with the threads to match its partitions.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  How we measured
&lt;/h2&gt;

&lt;p&gt;We ran everything on one machine and one broker, against the stub, which waited a fixed time before it answered. A real service's time varies. Ours did not, so each run shows one latency at a time. The numbers describe the shape of the problem, not your cluster's. The records were real: newly published vulnerability entries, &lt;a href="https://www.cve.org/About/Overview" rel="noopener noreferrer"&gt;CVEs&lt;/a&gt;, from the &lt;a href="https://nvd.nist.gov/" rel="noopener noreferrer"&gt;National Vulnerability Database&lt;/a&gt;, replayed from one captured window. The runs used Kafka Streams 4.2.0 and the classic group protocol.&lt;/p&gt;

&lt;p&gt;The stub, the replay script and the application that measured all of this are not published. If you want to run them against your own numbers, write to &lt;a href="mailto:info@alginte.com"&gt;info@alginte.com&lt;/a&gt; and we will send them.&lt;/p&gt;

&lt;p&gt;This post uses the NVD API but is not endorsed or certified by the NVD.&lt;/p&gt;

&lt;h2&gt;
  
  
  Related reading
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Brice Leporini, &lt;a href="https://dev.to/bleporini/why-you-should-not-query-a-database-in-your-stream-processors-4829"&gt;Why you should not query a database in your stream processors&lt;/a&gt; (2022): the same problem from the database side, with a table join and a two-stage design as the ways out.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://cwiki.apache.org/confluence/display/KAFKA/KIP-442:+Return+to+default+max+poll+interval+in+Streams" rel="noopener noreferrer"&gt;KIP-442&lt;/a&gt;: until Kafka 2.3, Kafka Streams set &lt;code&gt;max.poll.interval.ms&lt;/code&gt; to effectively infinite, so older advice that a slow Streams application is never evicted predates it.&lt;/li&gt;
&lt;li&gt;Sébastien Viale, Loïc Greffier and Valérie Servaire, &lt;a href="https://blogit.michelin.io/processing-error-handling-in-kafka-streams/" rel="noopener noreferrer"&gt;Processing error handling in Kafka Streams&lt;/a&gt; (2025): the handler KIP-1033 added, written by two of its authors, with the built-in choices and the dead-letter queue support KIP-1034 added later.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;In the next posts of this series, the service is an AI model that makes a decision on each event. To get them, follow the blog's &lt;a href="https://www.alginte.com/rss.xml" rel="noopener noreferrer"&gt;RSS feed&lt;/a&gt; or &lt;a href="https://x.com/alginte_sw" rel="noopener noreferrer"&gt;@alginte_sw on X&lt;/a&gt;, where we post each one.&lt;/p&gt;

</description>
      <category>apachekafka</category>
      <category>kafkastreams</category>
      <category>java</category>
    </item>
    <item>
      <title>What max.poll.interval.ms bounds in Kafka Streams, measured</title>
      <dc:creator>alginte software</dc:creator>
      <pubDate>Wed, 21 Oct 2026 16:46:42 +0000</pubDate>
      <link>https://dev.to/alginte/what-maxpollintervalms-bounds-in-kafka-streams-measured-2nb1</link>
      <guid>https://dev.to/alginte/what-maxpollintervalms-bounds-in-kafka-streams-measured-2nb1</guid>
      <description>&lt;p&gt;Kafka Streams scales by dividing the work. Each partition of the input becomes a &lt;a href="https://kafka.apache.org/42/streams/architecture/#stream-partitions-and-tasks" rel="noopener noreferrer"&gt;&lt;em&gt;task&lt;/em&gt;&lt;/a&gt;. The tasks are spread over the &lt;a href="https://kafka.apache.org/42/streams/architecture/#threading-model" rel="noopener noreferrer"&gt;stream threads&lt;/a&gt; of all the instances that share an &lt;code&gt;application.id&lt;/code&gt;. Add an instance, or raise &lt;a href="https://kafka.apache.org/42/configuration/kafka-streams-configs/#streamsconfigs_num.stream.threads" rel="noopener noreferrer"&gt;&lt;code&gt;num.stream.threads&lt;/code&gt;&lt;/a&gt;, and the group hands the new threads some of the tasks.&lt;/p&gt;

&lt;p&gt;The threads share no state, so they need no coordination among themselves. The consumer group coordinates them instead. It distinguishes a working thread from a stuck one by the poll: a thread has to call &lt;a href="https://kafka.apache.org/42/javadoc/org/apache/kafka/clients/consumer/KafkaConsumer.html#detecting-consumer-failures-heading" rel="noopener noreferrer"&gt;&lt;code&gt;poll()&lt;/code&gt;&lt;/a&gt; at least every &lt;a href="https://kafka.apache.org/42/configuration/consumer-configs/#consumerconfigs_max.poll.interval.ms" rel="noopener noreferrer"&gt;&lt;code&gt;max.poll.interval.ms&lt;/code&gt;&lt;/a&gt;. If it does not, the group &lt;a href="https://kafka.apache.org/42/javadoc/org/apache/kafka/clients/consumer/KafkaConsumer.html#consumer-groups-and-topic-subscriptions-heading" rel="noopener noreferrer"&gt;rebalances&lt;/a&gt;. It takes the thread's tasks away and hands them to the others.&lt;/p&gt;

&lt;p&gt;A rebalance has a cost. Whatever a thread had done since its last commit is done again by the next owner of its tasks. A stateful task that moves to another instance cannot resume until its state stores are restored from their changelog topics. Kafka's docs name that restoration as the main cost of moving a task (&lt;a href="https://kafka.apache.org/42/streams/architecture/#fault-tolerance" rel="noopener noreferrer"&gt;Fault Tolerance&lt;/a&gt;). A &lt;a href="https://kafka.apache.org/42/configuration/kafka-streams-configs/#streamsconfigs_num.standby.replicas" rel="noopener noreferrer"&gt;standby replica&lt;/a&gt; shortens it. Another instance keeps a copy of the state up to date, and the task moves there. Standbys are off by default.&lt;/p&gt;

&lt;p&gt;When you add an instance, the rebalance that follows is worth its cost. When a slow thread causes one, it is best avoided. This post is about the second kind.&lt;/p&gt;

&lt;p&gt;Polling in time is easy while each record takes microseconds or milliseconds. A remote call changes that. Each record takes as long as the service takes to answer, or until a timeout cuts the call off. The thread waits either way. This is not yet the common case for Kafka Streams, and the standard advice is not to block a stream thread at all. On the other hand, we expect such calls to become more common. More teams now call external services on events as they arrive: an AI model's API above all, but also a scoring service, or anything else that answers in seconds.&lt;/p&gt;

&lt;p&gt;A service's answer time also changes over the day. It answers quickly when it is quiet and slowly when it is busy. It is often busiest just when your stream has the most to send it. A slow service is a known hazard. A service that &lt;em&gt;becomes&lt;/em&gt; slow is a different one. It hurts the threads that had been keeping up, not the ones that were already behind. We come to why &lt;a href="https://www.alginte.com/blog/what-max-poll-interval-ms-bounds-in-kafka-streams/#when-a-fast-threads-service-slows-down" rel="noopener noreferrer"&gt;later in the post&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;The usual reading is that &lt;code&gt;max.poll.interval.ms&lt;/code&gt; bounds one call. Keep each call under it, and the thread keeps its tasks. It bounds something larger. On Kafka Streams 4.2.0, we saw threads &lt;em&gt;evicted&lt;/em&gt; from the group even though every call took well under the poll interval. Here is what we found, with the section that shows each:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The poll interval bounds more than one call. A thread with several tasks makes a call for each of them between two polls (&lt;a href="https://www.alginte.com/blog/what-max-poll-interval-ms-bounds-in-kafka-streams/#the-unit-is-the-round" rel="noopener noreferrer"&gt;the round&lt;/a&gt;).&lt;/li&gt;
&lt;li&gt;A thread that had been fast was evicted again and again once its service slowed, at the default settings (&lt;a href="https://www.alginte.com/blog/what-max-poll-interval-ms-bounds-in-kafka-streams/#when-a-fast-threads-service-slows-down" rel="noopener noreferrer"&gt;a fast thread's service slows down&lt;/a&gt;).&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Two things depend on the group, not the thread. A rebalance opened by another member can evict a thread whose settings are safe. And under Kafka's newer streams protocol, what an eviction costs depends on who else is in the group. Both are measured in a coming post.&lt;/p&gt;

&lt;p&gt;We measured each case in a run, and predicted each run before it ran. Every run used a single machine with one broker and a stub HTTP service whose latency we set. Every run used the classic group protocol, Kafka Streams' default &lt;a href="https://kafka.apache.org/42/configuration/kafka-streams-configs/#streamsconfigs_group.protocol" rel="noopener noreferrer"&gt;&lt;code&gt;group.protocol&lt;/code&gt;&lt;/a&gt;. A &lt;a href="https://kafka.apache.org/42/javadoc/org/apache/kafka/streams/kstream/KStream.html#mapValues(org.apache.kafka.streams.kstream.ValueMapper)" rel="noopener noreferrer"&gt;&lt;code&gt;mapValues&lt;/code&gt;&lt;/a&gt; called it and blocked until it answered. The use case is illustrative: a stream that sends each new security advisory, a CVE, to a service that scores it. The records were real, newly published CVEs, replayed from one captured window. The numbers describe the shape of the problem, not your cluster. The method, and the predictions that were wrong, are in &lt;a href="https://www.alginte.com/blog/what-max-poll-interval-ms-bounds-in-kafka-streams/#how-the-numbers-were-made" rel="noopener noreferrer"&gt;How the numbers were made&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  The stream thread's loop
&lt;/h2&gt;

&lt;p&gt;Matthias J. Sax explains the stream thread's loop in his talk &lt;a href="https://www.youtube.com/watch?v=2_-WoWlAD5M" rel="noopener noreferrer"&gt;The Nuts and Bolts of Kafka Streams&lt;/a&gt;. From about nine minutes in, he covers the buffers, the bursts of records per task and why their size is adjusted. We build on his picture and follow one case: what that adjustment does to a thread whose every record waits on a slow call.&lt;/p&gt;

&lt;p&gt;Between two polls, the thread has work that comes due. A commit is due once the commit interval has passed, and a poll before the poll interval runs out. A punctuation is due when it is scheduled. Checking for due work after every record would cost time. Checking too rarely would make that work late. So the thread works in &lt;em&gt;passes&lt;/em&gt; of up to N records per task and checks after each pass. While nothing is due, it adds one to N, and the passes grow. When something comes due, it halves N, so the next check comes sooner. It is the rule TCP uses for its &lt;a href="https://www.rfc-editor.org/rfc/rfc5681#section-3.1" rel="noopener noreferrer"&gt;congestion window&lt;/a&gt;: grow slowly, back off fast. The rule assumes a record is cheap, microseconds or milliseconds. A remote call breaks that assumption. The rest of this post follows what happens then, and which settings keep a thread in its group.&lt;/p&gt;

&lt;p&gt;A poll hands its records to queues, one for each task and input partition. When a queue holds more than &lt;a href="https://kafka.apache.org/42/configuration/kafka-streams-configs/#streamsconfigs_buffered.records.per.partition" rel="noopener noreferrer"&gt;&lt;code&gt;buffered.records.per.partition&lt;/code&gt;&lt;/a&gt; records, 1,000 by default, its partition is paused (&lt;a href="https://github.com/apache/kafka/blob/4.2.0/streams/src/main/java/org/apache/kafka/streams/processor/internals/StreamTask.java#L1167" rel="noopener noreferrer"&gt;&lt;code&gt;StreamTask&lt;/code&gt;&lt;/a&gt;). The thread stops fetching from it until the queue is back within the limit. It still has to poll in time. In a pass, the tasks take turns. Each runs up to N records from its queue, one after another, before the next task starts (&lt;a href="https://github.com/apache/kafka/blob/4.2.0/streams/src/main/java/org/apache/kafka/streams/processor/internals/TaskExecutor.java#L67-L97" rel="noopener noreferrer"&gt;&lt;code&gt;TaskExecutor&lt;/code&gt;&lt;/a&gt;). With three tasks, A, B and C, and N at 2, a pass runs two records from each in turn: A, A, B, B, C, C. After each pass, the thread decides what comes next (&lt;a href="https://github.com/apache/kafka/blob/4.2.0/streams/src/main/java/org/apache/kafka/streams/processor/internals/StreamThread.java#L1298-L1320" rel="noopener noreferrer"&gt;&lt;code&gt;StreamThread&lt;/code&gt;, lines 1298 to 1320&lt;/a&gt;).&lt;/p&gt;

&lt;p id="the-loop"&gt;The loop: what a stream thread does after each pass, in Kafka Streams 4.2.0.&lt;/p&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;after each pass:
  run any punctuation that is due
  commit, if commit.interval.ms has passed on the commit clock
  if the pass processed nothing:
      go back and poll
  else if the time since the last poll is past half of max.poll.interval.ms:
      halve N, and go back and poll
  else if the pass committed or punctuated:
      halve N, and run another pass
  else:
      add one to N, and run another pass
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The figure below follows the loop for three tasks at the default settings. Each call takes 40 seconds, longer than most, so that every pass is wide enough to see. Every pass takes longer than the 30-second commit interval, so every pass ends in a commit. Each commit halves N, and N never drops below 1, so each pass runs one record per task. The first pass ends at 120 seconds, short of the half-way mark, and the thread runs another. The second ends at 240 seconds, past the mark, and the thread polls with 60 seconds to spare.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F5poqf7tgvgjybie12uai.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F5poqf7tgvgjybie12uai.png" alt="A computed timeline of one thread, not a trace of a run. Three tasks, A, B and C, each have a queue of records. A poll fills the queues, and a slow call never empties them. Each call takes 40 seconds. The commit interval is at its default of 30 seconds. max.poll.interval.ms is at its default of 300 seconds, with a dashed line at its half, 150 seconds. Each pass runs one record from each task, A, B, C, and ends in a commit, so N stays at 1. The pass that ends at 240 seconds is past the half-way mark, and the thread polls." width="800" height="400"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Almog Gavra's &lt;a href="https://github.com/responsivedev/kafka-streams-archive/blob/27b88a314ce79cb0fa0d1c64ebc47c5cc2a2dff5/blog-archive/important-configs-kafka-streams-101/page.mdx?plain=1#L144" rel="noopener noreferrer"&gt;Kafka Streams 101 on configs&lt;/a&gt; names the half-interval check. Two facts the rest of this post leans on are less known. N starts at 1 when the thread starts (&lt;a href="https://github.com/apache/kafka/blob/4.2.0/streams/src/main/java/org/apache/kafka/streams/processor/internals/StreamThread.java#L877" rel="noopener noreferrer"&gt;line 877&lt;/a&gt;) and is never reset. It is carried from one poll to the next and, as we will see, across a rebalance. And a pass processes nothing only when none of its tasks has a record it can process. With a slow call and a backlog there always is one, so only the half-interval check sends the thread back to poll.&lt;/p&gt;

&lt;p&gt;This is the default loop of 4.2.0. A second one, with processing threads, sits behind an internal flag that &lt;a href="https://github.com/apache/kafka/blob/4.2.0/streams/src/main/java/org/apache/kafka/streams/StreamsConfig.java#L1320-L1328" rel="noopener noreferrer"&gt;defaults to false&lt;/a&gt;. The code itself describes it as incomplete, and nothing here applies to it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The unit is the round
&lt;/h2&gt;

&lt;p&gt;A thread polls only after a whole pass, so what has to fit inside &lt;code&gt;max.poll.interval.ms&lt;/code&gt; is not one call. It is at least one record of each of the thread's tasks, one after another. We call that a &lt;em&gt;round&lt;/em&gt;: with T tasks on a thread and a call of L seconds, about T x L. A. Sophie Blee-Goldman put it in one sentence in &lt;a href="https://github.com/responsivedev/kafka-streams-archive/blob/27b88a314ce79cb0fa0d1c64ebc47c5cc2a2dff5/blog-archive/performance-optimization-kafka-streams-101/page.mdx?plain=1#L71" rel="noopener noreferrer"&gt;Kafka Streams 101: Optimizing your apps&lt;/a&gt;: a thread makes progress as long as it processes one record per task within the poll interval.&lt;/p&gt;

&lt;p&gt;The round grows with the tasks on a thread, as the figure below shows. An input topic of 32 partitions gave a single thread 32 tasks, and a round of 32 calls. With 32 threads, each had one task, and a round of one call.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fluze8kw48cblvzitrhiv.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fluze8kw48cblvzitrhiv.png" alt="Three rows of 300 millisecond calls on one time axis of 9.6 seconds, each call a block coloured by the task it serves. With 3 partitions and one thread, the three tasks take turns, one call each, so a round is 3 calls, 0.9 seconds. With 32 partitions and one thread, a round is 32 calls, 9.6 seconds. With 32 partitions and 32 threads, each thread has one task, so a round is one call, 0.3 seconds. The 32 threads call at the same time." width="800" height="545"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;In the figure above, every round fits the poll interval with room to spare, so a slow call costs only throughput. A round longer than the poll interval costs more. To show it, we set up a case on purpose: three tasks on one thread, a 12-second call, and a 30-second poll interval, so a round takes 36 seconds. The figure below draws its first pass. Every call fits well inside the poll interval, even inside half of it. The round does not, and at 30 seconds the client leaves the group.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fo6ftn5rij8axer0hk30p.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fo6ftn5rij8axer0hk30p.png" alt="A computed timeline of one thread, not a trace of a run. Three tasks, A, B and C, take 12 seconds a call. max.poll.interval.ms is scaled to 30 seconds, with a dashed line at its half, 15 seconds. The first pass runs A, B and C, one round of 36 seconds. Each call is shorter than half the poll interval. At 30 seconds, during the call for C, the client leaves the group. The thread's poll at 36 seconds is too late." width="799" height="348"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;We stopped the run after 210 seconds. In that time the thread was evicted four times and never committed. Each CVE should give one output record, but the thread had written 17 records for only 7 CVEs, the first few of each partition, again and again. Left running, it would have written more records for the same 7, and never reached the rest of the backlog.&lt;/p&gt;

&lt;p&gt;The 30-second poll interval is the default of 300 scaled down tenfold, only to keep the run short. Later sections call these runs the scaled runs. At the default, the same case is thirty tasks and a 12-second call. Thirty tasks is what the default of one thread gets from a topic with thirty partitions, and an AI model writing a long answer can take 12 seconds. Or it is three tasks and a 120-second call. A call can take that long when the service is overloaded, or when the client retries with backoff. Several later runs use a 120-second call on a single task. What counts is the round, and ten tasks at 12 seconds make the same 120-second round. One task is simply easier to follow.&lt;/p&gt;

&lt;h2&gt;
  
  
  The commit interval is the other clock
&lt;/h2&gt;

&lt;p&gt;With slow calls, leave &lt;code&gt;commit.interval.ms&lt;/code&gt; at its default. Raising it saves little, and it opens a way to be evicted. The mechanism still matters, because a pass without a commit is also how a later case ends in an eviction: &lt;a href="https://www.alginte.com/blog/what-max-poll-interval-ms-bounds-in-kafka-streams/#when-a-fast-threads-service-slows-down" rel="noopener noreferrer"&gt;a thread whose service slows&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;The round is not the whole story. How many rounds a thread runs before it polls depends on commits, as &lt;a href="https://www.alginte.com/blog/what-max-poll-interval-ms-bounds-in-kafka-streams/#the-loop" rel="noopener noreferrer"&gt;the loop&lt;/a&gt; shows. A commit is due when &lt;a href="https://kafka.apache.org/42/configuration/kafka-streams-configs/#streamsconfigs_commit.interval.ms" rel="noopener noreferrer"&gt;&lt;code&gt;commit.interval.ms&lt;/code&gt;&lt;/a&gt; has passed on the thread's commit clock, which starts at the last commit (&lt;a href="https://github.com/apache/kafka/blob/4.2.0/streams/src/main/java/org/apache/kafka/streams/processor/internals/StreamThread.java#L1861-L1885" rel="noopener noreferrer"&gt;&lt;code&gt;maybeCommit&lt;/code&gt;&lt;/a&gt;). A pass that commits halves N, the number of records each task runs in a pass. A pass without a commit grows it.&lt;/p&gt;

&lt;p&gt;The defaults are a 30-second commit interval against a 300-second poll interval. Every pass longer than 30 seconds then commits, so N stays at 1, and a thread whose round fits the poll interval polls in time. That holds for a latency that has been steady since the thread started, and in a group with no rebalance under way. The first condition comes back below, and the second in a coming post. We checked it without scaling: one task, a 120-second call, 15 minutes. The thread committed after every record and never rebalanced, and the output held 7 records of 7 CVEs.&lt;/p&gt;

&lt;p&gt;Each commit costs work: the thread flushes its state stores and writes its offsets, and processes nothing meanwhile. Raising &lt;code&gt;commit.interval.ms&lt;/code&gt; saves some of that, which can buy throughput. With calls that take seconds, the saving is small, and the cost is what follows.&lt;/p&gt;

&lt;p&gt;Raise &lt;code&gt;commit.interval.ms&lt;/code&gt; above the length of a round, and a pass can end with no commit due. We ran the same one-task, 120-second call with both intervals at 300 seconds. The first pass after the poll ended at 120 seconds. That was not past the half-way mark of 150, and no commit was due. So N went to 2, and the next pass took two records, another 240 seconds. The thread could poll again only at 360 seconds, past the poll interval. A rebalance followed at 363.8 seconds, and the output held 7 records for 4 CVEs.&lt;/p&gt;

&lt;p&gt;We call the range this round falls in the &lt;em&gt;band&lt;/em&gt;: longer than a third of the poll interval, shorter than half. A round in the band ends its first pass before the half-way mark, so the thread does not poll. With no commit due, N goes to 2, and the second pass takes two rounds. The thread can poll only after three rounds, and three rounds run past the interval. A round under a third survives, because three rounds still fit. A round over half survives too, because its first pass crosses the half-way mark and the thread polls.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fotplj04yuxbamvbc4cep.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fotplj04yuxbamvbc4cep.png" alt="A computed timeline of the same thread as the loop's figure, not a trace of a run. The three tasks take 40 seconds a call, and the commit interval is 300 seconds. The first pass ends at 120 seconds with no commit due, so N goes to 2. The second pass runs A, A, B, B, C, C from 120 to 360 seconds. The half-way mark of max.poll.interval.ms, 150 seconds, falls inside it. At 300 seconds the client leaves the group. The thread's poll at 360 seconds is too late." width="799" height="348"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;It is the client that leaves. When the poll timer expires, its own heartbeat thread sends the leave, and the processing thread learns of it at its next commit.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fs96sfuk4nuu7szfgw22b.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fs96sfuk4nuu7szfgw22b.png" alt="Two small charts side by side, one task, 120 seconds per call, the default 300-second poll interval. Left, a commit every 30 seconds: records done and records committed rise together in steps, one record every 120 seconds, with no rebalance. The output topic held 7 records of 7 distinct keys. Right, a commit every 300 seconds: records done rise in the same steps, a rebalance is marked at 364 seconds, and records committed stay at zero until about 725 seconds. The output topic held 7 records of 4 distinct keys." width="800" height="497"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Runs on both sides of the band agree. In the scaled runs, the commit interval was left at 30 seconds against a 30-second poll interval, so commits were rare by accident. A round of 12 seconds (0.4 of the poll interval) was evicted and wrote 96 duplicates in 256 records. Rounds of 9 seconds (0.3) and 17 seconds (0.57) were not. One case needs care even at the defaults. A round just under half polls only after two rounds, seconds before the poll interval runs out. We measured the band with the commit interval equal to the poll interval. Other settings above the round follow from &lt;a href="https://www.alginte.com/blog/what-max-poll-interval-ms-bounds-in-kafka-streams/#the-loop" rel="noopener noreferrer"&gt;the loop&lt;/a&gt;, not from a run.&lt;/p&gt;

&lt;p&gt;The same clock explains a smaller oddity. Commits land later than the commit interval says: about 35 seconds apart with a 300 ms call, and 35 to 40 with a 3-second one in a scaled run. A commit waits for the pass in progress, and between commits the passes grow by a round each. Apurva Mehta and A. Sophie Blee-Goldman's &lt;a href="https://github.com/responsivedev/kafka-streams-archive/blob/27b88a314ce79cb0fa0d1c64ebc47c5cc2a2dff5/blog-archive/guide-to-kafka-streams-exactly-once-transactions/page.mdx?plain=1#L157" rel="noopener noreferrer"&gt;exactly-once guide&lt;/a&gt; calls the commit interval a lower bound, and this is why.&lt;/p&gt;

&lt;h2&gt;
  
  
  After an eviction
&lt;/h2&gt;

&lt;p&gt;Go back to the band run of the last section: one task, a 120-second call, and both intervals at 300 seconds. It was evicted, and rebalanced at 363.8 seconds. Our first reading of &lt;a href="https://www.alginte.com/blog/what-max-poll-interval-ms-bounds-in-kafka-streams/#the-loop" rel="noopener noreferrer"&gt;the loop&lt;/a&gt; said it would then be evicted every time it rejoined, since it would start the same way again. It was not. After rejoining, it kept its place, because a rebalance is not a fresh start. One thing survives it that a restart would reset, and one thing restarts.&lt;/p&gt;

&lt;p&gt;N survives. The failed commit that reveals the eviction throws from the commit check, before N is adjusted, and N is reset only when the thread starts. So the first pass after rejoining took two records, 240 seconds, crossed the half-way mark and polled in time.&lt;/p&gt;

&lt;p&gt;The commit clock restarts. The clock counts the time since the last commit. Once it passes the commit interval, the next commit check commits, and restarts the clock even when there is nothing to commit (&lt;a href="https://github.com/apache/kafka/blob/4.2.0/streams/src/main/java/org/apache/kafka/streams/processor/internals/StreamThread.java#L1881" rel="noopener noreferrer"&gt;line 1881&lt;/a&gt;). After the rejoin, the task needs a moment to start, and the thread runs a few passes that process nothing. The first of them finds a commit overdue, commits nothing and restarts the clock. So the next real commit falls due 300 seconds after the rejoin. The log does not show those empty passes. We inferred them because nothing else fits the timings, and we predicted the next eviction from them.&lt;/p&gt;

&lt;p&gt;We ran the same settings again for 1,500 seconds, and they did what that predicted. The thread rebalanced at 363.4 and 1,203.9 seconds, committed once between them at about 725, and wrote 12 records for 7 CVEs. A thread in the band is evicted again, but not every time it rejoins. When depends on where N and the commit clock stand. Each eviction costs the calls made since the last commit, made and written again.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fygu7aj05obl46il0d9c7.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fygu7aj05obl46il0d9c7.png" alt="A line chart over 1,500 seconds of one task at 120 seconds per call, with a 300-second poll interval and a 300-second commit interval. Records done rise by one every 120 seconds to 12. Two rebalances are marked, at 363 and 1,204 seconds. Records committed stay at zero until about 725 seconds, rise to 3 and stay there to the end. The output topic held 12 records, 7 of them distinct." width="800" height="432"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  When a fast thread's service slows down
&lt;/h2&gt;

&lt;p&gt;That N survives a rebalance has a consequence that matters more than the band, and it needs no setting raised. N has no upper limit in the code. It grows by one after every pass that neither committed nor punctuated, however few records the pass held. A stream that keeps up therefore carries a large N. Ours, at 25 records a second with the service answering at once, reached 221 in two minutes. A pass runs at most N records per task, but only the records waiting in the queues. So a large N means a pass takes everything waiting.&lt;/p&gt;

&lt;p&gt;A commit halves N only when the check falls on a pass that held records. On an idle pass it leaves N alone, and most passes of a stream that keeps up are idle. In another run of the same two minutes, one check did land on a pass with records, and N ended at 124. Kafka logs N at DEBUG in every pass, as "iterations", which is where these numbers come from (&lt;a href="https://github.com/apache/kafka/blob/4.2.0/streams/src/main/java/org/apache/kafka/streams/processor/internals/StreamThread.java#L1283-L1285" rel="noopener noreferrer"&gt;line 1283&lt;/a&gt;).&lt;/p&gt;

&lt;p&gt;Then we slowed the service to 10 seconds a call, a thirtieth of the poll interval, with 40 records waiting and every setting at its default. The thread took the 40 as one pass of 400 seconds, with no look at the clock inside it. The poll timer expired at 300. The pass's commit, the first attempt in 415 seconds, failed. N was 222 after the rebalance as before it, so the next pass was the same records again. It was evicted every time it rejoined: 401, 811 and 1,222 seconds after the slow-down, and a fourth was under way when the run ended. In all, 155 records were written for 41 CVEs, 154 of them from calls of 10 seconds, and not one was committed.&lt;/p&gt;

&lt;p&gt;The same 40 records at the same 10 seconds were no trouble for a thread that starts on them. N went 1, 2, 1, 2, with a commit after every second pass. There was no rebalance, and all 40 were done in about 400 seconds. The two threads differ only in their history. No setting states it, and nothing about the slow call shows it.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fddk5x6ba5y97xqhmyis5.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fddk5x6ba5y97xqhmyis5.png" alt="A line chart over 1,600 seconds from the moment a backlog of 40 records was there, with a call of 10 seconds, one task and default settings. The calls made by a thread that had been running fast rise steadily to 154, with evictions marked at 401, 811 and 1,222 seconds, while its committed records stay at zero to the end. The records done by a thread that starts on the same 40 rise to 40 by about 400 seconds and stop, with its committed records close behind." width="800" height="452"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Ten seconds after an instant answer is an extreme slow-down. We chose it so that one run would be short and leave no doubt. The arithmetic of &lt;a href="https://www.alginte.com/blog/what-max-poll-interval-ms-bounds-in-kafka-streams/#the-loop" rel="noopener noreferrer"&gt;the loop&lt;/a&gt; puts the line much lower. With N at 222 and that many records waiting, a pass is 222 calls back to back. It has to end within the 300-second poll interval, so each call can take at most 300 ÷ 222, about 1.35 seconds. Any slower, and one pass outlasts the poll interval. The 222 is measured. The 1.35 seconds is only that division: we did not run a slow-down to it.&lt;/p&gt;

&lt;p&gt;One more condition belongs here. We placed the backlog whole: the thread was held in one call while the 40 arrived, so that its next poll returned all of them. A backlog that builds by itself reaches the thread one poll at a time. Each pass then takes what arrived during the one before. When records arrive faster than the service answers, each pass is longer than the last. Arriving more slowly is not enough on its own: a backlog already waiting is taken in one pass, up to &lt;code&gt;max.poll.records&lt;/code&gt; of it, and that pass still has to fit the poll interval. This follows from &lt;a href="https://www.alginte.com/blog/what-max-poll-interval-ms-bounds-in-kafka-streams/#the-loop" rel="noopener noreferrer"&gt;the loop&lt;/a&gt;, and we did not run it either.&lt;/p&gt;

&lt;p&gt;The thread does not get out by itself. Each time it rejoins, N is still 222, the pass is the same 40 records, and the commit that would halve N is the one that fails. Only a new thread starts N at 1 again. Restarting the instance gives one, and so does replacing the thread at runtime with &lt;a href="https://kafka.apache.org/42/javadoc/org/apache/kafka/streams/KafkaStreams.html#removeStreamThread()" rel="noopener noreferrer"&gt;&lt;code&gt;removeStreamThread()&lt;/code&gt;&lt;/a&gt; and &lt;a href="https://kafka.apache.org/42/javadoc/org/apache/kafka/streams/KafkaStreams.html#addStreamThread()" rel="noopener noreferrer"&gt;&lt;code&gt;addStreamThread()&lt;/code&gt;&lt;/a&gt;. The thread that started on the backlog shows what follows. A restart redoes the uncommitted records once. It is a cure, not a prevention: once the service is fast again, N grows back, and the next slow-down sets the same trap. To stay out, cap &lt;code&gt;max.poll.records&lt;/code&gt;, so that a poll's worth of slow calls fits inside the poll interval. It saved this thread with nothing else changed, as &lt;a href="https://www.alginte.com/blog/what-max-poll-interval-ms-bounds-in-kafka-streams/#the-levers-and-what-each-costs" rel="noopener noreferrer"&gt;the levers&lt;/a&gt; below show.&lt;/p&gt;

&lt;h2&gt;
  
  
  The levers, and what each costs
&lt;/h2&gt;

&lt;p&gt;&lt;em&gt;&lt;code&gt;max.poll.interval.ms&lt;/code&gt;.&lt;/em&gt; Raising it lets a longer round fit, so a thread that was being evicted keeps its place and its calls are not made again. It adds no throughput: a stream that was being evicted falls behind quietly instead. And it has two costs.&lt;/p&gt;

&lt;p&gt;The first: a thread that is alive but stuck, on a call with no timeout, keeps its partitions until the poll interval runs out. A process that dies is still noticed sooner, by the &lt;a href="https://kafka.apache.org/42/configuration/consumer-configs/#consumerconfigs_session.timeout.ms" rel="noopener noreferrer"&gt;session timeout&lt;/a&gt;, because heartbeats come from a thread of their own.&lt;/p&gt;

&lt;p&gt;The second: with the classic protocol, the default, a rebalance waits for every member to rejoin, up to the poll interval, as Blee-Goldman's &lt;a href="https://github.com/responsivedev/kafka-streams-archive/blob/27b88a314ce79cb0fa0d1c64ebc47c5cc2a2dff5/blog-archive/guide-to-kafka-streams-rebalancing/page.mdx?plain=1#L481-L482" rel="noopener noreferrer"&gt;guide to rebalancing&lt;/a&gt; describes. A busy thread rejoins only after the pass that takes it past half the poll interval. So with a 30-minute poll interval, a new instance can wait 15 minutes for its tasks, and longer by however long that pass takes. That follows from the loop, and we did not measure it. Until Kafka 2.3, Streams set this interval to effectively infinite. &lt;a href="https://cwiki.apache.org/confluence/display/KAFKA/KIP-442:+Return+to+default+max+poll+interval+in+Streams" rel="noopener noreferrer"&gt;KIP-442&lt;/a&gt; returned it to the consumer's default.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;&lt;code&gt;commit.interval.ms&lt;/code&gt;.&lt;/em&gt; Lowering it means more commits, and each one is a flush and an offset write during which the thread processes nothing. Raising it above the length of a round opens the band from a third to a half of the poll interval. Keep a round under a third then.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;&lt;code&gt;max.poll.records&lt;/code&gt;.&lt;/em&gt; Streams sets it to 1,000, and every run but one left it there. It caps what one poll hands the thread. For a thread whose N is larger than that, it caps the pass. We ran the fast stream that slows once more, with nothing changed but this setting, at 20. The poll returned 20 of the 40 waiting records, and the pass was 200 seconds. Its commit went through with 100 seconds to spare and halved N, and the other 20 went the same way. There was no eviction, and each record was done once, where at the default it had been evicted every time it rejoined.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://issues.apache.org/jira/browse/KAFKA-14382" rel="noopener noreferrer"&gt;KAFKA-14382&lt;/a&gt; names lowering it as a workaround that shortens the processing loop. This is that, measured for one value. By &lt;a href="https://www.alginte.com/blog/what-max-poll-interval-ms-bounds-in-kafka-streams/#the-loop" rel="noopener noreferrer"&gt;the loop&lt;/a&gt;, the rule is that a poll's worth of calls has to fit the poll interval: &lt;code&gt;max.poll.records&lt;/code&gt; times the call. What it costs a stream while it is fast, more polls for the same records, we did not measure.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;More instances.&lt;/em&gt; They add threads, and threads add throughput when a round fits. They do not rescue a round in the band. A coming post shows that a second instance made the repeated work worse.&lt;/p&gt;

&lt;p&gt;For a long call, then, these settings keep a thread in its group. Start with a timeout on every call, retries included. The other settings rest on it: without a timeout, no record has an upper limit, and no setting is safe. Keep &lt;code&gt;commit.interval.ms&lt;/code&gt; at its default, below a round, so every pass commits and N stays at 1. Give each thread few enough tasks that a round fits well inside the poll interval. Cap &lt;code&gt;max.poll.records&lt;/code&gt; so that a poll's worth of calls at the timeout fits too. That cap is what kept the thread whose service slowed. Raise &lt;code&gt;max.poll.interval.ms&lt;/code&gt; last, for the costs above.&lt;/p&gt;

&lt;h2&gt;
  
  
  What we did not measure
&lt;/h2&gt;

&lt;p&gt;A single machine with one broker, and a stub whose latency has no tail. A round just under half the poll interval has no margin for one.&lt;/p&gt;

&lt;p&gt;A latency that changes, beyond one case. Every run but two held the call's latency steady from the start. The fast stream that slows was run twice, at 10 seconds, on one task, with its backlog placed whole: once at the default &lt;code&gt;max.poll.records&lt;/code&gt;, and once at 20. A slow-down that comes gradually, a backlog that builds by itself, a service that recovers while a pass is under way, and other latencies are readings of the loop.&lt;/p&gt;

&lt;p&gt;Exactly-once. Every run was at-least-once. A transactional commit takes another path through the broker, and the commit interval's default is different.&lt;/p&gt;

&lt;p&gt;Kafka Streams 4.2.0 only. In that version, restoration runs on a separate state-updater thread by default, which is one way the loop can differ from older versions. We make no claim about any version whose source we have not read. Nor do we claim anything about the loop with processing threads, which 4.2.0 keeps switched off.&lt;/p&gt;

&lt;p&gt;And one thing we saw and have not explained. In the run of &lt;a href="https://www.alginte.com/blog/what-max-poll-interval-ms-bounds-in-kafka-streams/#the-unit-is-the-round" rel="noopener noreferrer"&gt;The unit is the round&lt;/a&gt;, three tasks at 12 seconds against a 30-second poll interval, the rebalances came 36 seconds apart. Once, the gap was 72. If you know why, or have seen the same on your own cluster, tell us in &lt;a href="https://github.com/alginte/community/discussions" rel="noopener noreferrer"&gt;Discussions&lt;/a&gt;. We will add what we learn to this post.&lt;/p&gt;

&lt;h2&gt;
  
  
  How the numbers were made
&lt;/h2&gt;

&lt;p&gt;Every run was written down as a prediction before it ran. Its result was added after, without editing the prediction. The early runs were predicted from a model of the loop, the later ones from its source.&lt;/p&gt;

&lt;p&gt;Several predictions were wrong, two of them about the mechanism. The first model, one round per poll, predicted that one task per thread would make a 12-second call safe against a 30-second poll interval. It was evicted. Reading &lt;code&gt;StreamThread&lt;/code&gt; is how we found the adaptive pass. Sax's talk, which we found afterwards, explains it too. The prediction for the 120-second call with rare commits rested on a false detail, that the first pass would commit, so its eviction came earlier than predicted. Two more missed a number: commits predicted 30 seconds apart came 35 apart, and one end count was an arithmetic slip.&lt;/p&gt;

&lt;p&gt;A note on names, for readers who open the source. The field that holds N is called &lt;code&gt;numIterations&lt;/code&gt;. Kafka's &lt;a href="https://kafka.apache.org/42/operations/monitoring/#thread-metrics" rel="noopener noreferrer"&gt;thread metrics&lt;/a&gt; count an &lt;em&gt;iteration&lt;/em&gt; as one poll and all the processing after it, which can hold several passes. We say pass to keep the two apart.&lt;/p&gt;

&lt;p&gt;The stub, the replay script and the application are not published. If you want to run them against your own numbers, write to &lt;a href="mailto:info@alginte.com"&gt;info@alginte.com&lt;/a&gt; and we will send them.&lt;/p&gt;

&lt;p&gt;This post is one of a few on calling a service from Kafka Streams. A coming one starts from the timeout on every call: how to make sure a call is really cut off, and the arithmetic that then says which settings are safe. To get it, follow the blog's &lt;a href="https://www.alginte.com/rss.xml" rel="noopener noreferrer"&gt;RSS feed&lt;/a&gt; or &lt;a href="https://x.com/alginte_sw" rel="noopener noreferrer"&gt;@alginte_sw on X&lt;/a&gt;, where we post each one.&lt;/p&gt;

&lt;h2&gt;
  
  
  Related reading
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Matthias J. Sax, &lt;a href="https://www.youtube.com/watch?v=2_-WoWlAD5M" rel="noopener noreferrer"&gt;The Nuts and Bolts of Kafka Streams&lt;/a&gt; (Current 2023): the architecture this post rests on, from the threading model to restoration.&lt;/li&gt;
&lt;li&gt;A. Sophie Blee-Goldman, &lt;a href="https://github.com/responsivedev/kafka-streams-archive/tree/main/blog-archive/performance-optimization-kafka-streams-101" rel="noopener noreferrer"&gt;Kafka Streams 101: Optimizing your apps&lt;/a&gt; (2025): the round, in one sentence.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://issues.apache.org/jira/browse/KAFKA-14382" rel="noopener noreferrer"&gt;KAFKA-14382&lt;/a&gt;: stream threads that process too long miss rebalances, and what shortens it.&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>kafka</category>
      <category>kafkastreams</category>
      <category>java</category>
    </item>
    <item>
      <title>Debezium change events in Kafka Streams: flatten, route, and survive the delete</title>
      <dc:creator>alginte software</dc:creator>
      <pubDate>Wed, 23 Sep 2026 06:07:29 +0000</pubDate>
      <link>https://dev.to/alginte/debezium-change-events-in-kafka-streams-flatten-route-and-survive-the-delete-54ab</link>
      <guid>https://dev.to/alginte/debezium-change-events-in-kafka-streams-flatten-route-and-survive-the-delete-54ab</guid>
      <description>&lt;p&gt;You have &lt;a href="https://debezium.io/" rel="noopener noreferrer"&gt;Debezium&lt;/a&gt; streaming a database table into Kafka. Call it &lt;code&gt;shipments&lt;/code&gt;: a row per parcel, with an order, a carrier, a weight and a destination, and every insert, update and delete against it landing on a topic as it happens. Now you want to do something with that topic — keep the shipments over ten kilograms, join them to orders, count them per carrier.&lt;/p&gt;

&lt;p&gt;If you have not met it, that first sentence is doing a lot of work. Debezium reads a database's own change log and turns every committed row change into a Kafka record, in commit order. No polling, no triggers, no timestamp column that somebody has to remember to touch, and no change to the application doing the writing: the database is already writing that log for its own recovery, and Debezium reads it. Before the live changes it takes a consistent &lt;a href="https://debezium.io/documentation/reference/stable/connectors/postgresql.html#postgresql-snapshots" rel="noopener noreferrer"&gt;snapshot&lt;/a&gt; of what is already there, so the stream starts complete rather than starting from now.&lt;/p&gt;

&lt;p&gt;Which log that is depends on the database, and there are &lt;a href="https://debezium.io/documentation/reference/stable/connectors/index.html" rel="noopener noreferrer"&gt;thirteen connectors&lt;/a&gt;  of them: the write-ahead log on Postgres, the binlog on MySQL and MariaDB, the oplog on MongoDB, the redo log on Oracle, and so on through SQL Server, Db2, Cassandra, Spanner and the rest. The &lt;a href="https://debezium.io/documentation/reference/stable/index.html" rel="noopener noreferrer"&gt;documentation&lt;/a&gt;is the place to start.&lt;/p&gt;

&lt;p&gt;This post uses Postgres. The change events below look almost the same from every connector, so a topology written for one mostly transfers to the next. If you want a real one running locally, &lt;a href="https://github.com/debezium/debezium-examples/tree/main/tutorial" rel="noopener noreferrer"&gt;&lt;code&gt;docker-compose-postgres.yaml&lt;/code&gt;&lt;/a&gt;in the tutorial examples takes a few minutes.&lt;/p&gt;

&lt;p&gt;Kafka Streams is the natural place for that logic. It is a library that ships with Apache Kafka, not a cluster to run: your application links it, reads the topic, writes another, and the broker you already have does the rest. So you reach for it, and the first record you look at is not a shipment. It is this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"before"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"after"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"shipment_id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"s-1"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"order_id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"ord-1001"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"carrier"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"DHL"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"weight_kg"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mf"&gt;2.4&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"destination"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Zagreb"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"notes"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"leave with neighbour"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"updated_at"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"2026-09-19T07:55:40.534185Z"&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"source"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"version"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"3.6.3.Final"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"connector"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"postgresql"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"db"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"inventory"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
              &lt;/span&gt;&lt;span class="nl"&gt;"schema"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"public"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"table"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"shipments"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"txId"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;826&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"lsn"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;28126360&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"..."&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"..."&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"transaction"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"op"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"c"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"ts_ms"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1789804541024&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"ts_us"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1789804541024594&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"ts_ns"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1789804541024594256&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;

&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That is the &lt;a href="https://debezium.io/documentation/reference/stable/connectors/postgresql.html#postgresql-events" rel="noopener noreferrer"&gt;change event envelope&lt;/a&gt;: a change is not a row, it is a before, an after and a verb. The new version of the row is in &lt;code&gt;after&lt;/code&gt;, the version it replaced is in &lt;code&gt;before&lt;/code&gt;, and &lt;code&gt;op&lt;/code&gt; says what happened — an insert, an update, a delete. On an insert there is nothing to put in &lt;code&gt;before&lt;/code&gt;, so it is null; on a delete there is nothing to put in&lt;code&gt;after&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Three things follow from that, and everybody does all three. Flatten &lt;code&gt;after&lt;/code&gt;. Route by &lt;code&gt;op&lt;/code&gt;. Survive the delete. Here is each as one expression, and how to know it is right before it runs against anything that matters.&lt;/p&gt;

&lt;h2&gt;
  
  
  First, the transform that may end the post here
&lt;/h2&gt;

&lt;p&gt;Debezium ships &lt;a href="https://debezium.io/documentation/reference/stable/transformations/event-flattening.html" rel="noopener noreferrer"&gt;&lt;code&gt;ExtractNewRecordState&lt;/code&gt;&lt;/a&gt;, a Kafka Connect single message transform (an SMT, in Connect's words) that unwraps the envelope. Put it on the connector and the envelope is gone: the topic carries the &lt;code&gt;after&lt;/code&gt; row, flat, the way you wanted in the first place.&lt;/p&gt;

&lt;p&gt;If that is all you need, use it. It runs in the connector, it costs you no application, and this post is over. What it cannot do is anything that depends on more than one record or on more than one topic: routing to different topics by a condition on the data, joining a change stream to another stream, keeping a running count per key, comparing &lt;code&gt;before&lt;/code&gt; to &lt;code&gt;after&lt;/code&gt; to find what actually changed. The moment the logic outgrows one record at a time, it moves into a stream, and the envelope comes with it.&lt;/p&gt;

&lt;p&gt;So: keep the envelope, and learn its three moves.&lt;/p&gt;

&lt;h2&gt;
  
  
  Flatten &lt;code&gt;after&lt;/code&gt;
&lt;/h2&gt;

&lt;p&gt;The expressions in this post are SpEL, written into a node on &lt;a href="https://www.alginte.com" rel="noopener noreferrer"&gt;Alginte&lt;/a&gt;'s canvas: a &lt;code&gt;mapValues&lt;/code&gt; node carries one that shapes the value, a &lt;code&gt;filter&lt;/code&gt; node carries one that answers true or false, and &lt;code&gt;value&lt;/code&gt; inside them is the record's value, which the JSON converter hands over as a map. &lt;a href="https://www.alginte.com/blog/visual-kafka-streams-builder/" rel="noopener noreferrer"&gt;Kafka Streams topologies you can draw and run&lt;/a&gt;walks the canvas end to end; the &lt;a href="https://docs.alginte.com/streams/spel#roots-key-and-value" rel="noopener noreferrer"&gt;expression reference&lt;/a&gt; has the rules. If you write Kafka Streams in Java, read each expression as the one-line body of the lambda in that node.&lt;/p&gt;

&lt;p&gt;The column you want is one hop in:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="n"&gt;value&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;get&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="err"&gt;'&lt;/span&gt;&lt;span class="n"&gt;after&lt;/span&gt;&lt;span class="err"&gt;'&lt;/span&gt;&lt;span class="o"&gt;).&lt;/span&gt;&lt;span class="na"&gt;get&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="err"&gt;'&lt;/span&gt;&lt;span class="n"&gt;carrier&lt;/span&gt;&lt;span class="err"&gt;'&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;On the record above that is &lt;code&gt;"DHL"&lt;/code&gt;. Build the whole flattened row the same way, in a &lt;code&gt;mapValues&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;id&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;value&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;after&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;shipment_id&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
 &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;carrier&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;value&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;after&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;carrier&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
 &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;weight&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;value&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;after&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;weight_kg&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)}&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;With the JSON converter the strings are real strings: &lt;code&gt;value.get('op') == 'd'&lt;/code&gt;works, no conversion, no &lt;code&gt;toString()&lt;/code&gt;. If your connector's &lt;code&gt;value.converter&lt;/code&gt; is Avro instead, so the same envelope lands as an Avro record with its schema in a registry, the field arrives as a &lt;code&gt;Utf8&lt;/code&gt;: &lt;code&gt;== 'd'&lt;/code&gt; still works, &lt;code&gt;.contains('d')&lt;/code&gt;does not until you call &lt;code&gt;.toString()&lt;/code&gt; on it first.&lt;/p&gt;

&lt;h2&gt;
  
  
  Route by &lt;code&gt;op&lt;/code&gt;
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;op&lt;/code&gt; is one character: &lt;code&gt;c&lt;/code&gt; for create, &lt;code&gt;u&lt;/code&gt; for update, &lt;code&gt;d&lt;/code&gt; for delete, &lt;code&gt;r&lt;/code&gt; for a row read during the initial snapshot. Route on it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;op&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;c&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;op&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;u&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="c1"&gt;// upserts&lt;/span&gt;
&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;op&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;d&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="c1"&gt;// deletes&lt;/span&gt;
&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;op&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;r&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="c1"&gt;// the snapshot backfill&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The one worth a second look is &lt;code&gt;r&lt;/code&gt;. When the connector first starts it reads the table and emits every existing row as a change event with &lt;code&gt;op: r&lt;/code&gt;, before any live change arrives — with &lt;code&gt;source.snapshot&lt;/code&gt; reading &lt;code&gt;first&lt;/code&gt; on the first of them and &lt;code&gt;last&lt;/code&gt; on the last, if you want to find the boundary. If your logic treats every event as news — send an email, call an API, increment a counter that means "changes today" — the first deployment sends one per existing row. That is the snapshot doing what it says, and it is why the stream starts with history instead of starting empty. Decide what &lt;code&gt;r&lt;/code&gt; means to you, and say so in an expression rather than finding out.&lt;/p&gt;

&lt;h2&gt;
  
  
  Survive the delete
&lt;/h2&gt;

&lt;p&gt;Here is the record your first version dies on:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"before"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"shipment_id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"s-1"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"order_id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"ord-1001"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"carrier"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"DHL"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
              &lt;/span&gt;&lt;span class="nl"&gt;"weight_kg"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mf"&gt;2.4&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"destination"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Zagreb"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"notes"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"leave with neighbour"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
              &lt;/span&gt;&lt;span class="nl"&gt;"updated_at"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"2026-09-19T07:55:40.534185Z"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"after"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"op"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"d"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"ts_ms"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1789804541026&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;

&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;after&lt;/code&gt; is null, because after a delete there is no row. Every expression above reads &lt;code&gt;value.get('after').get(...)&lt;/code&gt;, and on this record that is a method call on null. It fails. Not quietly, not as a &lt;code&gt;false&lt;/code&gt; that drops the record: it fails, and depending on where you put it, your topology stops.&lt;/p&gt;

&lt;p&gt;The thing to notice is &lt;em&gt;when&lt;/em&gt; it fails. It works on every insert and every update and every snapshot row, which is to say it works for as long as nobody deletes anything. Then somebody does.&lt;/p&gt;

&lt;p&gt;There are two honest fixes. Route first, and only flatten where you know there is an &lt;code&gt;after&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;op&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;d&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="c1"&gt;// a filter, upstream of the flatten&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Or make the access itself null-safe, with SpEL's &lt;a href="https://docs.spring.io/spring-framework/reference/core/expressions/language-ref/operator-safe-navigation.html" rel="noopener noreferrer"&gt;safe navigation&lt;/a&gt;operator:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;value&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;after&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="err"&gt;?&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;shipment_id&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;On an insert that is &lt;code&gt;"s-1"&lt;/code&gt;. On the delete it is &lt;code&gt;null&lt;/code&gt;, and nothing throws. It reads as a small thing and it is the difference between a topology that survives its first delete and one that does not.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fvfgr9rm9zieo7z3dtdpg.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fvfgr9rm9zieo7z3dtdpg.png" alt="The map values editor holding the two-line flatten {'id': value.get('after')?.get('shipment_id'), 'carrier': value.get('after')?.get('carrier')} with no issues. Under it the sample preview evaluated against a real record from the shipments-cdc topic, partition 0, offset 3: the delete, with before holding the row and after null. The out value is {" width="800" height="736"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;And where the delete is what you actually care about — a downstream store to evict, an index entry to remove — &lt;code&gt;before&lt;/code&gt; is where the row is:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;value&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;before&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;shipment_id&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Which is why &lt;code&gt;before&lt;/code&gt; is worth configuring, and this is the part that surprised me when I measured it. On Postgres, &lt;code&gt;REPLICA IDENTITY&lt;/code&gt; decides how much of the old row reaches the connector. With the default, the old tuple is the primary key and nothing else — so an &lt;strong&gt;update&lt;/strong&gt; has no &lt;code&gt;before&lt;/code&gt; at all:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"before"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"after"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"shipment_id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"s-2"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"carrier"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"GLS"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"..."&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"..."&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"op"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"u"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;

&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;and a &lt;strong&gt;delete&lt;/strong&gt; has a &lt;code&gt;before&lt;/code&gt; that looks complete and is not:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="nl"&gt;"before"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"shipment_id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"s-1"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"order_id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;""&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"carrier"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;""&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"destination"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;""&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"weight_kg"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mf"&gt;0.0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"notes"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"updated_at"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"1970-01-01T00:00:00.000000Z"&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;

&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The key is real. Every other column has been filled in with its type's zero: an empty string for the text columns, &lt;code&gt;0.0&lt;/code&gt; for the weight, and for the timestamp the epoch, which is the one that catches the eye. Not null, which you would notice — a value, which you would not; only &lt;code&gt;notes&lt;/code&gt; is null, and only because that column is nullable. A rule that reads&lt;code&gt;value.get('before').get('carrier')&lt;/code&gt; gets &lt;code&gt;""&lt;/code&gt; and carries on; a filter on&lt;code&gt;weight_kg &amp;lt; 5&lt;/code&gt; says yes to a parcel that weighed nothing; a rule that reads&lt;code&gt;updated_at&lt;/code&gt; gets 1970.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;ALTER TABLE ... REPLICA IDENTITY FULL&lt;/code&gt; gives you the whole old row on both updates and deletes, at the cost of more WAL. If anything downstream reads&lt;code&gt;before&lt;/code&gt; for more than the key, that is the setting, and it belongs in the migration that creates the table rather than in the incident that finds it.&lt;/p&gt;

&lt;h3&gt;
  
  
  And then the tombstone
&lt;/h3&gt;

&lt;p&gt;There is one more record, and it is not an envelope at all:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;key&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;shipment_id"&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;s-1"&lt;/span&gt;&lt;span class="pi"&gt;}&lt;/span&gt;
&lt;span class="na"&gt;value&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;After the delete event, Debezium emits a &lt;a href="https://debezium.io/documentation/reference/stable/connectors/postgresql.html#postgresql-delete-events" rel="noopener noreferrer"&gt;tombstone&lt;/a&gt;: same key, null value, no envelope, nothing to reach into. Its job is log compaction — it is the marker that lets Kafka eventually drop every earlier record with that key. It is on by default, and it is the second record your delete produces.&lt;/p&gt;

&lt;p&gt;For your topology that means &lt;code&gt;value&lt;/code&gt; itself is null. Not &lt;code&gt;after&lt;/code&gt;, not a field: the whole value. An expression cannot save you there, because there is nothing to evaluate. Decide what a null value means in your stream and handle it as a null, or turn the tombstones off with &lt;code&gt;tombstones.on.delete=false&lt;/code&gt; if the downstream does not need them and you would rather not think about it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The one that bites silently
&lt;/h2&gt;

&lt;p&gt;Everything above fails loudly, which is the good kind. This one does not.&lt;/p&gt;

&lt;p&gt;Give the table a &lt;code&gt;NUMERIC&lt;/code&gt; column — a weight, a price, an amount, which in a database is exactly what &lt;code&gt;NUMERIC&lt;/code&gt; is for — and under Debezium's default settings it arrives like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="nl"&gt;"weight_kg"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"APA="&lt;/span&gt;&lt;span class="w"&gt;

&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That is 2.40. Debezium's &lt;code&gt;decimal.handling.mode&lt;/code&gt; &lt;a href="https://debezium.io/documentation/reference/stable/connectors/postgresql.html#postgresql-decimal-types" rel="noopener noreferrer"&gt;defaults to &lt;code&gt;precise&lt;/code&gt;&lt;/a&gt;: a &lt;code&gt;NUMERIC&lt;/code&gt; is an exact decimal, a JSON number is a float, and rather than lose precision the connector sends the unscaled bytes, base64-encoded, with the scale in the schema.&lt;/p&gt;

&lt;p&gt;What it means for your expression is that &lt;code&gt;weight_kg&lt;/code&gt; is a String. So this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;value&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;after&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;weight_kg&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;5&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;is a String compared to a number. If you want the ordinary thing, say so on the connector:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight properties"&gt;&lt;code&gt;&lt;span class="err"&gt;"decimal.handling.mode":&lt;/span&gt; &lt;span class="err"&gt;"double"&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;and &lt;code&gt;weight_kg&lt;/code&gt; is &lt;code&gt;2.4&lt;/code&gt;. There is a third setting, &lt;code&gt;string&lt;/code&gt;, which gives you&lt;code&gt;"2.40"&lt;/code&gt; — exact, readable, and still not a number until you convert it. Choose deliberately: &lt;code&gt;precise&lt;/code&gt; when the value is money and the arithmetic is someone else's problem downstream, &lt;code&gt;double&lt;/code&gt; when you are comparing weights in a filter and a float is fine.&lt;/p&gt;

&lt;h2&gt;
  
  
  Prove it before it runs
&lt;/h2&gt;

&lt;p&gt;Every expression in this post is one line, and every one of them has a record that breaks it. The chained &lt;code&gt;get&lt;/code&gt; breaks on the delete. The numeric comparison breaks on the default decimal mode. The tombstone breaks anything that assumes a value.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://www.alginte.com/blog/kafka-streams-topology-starts-in-a-sandbox/" rel="noopener noreferrer"&gt;You do not have to find those on a shared cluster&lt;/a&gt;. Take a sample of the topic — the ordinary record, an update, a delete, and the tombstone — and evaluate against it while you write. In Alginte that is the &lt;a href="https://docs.alginte.com/installation/playground" rel="noopener noreferrer"&gt;playground&lt;/a&gt;: one command, its own broker, and a folder where the change events you copied in are produced at every start. There is a Debezium sample in it already, four real events captured from a Postgres, so you can see the envelope without having a database or a connector anywhere near it; the tombstone you produce from the console, with a null value, since a file has no way to hold one.&lt;/p&gt;

&lt;p&gt;The part that matters is not the tool, it is the order: write the expression against a record that has actually happened, see the result, and only then deploy. A chained &lt;code&gt;get&lt;/code&gt; through a null &lt;code&gt;after&lt;/code&gt; is a two-second discovery when the delete is in front of you, and an incident when it is not.&lt;/p&gt;

&lt;h2&gt;
  
  
  The short version
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Your row is in &lt;code&gt;after&lt;/code&gt;; &lt;code&gt;before&lt;/code&gt; is the old one; &lt;code&gt;op&lt;/code&gt; says which to believe.&lt;/li&gt;
&lt;li&gt;If a flat topic is all you need, &lt;code&gt;ExtractNewRecordState&lt;/code&gt; gives you that in the connector and you can stop.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;op: r&lt;/code&gt; is the initial snapshot, and it arrives once per existing row.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;after&lt;/code&gt; is null on a delete. Route on &lt;code&gt;op&lt;/code&gt; first, or use &lt;code&gt;?.&lt;/code&gt;, or read&lt;code&gt;before&lt;/code&gt; — and set &lt;code&gt;REPLICA IDENTITY FULL&lt;/code&gt; if you want the whole old row.&lt;/li&gt;
&lt;li&gt;The delete is followed by a tombstone: same key, null value, no envelope.&lt;/li&gt;
&lt;li&gt;A &lt;code&gt;NUMERIC&lt;/code&gt; is base64 unless you set &lt;code&gt;decimal.handling.mode&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;None of that is hard. All of it is easier to learn from a record in front of you, on a &lt;a href="https://docs.alginte.com/installation/playground" rel="noopener noreferrer"&gt;playground&lt;/a&gt; you can experiment with freely, before the topology runs anywhere that matters.&lt;/p&gt;

</description>
      <category>debezium</category>
      <category>cdc</category>
      <category>apachekafka</category>
      <category>kafkastreams</category>
    </item>
    <item>
      <title>A Kafka Streams topology starts in a sandbox, not on your cluster</title>
      <dc:creator>alginte software</dc:creator>
      <pubDate>Sat, 19 Sep 2026 17:05:18 +0000</pubDate>
      <link>https://dev.to/alginte/a-kafka-streams-topology-starts-in-a-sandbox-not-on-your-cluster-i3p</link>
      <guid>https://dev.to/alginte/a-kafka-streams-topology-starts-in-a-sandbox-not-on-your-cluster-i3p</guid>
      <description>&lt;p&gt;Somebody owns the Kafka cluster you need, and it is not you. You have a topology in mind: read &lt;code&gt;orders&lt;/code&gt;, keep the big ones, write them somewhere. You have a predicate in mind too, and you are fairly sure it is right. "Fairly sure" is the problem. To find out, you need to run it, and the only place to run it is the shared cluster: a consumer group in everyone's list, internal topics with your name on them, and an output topic that real consumers may already be reading. Kafka does not take records back. A wrong predicate there means wrong records that somebody downstream has already acted on.&lt;/p&gt;

&lt;p&gt;So you do not run it. The idea is shelved, and you never learn that it was the right one.&lt;/p&gt;

&lt;p&gt;This post is about the other order: find out first, in a place where being wrong costs nothing, and only then touch the cluster. It needs two things. A stack of your own that you are free to break, and a way to carry what you learned across the border without carrying the stack with it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The expensive part is not the topology
&lt;/h2&gt;

&lt;p&gt;Writing a Kafka Streams topology is not where the time goes. The time goes into the question the code cannot answer for itself: does the predicate do what you meant, on the records that actually arrive? We made that argument at length in &lt;a href="https://www.alginte.com/blog/kafka-streams-without-codegen/" rel="noopener noreferrer"&gt;The compiler was never what you wanted&lt;/a&gt;: the cost is in the loop between having an idea and finding out, and on a shared cluster that loop is long, so you run it rarely.&lt;/p&gt;

&lt;p&gt;The loop is short when three things are true. The records you evaluate against are real. The evaluation you see before deploying is the one that will run after. And being wrong leaves nothing behind. The earlier post was about the first two. This one is about the third, and about what happens after you are right.&lt;/p&gt;

&lt;h2&gt;
  
  
  A stack of your own, free to break
&lt;/h2&gt;

&lt;p&gt;One &lt;a href="https://github.com/alginte/community/tree/main/playground" rel="noopener noreferrer"&gt;&lt;code&gt;docker compose up -d&lt;/code&gt;&lt;/a&gt; gives you a single-node Kafka, a Schema Registry, and a console that runs Kafka Streams topologies, bound to your machine, with no volumes. It comes seeded with topics, real Avro records against a registered subject, and a topology already running, so the first thing you see is a canvas with numbers on it. &lt;code&gt;docker compose down&lt;/code&gt; returns the machine to the state it was in. That is the whole safety story. Nothing here can hurt anyone, because nothing here is shared.&lt;/p&gt;

&lt;p&gt;Seeded orders are fine for the first ten minutes. After that you want your own topic. Say it is &lt;code&gt;shipments&lt;/code&gt;, with JSON Schema values. Two folders beside the compose file take it in, and because they are read at every &lt;code&gt;up&lt;/code&gt;, what you put there survives the &lt;code&gt;down&lt;/code&gt; that erases everything else:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="s"&gt;playground/&lt;/span&gt;
  &lt;span class="s"&gt;docker-compose.yml&lt;/span&gt;
  &lt;span class="s"&gt;schemas/&lt;/span&gt;
    &lt;span class="s"&gt;shipments-value.json the subject, saved out of your registry&lt;/span&gt;
  &lt;span class="s"&gt;records/&lt;/span&gt;
    &lt;span class="s"&gt;shipments.json-schema.jsonl a sample of the topic, one record per line&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The file name is the contract. &lt;code&gt;shipments-value.json&lt;/code&gt; is registered as the subject &lt;code&gt;shipments-value&lt;/code&gt;; &lt;code&gt;.avsc&lt;/code&gt; and &lt;code&gt;.proto&lt;/code&gt; do the same for Avro and Protobuf. &lt;code&gt;shipments.json-schema.jsonl&lt;/code&gt; is produced into the topic&lt;code&gt;shipments&lt;/code&gt;, each value framed against that subject, and a line is either a keyed record or a bare one:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;s-1|{"shipmentId":"s-1","orderId":"ord-1001","carrier":"DHL","weightKg":2.4,"destination":"Zagreb"}
s-2|{"shipmentId":"s-2","orderId":"ord-1003","carrier":"UPS","weightKg":11.0,"destination":"Milano"}

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Keep the topic and subject names identical to the real ones, because the design you are about to make carries them. A small sample is enough, if it is the right one: the ordinary record, and then the ones that break things. A null where a field is optional, an empty list, a value sitting on the boundary of your predicate, a key you did not expect. What you are proving is logic against the shape of your data, and shape is a matter of variety, not volume.&lt;/p&gt;

&lt;p&gt;Production may not be able to give you that sample. The ordinary record is there a million times over; the one that breaks your predicate may not have happened yet, and a shared topic is no place to produce it to find out, with everyone else's consumers downstream. Here it is one more line in the file: written by hand, framed against the same subject as the sampled ones, seen by nobody's consumer but yours.&lt;/p&gt;

&lt;p&gt;Anything personal in the sampled records is yours to redact before it lands in the folder. The playground's repository has these two files as templates to copy.&lt;/p&gt;

&lt;p&gt;Now the loop. Drop a source node on &lt;code&gt;shipments&lt;/code&gt;, bound to &lt;code&gt;shipments-value&lt;/code&gt;. Add a filter, and start typing the predicate. As you type, it is &lt;a href="https://www.alginte.com/blog/building-a-spel-editor/" rel="noopener noreferrer"&gt;evaluated against the newest record on the topic&lt;/a&gt;, and the result sits next to the editor: the input record, the output, the verdict. Put a &lt;code&gt;mapValues&lt;/code&gt; in front of it and the filter sees the shape the&lt;code&gt;mapValues&lt;/code&gt; produced, not a guess. Misspell a field and the editor tells you on the keystroke. On an Avro or Protobuf record the access fails, with the same message the deployed operator would throw, because it is the same evaluation. On a JSON Schema record, where a missing key is a quiet&lt;code&gt;null&lt;/code&gt; at runtime, the editor warns on the name and offers the closest one the subject declares. This is the short loop: the few seconds between the typo and finding it.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F442p2tlwx4ww6157f774.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F442p2tlwx4ww6157f774.png" alt="The filter's drawer on a JSON Schema source, the expression value.get('carier') == 'DHL' underlined as a warning. The status panel reads: the subject declares no property 'carier', lists the five declared properties, says an absent key answers null so this never fails here or deployed, and asks: did you mean 'carrier'? Under it the sample preview shows a real shipment and the verdict: predicate would DROP the sample." width="800" height="1194"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Fix the name, finish the predicate, and the same panel judges it against a record you brought:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F6ubwtqon4aqlg5s43klk.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F6ubwtqon4aqlg5s43klk.png" alt="The same drawer with the two-line predicate value.get('carrier') == 'DHL' &amp;amp;&amp;amp; value.get('weightKg') &lt; 5 and no issues. The sample preview shows a real record from the shipments topic, key s-3, a DHL parcel of 1.1 kg for Berlin, in and out, with the verdict: predicate passes the sample." width="800" height="1061"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Submit, and a gate runs before anything deploys: a source topic that does not exist, a source whose subject the registry does not have, join inputs that are not co-partitioned, a windowed key on its way to a sink that cannot write it. Each refusal names the node and the fix. Pass the gate, and the stream runs here, on this broker. Produce a few records, written by hand or generated at random from the subject's schema, one click each. Watch the sink fill, open the State tab and see &lt;a href="https://www.alginte.com/blog/monitoring-kafka-streams-beyond-the-cluster/" rel="noopener noreferrer"&gt;the threads, the stores and the lag&lt;/a&gt;. Delete it, keep the state or remove it. Deploy it again under the same id, and the console tells you what the earlier run left on the cluster, a changelog and a committed offset, and asks whether to continue on it or start clean.&lt;/p&gt;

&lt;p&gt;None of this is a simulation. It is the Kafka Streams client, running your topology, on a broker you can throw away. The console in the loop is &lt;a href="https://www.alginte.com" rel="noopener noreferrer"&gt;Alginte&lt;/a&gt;, in the mode it calls&lt;a href="https://docs.alginte.com/installation/playground" rel="noopener noreferrer"&gt;the playground&lt;/a&gt;, and the one claim that makes the loop worth trusting is the one we test hardest: an expression that evaluates one way in the editor evaluates the same way deployed, on Avro, JSON Schema and Protobuf alike. That promise is held by a corpus of rows that run each expression on both surfaces and refuse to ship a disagreement.&lt;/p&gt;

&lt;h2&gt;
  
  
  Carrying it across the border
&lt;/h2&gt;

&lt;p&gt;A design that works here has to reach the cluster that matters, and it must not bring the sandbox with it.&lt;/p&gt;

&lt;p&gt;The design travels as a file. &lt;strong&gt;Download&lt;/strong&gt; writes the whole thing: the configuration, every node with its expressions and serdes, the edges, the layout. The file is named after the &lt;code&gt;application.id&lt;/code&gt;, and it belongs in git, beside the code it feeds. It is plain, indented JSON, so a changed expression is a one-line diff, a new node is a block a reviewer can read in a pull request, and the history of the stream is the history of the file:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight diff"&gt;&lt;code&gt;   "data": {
     "name": "light DHL parcels",
     "expression": {
&lt;span class="gd"&gt;- "expression": "value.get('carrier') == 'DHL' &amp;amp;&amp;amp; value.get('weightKg') &amp;lt; 5",
&lt;/span&gt;&lt;span class="gi"&gt;+ "expression": "value.get('carrier') == 'DHL' &amp;amp;&amp;amp; value.get('weightKg') &amp;lt; 10",
&lt;/span&gt;       "language": "SpEL"
     }
&lt;span class="err"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That is what makes it the durable artefact: the sandbox is thrown away, the file is not.&lt;/p&gt;

&lt;p&gt;What the file does not carry is as important: not the records, not the topic, not the schema, not the stream's state. Those are the cluster's, on both sides.&lt;/p&gt;

&lt;p&gt;On the console that runs against the real cluster, &lt;strong&gt;Upload&lt;/strong&gt; the file. The same wizard opens with the same validation. Three things are different at the border, and each is handled by a rule rather than by remembering.&lt;/p&gt;

&lt;p&gt;A design carries its logic, not its cluster. If the file has&lt;code&gt;bootstrap.servers&lt;/code&gt;, a registry URL or security settings in it, the upload leaves them out and says which, so the connection that applies is this console's, never the sandbox's.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F3082gc2085b8mb1j1mak.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F3082gc2085b8mb1j1mak.png" alt="The builder's canvas just after an upload: the three nodes of the design, source, filter with its predicate, sink, and a notice beside them: Cluster-bound properties left out of the upload. bootstrap.servers, schema.registry.url: a design carries its logic, not the cluster it was written on. This console's own values apply; set them on Stream Properties if this stream needs others." width="800" height="1013"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Names are checked against this cluster. The gate refuses a source topic that does not exist here, and a source whose subject this registry lacks, naming the node. Register the subject first, with the same text you put in&lt;code&gt;schemas/&lt;/code&gt;, and the refusal goes away.&lt;/p&gt;

&lt;p&gt;The id is checked against this cluster's memory. If a stream under this&lt;code&gt;application.id&lt;/code&gt; has run here before, the console says so before deploying: these internal topics, this committed offset. Continue on them, or start fresh. It never guesses.&lt;/p&gt;

&lt;p&gt;Then Submit. The deploy is the same gate and the same client. The only thing that changed is the cluster it joined.&lt;/p&gt;

&lt;p&gt;That is one of two routes out of the sandbox, and neither is the lesser one. The other: your team ships its streams as Java services, with a compiler and a test suite, and what you carry across is the shape, the expressions and the evidence that they are right. Either way you arrive having already answered the question the compiler cannot.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the sandbox does not prove
&lt;/h2&gt;

&lt;p&gt;Scale. A design proven on a small, well-chosen sample is proven for what it does to a record, not for its throughput, its partition layout under load, or how long its stores take to restore after a restart. Those are questions for the real cluster, and there they have answers. In the sandbox, the &lt;a href="https://www.alginte.com/blog/monitoring-kafka-streams-beyond-the-cluster/" rel="noopener noreferrer"&gt;&lt;strong&gt;State tab&lt;/strong&gt;&lt;/a&gt; showed a handful of records moving. On the real cluster, the same tab shows what matters at scale: a restore as it replays, the threads and the tasks they hold, the lag of every store, and on the canvas the throughput of every node. It is the same console on both sides of the border: the workbench here, the instrument there. The sandbox tells you the logic is right. The first hour on the real cluster tells you the rest, and it is an hour you can watch rather than infer.&lt;/p&gt;

&lt;h2&gt;
  
  
  The order of operations
&lt;/h2&gt;

&lt;p&gt;Start where being wrong is free. Bring the shape of your data, and a sample that covers its corners. Write the expression against a real record and let the editor tell you on the keystroke. Deploy on a broker you can throw away, and throw it away. Then, if you choose to run it as it is, carry the design across to the production cluster as a file, and let the gate on the other side check the names, the id and the connection. Deploy there once, on purpose.&lt;/p&gt;

&lt;p&gt;The production cluster is still somebody else's. The difference is that your stream now arrives there with its logic already proven, and "fairly sure" left behind in the sandbox.&lt;/p&gt;

</description>
      <category>apachekafka</category>
      <category>kafkastreams</category>
      <category>spring</category>
      <category>java</category>
    </item>
    <item>
      <title>Monitoring Kafka Streams applications: the state a cluster view cannot show</title>
      <dc:creator>alginte software</dc:creator>
      <pubDate>Tue, 15 Sep 2026 05:38:25 +0000</pubDate>
      <link>https://dev.to/alginte/monitoring-kafka-streams-applications-the-state-a-cluster-view-cannot-show-1cde</link>
      <guid>https://dev.to/alginte/monitoring-kafka-streams-applications-the-state-a-cluster-view-cannot-show-1cde</guid>
      <description>&lt;p&gt;A typical Kafka console reads the cluster: topics, partitions, consumer groups, committed offsets, lag. That is the right picture for most of what a console is for.&lt;/p&gt;

&lt;p&gt;A Kafka Streams application is a client of that cluster, and from the cluster's side it looks like what it is: a consumer group with members, some committed offsets, a few internal topics with a naming pattern.&lt;/p&gt;

&lt;p&gt;What the application is &lt;em&gt;doing&lt;/em&gt; is not on the cluster. Whether it is restoring a store from its changelog, and how far along it is. Whether a standby is keeping up with its active. Whether the threads Kafka reports as alive are serving the tasks assigned to them. How many times it has rebalanced since it started and how long the last one took. All of that is inside the process, in listeners and gauges the Kafka Streams API exposes to the application itself.&lt;/p&gt;

&lt;p&gt;The restore case is the sharpest. The consumer that replays a changelog is not a member of any group, commits no offset, and keeps its position in a local checkpoint file. So while an instance that started with an empty state directory replays millions of records before it can process a single new one, the cluster shows a stable group and, if input keeps arriving, a lag that grows for no reason it can show. The application is doing the one thing that decides when it will be available again, and the cluster has no way to say so.&lt;/p&gt;

&lt;p&gt;Anyone who has explained that silence to a colleague, or to a customer on an upgrade call, knows the shape of the problem: the application was fine, or was not, and the picture in front of everyone was of something else.&lt;/p&gt;

&lt;p&gt;We built Alginte around the stream because of this gap, on both sides of it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Visibility into the stream's shape
&lt;/h2&gt;

&lt;p&gt;The first side was design time. A Kafka Streams topology is a graph, and the DSL, the describe output and every diagram in every article draw it as one, but the drawing never contains what the operators do. So we build the stream as the graph it is: in the &lt;a href="https://www.alginte.com/blog/visual-kafka-streams-builder/" rel="noopener noreferrer"&gt;modeler&lt;/a&gt; you draw the topology, node by node and edge by edge, and the boxes get their bodies.&lt;/p&gt;

&lt;p&gt;Each operator carries the expression it applies, and the editor shows a record that is really in the source topic and what that expression makes of it. The derived shape at every node is marked as inferred rather than asserted. You see the stream's shape, and what it does to your data, before it exists on the cluster. That is visibility into the stream as a thing you are building.&lt;/p&gt;

&lt;h2&gt;
  
  
  Visibility into the stream's operation
&lt;/h2&gt;

&lt;p&gt;The second side is run time, available since 0.11.0. Because the console runs the stream, it holds the Kafka Streams client and can read it directly: no agent, no scraping, nothing written to the cluster.&lt;/p&gt;

&lt;p&gt;A &lt;strong&gt;State&lt;/strong&gt; tab on every deployed stream shows what the process knows. A restore banner appears only while a store replays its changelog, showing how many of the records it has replayed.&lt;/p&gt;

&lt;p&gt;The lifecycle is the state the console derives, not only the one Kafka reports, because the raw client state misleads in two known ways: the console reads &lt;code&gt;REBALANCING&lt;/code&gt; with every thread dead as &lt;code&gt;ERROR&lt;/code&gt;, and &lt;code&gt;RUNNING&lt;/code&gt; with assigned tasks served by nobody as &lt;code&gt;DEGRADED&lt;/code&gt;, with the shortfall in red.&lt;/p&gt;

&lt;p&gt;Every store partition is listed with the one figure that matters most, read by its role: records left to replay while restoring, distance behind the active on a standby, and the plain changelog lag at steady state. Threads, rebalances with their durations and an event timeline complete it. Beside all of it sit the cluster's own facts about the same stream, group state, committed offsets, changelog topics, so the two views can be read against each other.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F48ho3wpjm4q4ch6cswjy.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F48ho3wpjm4q4ch6cswjy.png" alt="The State tab while a stream restores a 7.5 million record store after its state directory was wiped: a banner with the records replayed of the total and a progress bar, the Lifecycle card reading REBALANCING with 0 of 0 tasks served, the Rebalances, Cluster and Observer cards, and the store row with its lag counting down." width="800" height="1127"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;A stream replaying 7.5 million changelog records after its state directory was wiped. The cluster's view of the same moment is a stable group and no sign of why.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The same page opens for a Kafka Streams application the console does not run, on any cluster it reads with the usual read-only rights. There it shows what the cluster can say, the group, the offsets and lag, the changelog topics and how far each reaches, and marks the rest as unseen: no lifecycle, because a group reads stable while threads die and we would rather show absence than invent one; no restore progress, no store sizes. Every figure the observer cannot see is a dash, never a zero. The docs describe &lt;a href="https://docs.alginte.com/streams/monitoring#which-view-you-get" rel="noopener noreferrer"&gt;which view you get&lt;/a&gt; in each case.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fs6xue0nf0jn2m5tmaq75.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fs6xue0nf0jn2m5tmaq75.png" alt="The observed page for a streams-protocol application this instance does not run: a banner saying the view is inferred from the cluster, an absent lifecycle, the Cluster card with group state and members, stores with end offsets only, source partitions with lag, and group state and epoch events." width="800" height="818"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;An application this console does not run: the cluster's facts, and everything it cannot see marked as absent rather than zero.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;That is the depth the cluster allows, and it is where the idea points next. An application you run outside Alginte will be able to report its own state to the console, thread state, restore progress and store sizes included, through a small reporter library added to the application, one call before it starts. The console side of that, an endpoint that accepts what a runtime pushes, exists today; the library follows. And the streams the console runs need not run in the console's own process forever: a headless runtime that hosts them elsewhere and reports back to the same page is the shape we are building toward. Either way the page stays the same. What changes is how much of the stream it is allowed to see.&lt;/p&gt;

&lt;p&gt;Together the two sides are one idea. The modeler lets you see the stream you are building; the State tab lets you see the stream you are running. Same object, both ends of its life, with the console built around it. Martin Kleppmann &lt;a href="https://martin.kleppmann.com/2015/03/04/turning-the-database-inside-out.html" rel="noopener noreferrer"&gt;turned the database inside out&lt;/a&gt;; this is the console turned inside out, the stream in the middle and the console around it.&lt;/p&gt;

&lt;h2&gt;
  
  
  What seeing it does not change
&lt;/h2&gt;

&lt;p&gt;Seeing a restore does not make it faster. A large store still takes the minutes it takes to replay; what changes is that those minutes are a number on a page with a bar beside it instead of an unexplained silence. Seeing a dead thread does not bring it back; what changes is that the person who has to decide what to do can tell it from an ordinary rebalance.&lt;/p&gt;

&lt;h2&gt;
  
  
  See what it is doing
&lt;/h2&gt;

&lt;p&gt;A stateful stream application is a commitment: it holds state, it has to restore it, and it can fail in ways the cluster does not show. Run it where you can see it, so that its state is a page anyone on the team can read, not a question for whoever wrote it. That is what the console is for: a stream you can see while you design it, and a stream you can see while it runs.&lt;/p&gt;

</description>
      <category>kafka</category>
      <category>kafkastreams</category>
      <category>java</category>
    </item>
    <item>
      <title>The compiler was never what you wanted</title>
      <dc:creator>alginte software</dc:creator>
      <pubDate>Fri, 11 Sep 2026 06:55:08 +0000</pubDate>
      <link>https://dev.to/alginte/the-compiler-was-never-what-you-wanted-1icd</link>
      <guid>https://dev.to/alginte/the-compiler-was-never-what-you-wanted-1icd</guid>
      <description>&lt;p&gt;You have an &lt;code&gt;orders&lt;/code&gt; topic on a Kafka cluster, its values encoded with &lt;a href="https://avro.apache.org/" rel="noopener noreferrer"&gt;Avro&lt;/a&gt; against a schema in the &lt;a href="https://docs.confluent.io/platform/current/schema-registry/index.html" rel="noopener noreferrer"&gt;Schema Registry&lt;/a&gt;. You want the orders worth more than fifty euros on a topic of their own, and you have decided to do it with &lt;a href="https://kafka.apache.org/documentation/streams/" rel="noopener noreferrer"&gt;Kafka Streams&lt;/a&gt; — a JVM library, your code, your deployment.&lt;/p&gt;

&lt;p&gt;The schema has five fields:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"record"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Order"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"namespace"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"com.alginte.demo"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
 &lt;/span&gt;&lt;span class="nl"&gt;"fields"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
   &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"orderId"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"string"&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
   &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"customerId"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"string"&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
   &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"item"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;       &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"string"&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
   &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"quantity"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;   &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"int"&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
   &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"priceEur"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;   &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"double"&lt;/span&gt;&lt;span class="p"&gt;}]}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You want one line of logic over them: &lt;code&gt;quantity * priceEur &amp;gt; 50&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Here is everything standing between that line and a topic of big orders.&lt;/p&gt;

&lt;h2&gt;
  
  
  Seven steps
&lt;/h2&gt;

&lt;p&gt;The route &lt;a href="https://github.com/confluentinc/kafka-streams-examples/blob/v8.4.0-2/pom.xml#L426-L441" rel="noopener noreferrer"&gt;Confluent's own examples&lt;/a&gt; take, and many projects with them:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Get the schema out of the registry&lt;/strong&gt; and into your repository as an &lt;code&gt;.avsc&lt;/code&gt; — or, if your team owns the schema in the repository and publishes it to the registry, the other way round. Whichever copy you call the source, there are now two that can disagree.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Add the code generator&lt;/strong&gt; to your build.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Configure it&lt;/strong&gt; — source and output directories, and the string type.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Build&lt;/strong&gt;, producing &lt;code&gt;Order.java&lt;/code&gt; under &lt;code&gt;target/generated-sources&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Write the topology&lt;/strong&gt; against the generated class.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Package&lt;/strong&gt; the application, with the schema, the class and the serde.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Deploy&lt;/strong&gt; it somewhere that runs a JVM.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Steps 2 and 3 are this, once — in Maven, though Gradle's equivalent has the same shape:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight xml"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;plugin&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;groupId&amp;gt;&lt;/span&gt;org.apache.avro&lt;span class="nt"&gt;&amp;lt;/groupId&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;artifactId&amp;gt;&lt;/span&gt;avro-maven-plugin&lt;span class="nt"&gt;&amp;lt;/artifactId&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;version&amp;gt;&lt;/span&gt;1.12.1&lt;span class="nt"&gt;&amp;lt;/version&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;executions&amp;gt;&amp;lt;execution&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;phase&amp;gt;&lt;/span&gt;generate-sources&lt;span class="nt"&gt;&amp;lt;/phase&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;goals&amp;gt;&amp;lt;goal&amp;gt;&lt;/span&gt;schema&lt;span class="nt"&gt;&amp;lt;/goal&amp;gt;&amp;lt;/goals&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;configuration&amp;gt;&lt;/span&gt;
      &lt;span class="nt"&gt;&amp;lt;sourceDirectory&amp;gt;&lt;/span&gt;${project.basedir}/src/main/avro&lt;span class="nt"&gt;&amp;lt;/sourceDirectory&amp;gt;&lt;/span&gt;
      &lt;span class="c"&gt;&amp;lt;!-- without this, string fields generate as CharSequence, not String;
           Confluent's own examples set it for the same reason --&amp;gt;&lt;/span&gt;
      &lt;span class="nt"&gt;&amp;lt;stringType&amp;gt;&lt;/span&gt;String&lt;span class="nt"&gt;&amp;lt;/stringType&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;/configuration&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;/execution&amp;gt;&amp;lt;/executions&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/plugin&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And step 5 is the part you actually wanted to write:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="n"&gt;builder&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;stream&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"orders"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;Consumed&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;with&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Serdes&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;Void&lt;/span&gt;&lt;span class="o"&gt;(),&lt;/span&gt; &lt;span class="n"&gt;orderSerde&lt;/span&gt;&lt;span class="o"&gt;))&lt;/span&gt;
       &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;filter&lt;/span&gt;&lt;span class="o"&gt;((&lt;/span&gt;&lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;order&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;order&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getQuantity&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;order&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getPriceEur&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;50&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
       &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;to&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"big-orders"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;Produced&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;with&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Serdes&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;Void&lt;/span&gt;&lt;span class="o"&gt;(),&lt;/span&gt; &lt;span class="n"&gt;orderSerde&lt;/span&gt;&lt;span class="o"&gt;));&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Seven steps, one of them the predicate. Two of them — adding the plugin and configuring it — you do that once.&lt;/p&gt;

&lt;p&gt;The filter is deliberately trivial — a real topology joins, aggregates and branches — but the seven steps are identical for twenty operators, because they are charged per project rather than per line of logic. And &lt;code&gt;application.id&lt;/code&gt;, &lt;code&gt;bootstrap.servers&lt;/code&gt;, the registry URL and your cluster's authentication are missing from the list because they are the price of running against a real cluster and registry, not the price of generating classes.&lt;/p&gt;

&lt;p&gt;Those two are not the cost.&lt;/p&gt;

&lt;h2&gt;
  
  
  Then you want to use a new field
&lt;/h2&gt;

&lt;p&gt;Somebody adds &lt;code&gt;region&lt;/code&gt; to the schema. While it sits there unused you are fine — Avro resolves the writer's schema against yours, your generated class does not know the field exists, and nothing needs rebuilding. Schema evolution is doing its job.&lt;/p&gt;

&lt;p&gt;Then somebody asks for EU orders only.&lt;/p&gt;

&lt;p&gt;The change to your logic is one term: &lt;code&gt;&amp;amp;&amp;amp; order.getRegion().equals("EU")&lt;/code&gt;. The change to your project is steps 1, 4, 5, 6 and 7 — pull the new schema, regenerate, rewrite, repackage, redeploy.&lt;/p&gt;

&lt;p&gt;That is the actual price, and it is charged not per schema change but per schema change &lt;em&gt;you need&lt;/em&gt;. Which, over the life of a pipeline, is many of them: fields get added because somebody intends to use them.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the seven steps buy
&lt;/h2&gt;

&lt;p&gt;They buy the compiler. &lt;code&gt;order.getQuantiy()&lt;/code&gt; does not compile. Rename a field, regenerate, and every stale use site turns red before anything runs. The IDE completes field names. Refactoring works.&lt;/p&gt;

&lt;p&gt;That is worth having. It is also answering a narrower question than the one you actually have.&lt;/p&gt;

&lt;p&gt;The compiler can tell you that &lt;code&gt;getQuantiy()&lt;/code&gt; is not a method. It cannot tell you whether &lt;code&gt;quantity * priceEur &amp;gt; 50&lt;/code&gt; is the predicate you meant, whether it matches any record on the topic, or whether the field you are multiplying holds what you think it holds. For that, the seven steps have one answer: deploy it and look.&lt;/p&gt;

&lt;p&gt;So the loop you are really in is not &lt;em&gt;edit, compile&lt;/em&gt;. It is &lt;strong&gt;edit, compile, package, deploy, produce a record, read the output&lt;/strong&gt; — and it costs the same whether the expression was right or wrong.&lt;/p&gt;

&lt;h2&gt;
  
  
  A tighter loop
&lt;/h2&gt;

&lt;p&gt;There is another shape for this, and it is the one we build: &lt;a href="https://www.alginte.com" rel="noopener noreferrer"&gt;Alginte&lt;/a&gt;, a browser-based topology builder that assembles it at runtime from topics you pick, instead of compiling it into an application you ship.&lt;/p&gt;

&lt;p&gt;Point it at &lt;code&gt;orders&lt;/code&gt;. The schema comes from the registry at runtime, the five field names arrive as completions, and the predicate is a string — written in &lt;a href="https://docs.spring.io/spring-framework/reference/core/expressions.html" rel="noopener noreferrer"&gt;SpEL&lt;/a&gt;, Spring's expression language, evaluated once per record:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;value.get('quantity') * value.get('priceEur') &amp;gt; 50
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That expression is evaluated against a real record from the topic while it is being typed — a record off the partition rather than a mock or a fixture, with the answer beside it.&lt;/p&gt;

&lt;p&gt;When it is right, it deploys as a Kafka Streams topology: the same library, the same &lt;code&gt;KafkaStreams&lt;/code&gt; client, the same rebalances, state stores and changelog topics you would have got from the seven steps. No separate engine is involved. The only thing that changed is how the topology was written.&lt;/p&gt;

&lt;p&gt;No &lt;code&gt;.avsc&lt;/code&gt; in a repository, no plugin, no &lt;code&gt;target/generated-sources&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://www.alginte.com/blog/kafka-streams-without-codegen/" rel="noopener noreferrer"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F3j50vees19rtv79nvjzt.webp" alt="The sample preview panel on a map node, showing a real record drawn from the orders topic on the in line. Typing value.get('item') makes the out line read Kettle; replacing the expression with value.get('quantity') * value.get('priceEur') makes the out line read 51. The editor reports no issues throughout and nothing is deployed." width="800" height="593"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;The record on the &lt;code&gt;in&lt;/code&gt; line is a real one off &lt;code&gt;orders&lt;/code&gt; — &lt;code&gt;quantity&lt;/code&gt; 3, &lt;code&gt;priceEur&lt;/code&gt; 17. The &lt;code&gt;out&lt;/code&gt; line is whatever the expression currently returns, and it follows the expression as it is edited: &lt;code&gt;51&lt;/code&gt;, which is the number the &lt;code&gt;&amp;gt; 50&lt;/code&gt; filter is about to judge. Nothing has been deployed.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Mistakes surface in the same place. A method that does not exist on the type the record actually carries is reported while it is being typed, naming the type — &lt;a href="https://www.alginte.com/blog/building-a-spel-editor/" rel="noopener noreferrer"&gt;the post about building that editor&lt;/a&gt; has that one on camera.&lt;/p&gt;

&lt;p&gt;That is not a compiler. It is the question the compiler could not answer, asked against real data, answered in seconds. And when somebody adds a sixth field and you want to use it, you type its name.&lt;/p&gt;

&lt;h2&gt;
  
  
  What you give up
&lt;/h2&gt;

&lt;p&gt;The compiler.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;value.get('quantiy')&lt;/code&gt; is a valid expression — javac never sees it, so nothing rejects it before it runs. Rename across the project goes too, and the unit tests that constructed &lt;code&gt;Order&lt;/code&gt; objects.&lt;/p&gt;

&lt;p&gt;What you do not lose is the typo itself. On Avro a misspelt field throws at access — &lt;code&gt;Not a valid schema field: quantiy&lt;/code&gt; — so it fails in the editor and fails deployed, identically. That is not the compiler's guarantee, which is made before anything runs. It is the same failure arriving in both places at the same moment: a weaker promise, and a real one.&lt;/p&gt;

&lt;p&gt;Some of it returns in a different form — field names completed from the registry, so the typo is never offered; the expression checked against a real record as you type; the same failure surfacing in the editor that would surface in the deployed topology. A different guarantee, weaker in some places, stronger in one.&lt;/p&gt;

&lt;p&gt;What does not go is the review. A topology drawn here exports as JSON and imports back, so the thing a reviewer reads and the thing git keeps is a file, not a browser session — the build step is gone, the artifact is not.&lt;/p&gt;

&lt;p&gt;You can have the generic half of this without any of the rest. Nothing stops you writing a Java topology that reads &lt;a href="https://avro.apache.org/docs/1.12.0/api/java/org/apache/avro/generic/GenericRecord.html" rel="noopener noreferrer"&gt;&lt;code&gt;GenericRecord&lt;/code&gt;&lt;/a&gt; and never generates a class: steps 1 to 4 disappear, and so does the compiler, since &lt;code&gt;record.get("quantiy")&lt;/code&gt; is a string lookup that javac will not check either. What you are left with is the worst of both — no type safety, and steps 6 and 7 still in front of you.&lt;/p&gt;

&lt;p&gt;The generic types are not the point. The loop around them is: the completions, the record on screen, and the deploy that is a click rather than a pipeline.&lt;/p&gt;

&lt;p&gt;What that buys is a faster answer to the question you actually had.&lt;/p&gt;

&lt;p&gt;And if you would rather write the Java regardless — the stream is one part of a larger application, or your team works that way, or any of the other good reasons — the loop is still worth having first. Draw it here, get the expressions right against real records, then spend the seven steps on logic you already know works. That is a better use of an afternoon than finding out after the deploy.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;This came out of building &lt;a href="https://www.alginte.com" rel="noopener noreferrer"&gt;Alginte&lt;/a&gt;, a visual Kafka Streams builder — self-hosted, free to run.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>kafka</category>
      <category>java</category>
      <category>avro</category>
      <category>spring</category>
    </item>
    <item>
      <title>SpEL in Kafka Streams: from evaluator to editor</title>
      <dc:creator>alginte software</dc:creator>
      <pubDate>Wed, 09 Sep 2026 09:42:51 +0000</pubDate>
      <link>https://dev.to/alginte/spel-in-kafka-streams-from-evaluator-to-editor-6pn</link>
      <guid>https://dev.to/alginte/spel-in-kafka-streams-from-evaluator-to-editor-6pn</guid>
      <description>&lt;p&gt;A Kafka Streams application is two things: a graph, and the logic inside it.&lt;br&gt;
The DSL writes both in one chain — operators like &lt;code&gt;mapValues&lt;/code&gt;, &lt;code&gt;filter&lt;/code&gt; and&lt;br&gt;
&lt;code&gt;join&lt;/code&gt; become the nodes, the chaining becomes the edges, and the lambda inside&lt;br&gt;
each operator is where the work happens:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="n"&gt;record&lt;/span&gt; &lt;span class="nf"&gt;Enriched&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="n"&gt;customer&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="n"&gt;product&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;double&lt;/span&gt; &lt;span class="n"&gt;total&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt; &lt;span class="o"&gt;}&lt;/span&gt;

&lt;span class="n"&gt;builder&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;stream&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"orders"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;Consumed&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;with&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Serdes&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;Void&lt;/span&gt;&lt;span class="o"&gt;(),&lt;/span&gt; &lt;span class="n"&gt;orderSerde&lt;/span&gt;&lt;span class="o"&gt;))&lt;/span&gt;
       &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;mapValues&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;value&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Enriched&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;
               &lt;span class="n"&gt;value&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getCustomerId&lt;/span&gt;&lt;span class="o"&gt;(),&lt;/span&gt;
               &lt;span class="n"&gt;value&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getItem&lt;/span&gt;&lt;span class="o"&gt;(),&lt;/span&gt;
               &lt;span class="n"&gt;value&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getQuantity&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;value&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getPriceEur&lt;/span&gt;&lt;span class="o"&gt;()))&lt;/span&gt;
       &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;filter&lt;/span&gt;&lt;span class="o"&gt;((&lt;/span&gt;&lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;value&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;value&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;total&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;50&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
       &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;to&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"orders-enriched"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;Produced&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;with&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Serdes&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;Void&lt;/span&gt;&lt;span class="o"&gt;(),&lt;/span&gt; &lt;span class="n"&gt;enrichedSerde&lt;/span&gt;&lt;span class="o"&gt;));&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A graph is a picture, so we draw it: operators dragged onto a canvas and wired&lt;br&gt;
together.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fefk70ju38z3ximkrk7xr.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fefk70ju38z3ximkrk7xr.png" alt="A Kafka Streams topology on a canvas: four boxes labelled Source (Stream), Map Values, Filter and Sink, wired top to bottom with arrows; the Map Values and Filter boxes each carry a red badge reading 1" width="800" height="593"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;The same four operators as the Java above, drawn rather than chained — one box&lt;br&gt;
each for &lt;code&gt;stream&lt;/code&gt;, &lt;code&gt;mapValues&lt;/code&gt;, &lt;code&gt;filter&lt;/code&gt; and &lt;code&gt;to&lt;/code&gt;. The two that need an&lt;br&gt;
expression each carry a red badge, and the step above counts them; the source and&lt;br&gt;
sink already have their topics. The graph is finished and the contents are not.&lt;br&gt;
Watching it built, and what happens after, is the subject of &lt;a href="https://www.alginte.com/blog/visual-kafka-streams-builder/" rel="noopener noreferrer"&gt;an earlier post&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The body of an operator is not a picture. It is a small computation over one&lt;br&gt;
record — and it is the only part of the whole thing doing anything specific to&lt;br&gt;
your data. That part, and only that part, is what an expression language is&lt;br&gt;
for.&lt;/p&gt;

&lt;p&gt;Written as an expression, that body needs no class and no build around it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;{'customer': value.get('customerId'),
 'product': value.get('item'),
 'total': value.get('quantity') * value.get('priceEur')}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The filter's body is one line: &lt;code&gt;value.get('total') &amp;gt; 50&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Writing those bodies in Java is not the hard part. Finding out whether they are&lt;br&gt;
&lt;em&gt;right&lt;/em&gt; is the hard part: compile, package, point the app at a broker, produce&lt;br&gt;
a few records, read what came out. Every step is routine, the whole loop is&lt;br&gt;
slow, and it takes exactly as long whether the expression was correct or not.&lt;/p&gt;

&lt;p&gt;Evaluated as an expression against a real record while you type, that loop&lt;br&gt;
collapses. It widens the set of people who can experiment with stream&lt;br&gt;
processing, and it makes learning it cheap — the barrier was never the DSL, it&lt;br&gt;
was everything you had to stand up before the DSL would run.&lt;/p&gt;
&lt;h2&gt;
  
  
  Why SpEL
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;It is built for this.&lt;/strong&gt;&lt;br&gt;
&lt;a href="https://docs.spring.io/spring-framework/reference/core/expressions.html" rel="noopener noreferrer"&gt;SpEL&lt;/a&gt; has driven routers and transformers in Spring Integration for years: a language built to take expressions at runtime.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;It is already there.&lt;/strong&gt; In a Spring application it arrives on the classpath with the framework.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;It can be contained.&lt;/strong&gt; A Kafka Streams lambda is compiled by whoever owns the deployment — your classpath, your risk. An expression typed into a browser arrives at runtime and runs in a process the author may not own. So containment comes first: a restricted evaluation context, with no type references, no constructors and no bean resolution. Spring provides that deliberately, which is what makes the whole approach possible — though the honest word is &lt;em&gt;containment&lt;/em&gt; rather than safety.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;There is a tokenizer to reuse.&lt;/strong&gt; That expression needs a real editor around&lt;br&gt;
it, and what that editor shows has to agree with what will actually run. The&lt;br&gt;
surest way is to tokenize with the same code the evaluator uses. SpEL has one.&lt;br&gt;
Getting hold of it is where this gets interesting.&lt;/p&gt;
&lt;h2&gt;
  
  
  Where the knowledge comes from
&lt;/h2&gt;

&lt;p&gt;In the Java above, the knowledge came from types — a class on the classpath, there at build time, whether generated from a schema or written by hand.&lt;/p&gt;

&lt;p&gt;An expression editor in a browser has none of it. No compile step, no class to consult — the only things that know anything about the data are the &lt;a href="https://docs.confluent.io/platform/current/schema-registry/index.html" rel="noopener noreferrer"&gt;Schema Registry&lt;/a&gt; and the records sitting on the topic. Everything the editor offers has to come from those two, while someone is typing.&lt;/p&gt;

&lt;p&gt;Drawing the graph is the easy half, and on its own it buys very little: a&lt;br&gt;
canvas with a bare text box for the logic trades away the help the IDE was giving and offers nothing in its place. Separating the two only pays off if the expression half gets something of its own.&lt;/p&gt;

&lt;p&gt;It will not be an IDE — but it has what an IDE cannot: the data. An IDE knows &lt;code&gt;priceEur&lt;/code&gt; is a number; it does not know this record's is 17. One expression, evaluated against a real record as you type, is closer to a REPL than to autocompletion.&lt;/p&gt;

&lt;p&gt;The editor is &lt;a href="https://microsoft.github.io/monaco-editor/" rel="noopener noreferrer"&gt;Monaco&lt;/a&gt;, the&lt;br&gt;
component behind VS Code, so it already knows how to highlight tokens, show a completion list and put a marker on a line. What it knows nothing about is SpEL, or your data.&lt;/p&gt;
&lt;h2&gt;
  
  
  Completions from types, and from content
&lt;/h2&gt;

&lt;p&gt;Completions come from two different places, and the difference is worth keeping visible.&lt;/p&gt;

&lt;p&gt;At a source node — reading a topic whose values have a registered schema — the completion list is the schema's own fields with their declared types. That is a contract: something else asserted it, and it holds for every record on the topic.&lt;/p&gt;

&lt;p&gt;One operator downstream, after a &lt;code&gt;mapValues&lt;/code&gt;, there is no schema any more. The value is whatever the expression returned. The fields offered there are derived by evaluating that expression against a real record and looking at what came out — and the UI marks them &lt;code&gt;(inferred)&lt;/code&gt;, because that is what they are: an observation of the sample in hand, not a promise. Draw another record and they may differ; if the data is heterogeneous, no single sample will say so.&lt;/p&gt;

&lt;p&gt;Inference is not the last word. A node can declare its output in the same&lt;br&gt;
vocabulary a source uses — a serde per side, optionally a registered subject — and a node that declares one becomes a fresh start: its completions are a contract again. What you cannot do is type a list of field names, and that is deliberate. Fields belong to schemas, and a shape worth describing is usually a shape worth registering.&lt;/p&gt;

&lt;p&gt;The same dropdown at two different nodes, two kinds of knowledge, labelled differently. It would be easy to present both as "fields", and it would be wrong.&lt;/p&gt;

&lt;p&gt;An empty topic is not a dead end. With no record to draw, the sample is&lt;br&gt;
randomly generated from the registered schema instead, and the preview says which one it is holding. That keeps the editor working before any data exists — while being the weaker evidence of the two, since it demonstrates the shape rather than your data.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fcm6e10o6vc1a4zbs6xjh.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fcm6e10o6vc1a4zbs6xjh.png" alt="A SpEL completion dropdown in the filter node, offering get('customer'), get('product') and get('total'), with the type hint reading " width="800" height="593"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;One operator after the &lt;code&gt;mapValues&lt;/code&gt;, the filter offers &lt;code&gt;customer&lt;/code&gt;, &lt;code&gt;product&lt;/code&gt; and &lt;code&gt;total&lt;/code&gt; — none of which exist in any schema. They are there because the expression on the node behind was evaluated against a real record, and the type hint says so. In the source node the same list would have come from the registry instead. &lt;a href="https://www.alginte.com/blog/visual-kafka-streams-builder/" rel="noopener noreferrer"&gt;The earlier post has this as a&lt;br&gt;
clip&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;
&lt;h2&gt;
  
  
  What an expression receives, and returns
&lt;/h2&gt;

&lt;p&gt;All of that depends on what the deserializer actually hands over — and that is rarely the Java type you would guess from the schema. &lt;strong&gt;Avro&lt;/strong&gt; gives you a &lt;code&gt;GenericRecord&lt;/code&gt;, and a field the schema calls a string does not arrive as a &lt;code&gt;String&lt;/code&gt;: it arrives as &lt;code&gt;org.apache.avro.util.Utf8&lt;/code&gt;, a &lt;code&gt;CharSequence&lt;/code&gt; wrapping the raw bytes. Equality still works — &lt;code&gt;value.get('item') == 'Kettle'&lt;/code&gt; matches on the orders stream — because SpEL compares &lt;code&gt;CharSequence&lt;/code&gt; content. &lt;code&gt;.contains(...)&lt;/code&gt; does not, because it is a &lt;code&gt;String&lt;/code&gt; method that &lt;code&gt;CharSequence&lt;/code&gt; never declared, so it needs a &lt;code&gt;.toString()&lt;/code&gt; first. That is the kind of detail that lives in serde Javadoc rather than anywhere you would think to look, and&lt;br&gt;
it costs an afternoon the first time.&lt;/p&gt;



&lt;p&gt;&lt;em&gt;Typing value.get('item').contains('Kettle') into a map expression; the editor reports one issue; opening the status shows EL1004E, method contains(java.lang.String) cannot be found on type org.apache.avro.util.Utf8; adding .toString() clears it to no issues.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;The same afternoon, spent in under thirty seconds. &lt;code&gt;.contains(...)&lt;/code&gt; on an Avro string, the message naming &lt;code&gt;org.apache.avro.util.Utf8&lt;/code&gt; rather than "invalid expression", and &lt;code&gt;.toString()&lt;/code&gt; clearing it.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Types are one half of what an editor can offer; the record is the other. Each node's sample preview puts that record in and the evaluated output out, side by side, re-running as the expression changes — so what an expression actually returns is on screen while it is still being written, against the data it will run on.&lt;/p&gt;



&lt;p&gt;&lt;em&gt;The Sample preview panel on a map node, showing a real record drawn from the orders topic on the in line. Typing value.get('item') makes the out line read Kettle; replacing the expression with value.get('quantity') * value.get('priceEur') makes the out line read 51. The editor reports no issues throughout and nothing is deployed.&lt;/em&gt; &lt;em&gt;&lt;code&gt;in&lt;/code&gt; is a real record off &lt;code&gt;orders&lt;/code&gt;; &lt;code&gt;out&lt;/code&gt; is whatever the expression returns, and it follows the expression as that changes — &lt;code&gt;value.get('item')&lt;/code&gt; gives &lt;code&gt;"Kettle"&lt;/code&gt;, then the intro's own &lt;code&gt;quantity * priceEur&lt;/code&gt; gives &lt;code&gt;51&lt;/code&gt;, the value the&lt;br&gt;
&lt;code&gt;&amp;gt; 50&lt;/code&gt; filter downstream exists to judge. Nothing has been deployed, and the editor never claims either answer is correct. It only makes them impossible to miss.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Avro's type names leak into error messages too, which the next section has to deal with.&lt;/p&gt;
&lt;h2&gt;
  
  
  Errors a user can act on
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;SpelCompilerMode.IMMEDIATE&lt;/code&gt; exists to make expressions faster by compiling them to bytecode. We use it in the &lt;strong&gt;validator&lt;/strong&gt;, as a type checker. Compiling forces type resolution to happen while someone is still typing, which is exactly when you want to hear that a method does not exist on the type you actually have.&lt;/p&gt;

&lt;p&gt;Catching it early, at the right line and column, is half of a diagnostic. The other half is what it says.&lt;/p&gt;

&lt;p&gt;SpEL's messages assume a developer who knows the JVM types involved. Ours are read by someone who has never heard of &lt;code&gt;GenericData$Record&lt;/code&gt; and should not have to. So the validator rewrites them.&lt;/p&gt;

&lt;p&gt;An indexing failure — someone reasonably tries &lt;code&gt;value['item']&lt;/code&gt; — becomes:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;indexing with &lt;code&gt;['...']&lt;/code&gt; is not supported on schema-backed records — use&lt;br&gt;
&lt;code&gt;get('fieldName')&lt;/code&gt; instead (the completions suggest it). A deployed topology&lt;br&gt;
fails the same way on Avro / Protobuf values.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The fix, a pointer at where it is offered, and a promise about the&lt;br&gt;
runtime. That last clause matters most: it tells the reader this is not an editor quirk to be worked around, because the deployed topology fails&lt;br&gt;
identically.&lt;/p&gt;

&lt;p&gt;Everything else gets a blunter rule: any message mentioning an internal sample type has that type replaced with "the sample record". Someone who writes a bad expression should learn what they did, not what class the deserializer happened to return. A few cases are worth naming precisely and get hand-written messages, like &lt;em&gt;"Invalid map access syntax. Use &lt;code&gt;value['key']&lt;/code&gt; without a dot."&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The rewriting covers Avro's generic types, Jackson's nodes and — since 0.10.0&lt;br&gt;
— Protobuf's message types, which became reachable the moment &lt;code&gt;get('field')&lt;/code&gt;&lt;br&gt;
started evaluating on them.&lt;/p&gt;
&lt;h2&gt;
  
  
  The tokenizer you cannot reach
&lt;/h2&gt;

&lt;p&gt;An editor needs three things an evaluator does not provide: syntax&lt;br&gt;
highlighting, completions, and errors at the right line and column. All three start from tokens.&lt;/p&gt;

&lt;p&gt;Spring has a tokenizer — the one its own parser uses. It is package-private.&lt;br&gt;
Against Spring Framework 7.0.8:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;class org.springframework.expression.spel.standard.Tokenizer {
  public org.springframework.expression.spel.standard.Tokenizer(java.lang.String);
  public java.util.List&amp;lt;org.springframework.expression.spel.standard.Token&amp;gt; process();
  static {};
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The constructor and &lt;code&gt;process()&lt;/code&gt; are &lt;strong&gt;already public&lt;/strong&gt;. The &lt;em&gt;class&lt;/em&gt; is not, so&lt;br&gt;
from outside the package there is no way to call them.&lt;/p&gt;

&lt;p&gt;The public surface of that package is &lt;code&gt;SpelExpressionParser&lt;/code&gt;, &lt;code&gt;SpelExpression&lt;/code&gt;&lt;br&gt;
and &lt;code&gt;SpelCompiler&lt;/code&gt;. Parsing, evaluation, compilation — no tokenization.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;SpelExpression&lt;/code&gt; does expose a parse tree — &lt;code&gt;getAST()&lt;/code&gt; returns &lt;code&gt;SpelNode&lt;/code&gt;s with&lt;br&gt;
start and end positions, which looks like exactly the supported route we missed.&lt;br&gt;
But a parser needs a valid expression, and an editor spends most of its life&lt;br&gt;
holding an invalid one. &lt;code&gt;value.&lt;/code&gt; is precisely when completions have to fire, and&lt;br&gt;
it does not parse; nor does this post's own map literal before its closing&lt;br&gt;
brace. Both tokenize.&lt;/p&gt;

&lt;p&gt;So we copied &lt;code&gt;Tokenizer&lt;/code&gt;, &lt;code&gt;Token&lt;/code&gt; and &lt;code&gt;TokenKind&lt;/code&gt; from&lt;br&gt;
&lt;a href="https://github.com/spring-projects/spring-framework/tree/v7.0.8/spring-expression/src/main/java/org/springframework/expression/spel/standard" rel="noopener noreferrer"&gt;Spring's &lt;code&gt;spel.standard&lt;/code&gt; package&lt;/a&gt;,&lt;br&gt;
under Apache-2.0, into a package of the same name in our own tree — which is&lt;br&gt;
what puts those package-private types within reach.&lt;/p&gt;

&lt;p&gt;All of it exists to enable one line:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="nc"&gt;List&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Token&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;tokens&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Tokenizer&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;inputData&lt;/span&gt;&lt;span class="o"&gt;).&lt;/span&gt;&lt;span class="na"&gt;process&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;What we add on top is thin, and most of it is a coordinate transform. Spring&lt;br&gt;
reports absolute character offsets; editors want line and column. So each&lt;br&gt;
&lt;code&gt;Token&lt;/code&gt; becomes a &lt;code&gt;SpELToken(start, end, line, charPositionInLine, type,&lt;br&gt;
modifiers)&lt;/code&gt; — the shape Monaco and LSP want — and goes over a WebSocket to the&lt;br&gt;
browser. There they are served through a &lt;code&gt;DocumentSemanticTokensProvider&lt;/code&gt;,&lt;br&gt;
which is the highlighting: every colour in the expression comes from the same&lt;br&gt;
tokenizer the evaluator parses with, so what the editor shows and what runs&lt;br&gt;
cannot drift apart.&lt;/p&gt;

&lt;p&gt;That is the whole of it. Spring's authors had already written the hard part;&lt;br&gt;
nothing was missing but a way to call it. What we carry is not&lt;br&gt;
complexity — it is their file.&lt;/p&gt;

&lt;h2&gt;
  
  
  What it costs
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;SpEL is not Java.&lt;/strong&gt; Broadening who can write a transformation costs the type&lt;br&gt;
system, the IDE, refactoring, unit tests, and the entire library ecosystem, in&lt;br&gt;
exchange for a string in a text box. That trade is defensible for &lt;em&gt;"what does&lt;br&gt;
this do to my records"&lt;/em&gt; and indefensible for a thousand-line pipeline.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The editor cannot catch a wrong answer.&lt;/strong&gt; It catches expressions that are&lt;br&gt;
&lt;em&gt;invalid&lt;/em&gt; — a method that does not exist, a syntax it cannot parse. One that is&lt;br&gt;
type-correct and simply always false passes every check. The preview above will&lt;br&gt;
faithfully show you the wrong answer; nothing will tell you that is what it&lt;br&gt;
is.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Package-private internals carry no compatibility promise.&lt;/strong&gt; Our copy of the&lt;br&gt;
tokenizer can break between Spring versions with no deprecation cycle, and that&lt;br&gt;
is our problem rather than Spring's. We took the risk knowingly; it is still a&lt;br&gt;
risk.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Protobuf was opaque when this was first published&lt;/strong&gt; — a &lt;code&gt;DynamicMessage&lt;/code&gt;&lt;br&gt;
offers no field access by name — and unlike the rest of this, it was ours to&lt;br&gt;
fix. It shipped in &lt;a href="https://www.alginte.com/releases/0.10.0/" rel="noopener noreferrer"&gt;0.10.0&lt;/a&gt;: one SpEL &lt;code&gt;MethodResolver&lt;/code&gt; gives&lt;br&gt;
&lt;code&gt;get('field')&lt;/code&gt; the same meaning on Protobuf as on Avro and JSON Schema,&lt;br&gt;
registered at all three places an evaluation context is built, so the editor&lt;br&gt;
and the runtime cannot disagree about it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Is there a better way to do this?
&lt;/h2&gt;

&lt;p&gt;That is our workaround, and we are not claiming it is the right answer.&lt;br&gt;
Package-private is a deliberate choice — internals stay private precisely so&lt;br&gt;
they can change — and we went around it.&lt;/p&gt;

&lt;p&gt;So the question is genuine. If there is a supported path to a SpEL token stream&lt;br&gt;
that we missed, we would much rather be told than go on carrying a copy of&lt;br&gt;
somebody else's tokenizer. And if there is not, we are probably not the last&lt;br&gt;
people who will want one.&lt;/p&gt;

&lt;p&gt;Either way the pieces are all there: the tokenizer works, the errors carry&lt;br&gt;
positions, the compiler doubles as a type checker. We would just like a&lt;br&gt;
supported way to hold one of them.&lt;/p&gt;

&lt;h2&gt;
  
  
  Updates
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;2026-09-07&lt;/strong&gt; — when first published (2026-08-31), this post said Protobuf
values were opaque to the editor and that the fix was on the list. It shipped
in &lt;a href="https://www.alginte.com/releases/0.10.0/" rel="noopener noreferrer"&gt;0.10.0&lt;/a&gt; on 2026-09-03: &lt;code&gt;get('field')&lt;/code&gt; evaluates on
Protobuf in the editor and deployed, and Protobuf's type names joined the
message rewriting. The two passages above were corrected to say so.&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;&lt;em&gt;This came out of building &lt;a href="https://www.alginte.com" rel="noopener noreferrer"&gt;Alginte&lt;/a&gt;, a visual Kafka&lt;br&gt;
Streams builder — self-hosted, free to run.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>kafka</category>
      <category>kafkastreams</category>
      <category>spring</category>
      <category>java</category>
    </item>
    <item>
      <title>Kafka Streams topologies you can draw and run</title>
      <dc:creator>alginte software</dc:creator>
      <pubDate>Tue, 08 Sep 2026 13:33:25 +0000</pubDate>
      <link>https://dev.to/alginte/kafka-streams-topologies-you-can-draw-and-run-49j7</link>
      <guid>https://dev.to/alginte/kafka-streams-topologies-you-can-draw-and-run-49j7</guid>
      <description>&lt;p&gt;You have an &lt;code&gt;orders&lt;/code&gt; topic. Each record carries a customer, an item, a&lt;br&gt;
quantity, and a price. You want the big ones — total over 50 — in a topic of&lt;br&gt;
their own, with that total already calculated.&lt;/p&gt;

&lt;p&gt;In Kafka Streams, that is a &lt;code&gt;mapValues&lt;/code&gt; and a &lt;code&gt;filter&lt;/code&gt;: a dozen lines of real&lt;br&gt;
logic.&lt;/p&gt;

&lt;p&gt;Getting them to run is a different size of job: a build file, a serde&lt;br&gt;
configuration, a jar, somewhere to put it, and a redeploy every time you want&lt;br&gt;
to check if your expression was right about the data. The work is small. The&lt;br&gt;
apparatus around it is not — and none of it tells you anything until the whole&lt;br&gt;
thing is up.&lt;/p&gt;

&lt;p&gt;That distance is the subject here. Not that Kafka Streams is hard, because it&lt;br&gt;
is not — but that the trip from &lt;em&gt;knowing what you want&lt;/em&gt; to &lt;em&gt;watching it&lt;br&gt;
happen&lt;/em&gt; is longer than a dozen lines of logic deserves.&lt;/p&gt;

&lt;p&gt;Start with what Kafka Streams already gives you — most of the picture is&lt;br&gt;
there, and the missing part is smaller than it looks. Deploy a topology and&lt;br&gt;
ask it to describe itself; here is a real one: four operators, reading&lt;br&gt;
&lt;code&gt;orders&lt;/code&gt; and writing &lt;code&gt;orders-enriched&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Topologies:
   Sub-topology: 0
    Source: Orders-src (topics: [orders])
      --&amp;gt; order-total-enrich
    Processor: order-total-enrich (stores: [])
      --&amp;gt; big-orders-bigOnly
      &amp;lt;-- Orders-src
    Processor: big-orders-bigOnly (stores: [])
      --&amp;gt; Big-Orders-sink
      &amp;lt;-- order-total-enrich
    Sink: Big-Orders-sink (topic: orders-enriched)
      &amp;lt;-- big-orders-bigOnly
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That is a graph. Nodes, directed edges, a topological order. The DSL&lt;br&gt;
constructs one; &lt;code&gt;describe()&lt;/code&gt; prints one, and it is what anyone sketches on a&lt;br&gt;
whiteboard when explaining a pipeline to someone else.&lt;/p&gt;

&lt;p&gt;Look at what it describes. &lt;code&gt;order-total-enrich&lt;/code&gt; is a processor: its name, its&lt;br&gt;
position, what feeds it, and what it feeds. What it &lt;strong&gt;does&lt;/strong&gt; lives somewhere&lt;br&gt;
else — the lambda inside &lt;code&gt;mapValues&lt;/code&gt;, the entire reason that node exists, is in&lt;br&gt;
your source rather than in the description. A topology description captures&lt;br&gt;
shape, and captures it completely; behaviour was never its job.&lt;/p&gt;

&lt;p&gt;What falls between them is the interesting part. A visual representation of a&lt;br&gt;
Kafka Streams application — ours included, until recently — tends to be a&lt;br&gt;
picture of the shape. Fill the boxes with real bodies and the picture stops&lt;br&gt;
being documentation of the program and becomes the program — something you can&lt;br&gt;
draw and run.&lt;/p&gt;
&lt;h2&gt;
  
  
  The data is already there, and so is its schema
&lt;/h2&gt;

&lt;p&gt;When records are produced through a schema registry — as these were — each one&lt;br&gt;
carries the id of the schema it was written against. So the shape is described&lt;br&gt;
before any topology exists, and a record and its schema are connected before&lt;br&gt;
anyone draws anything — you can watch it work.&lt;/p&gt;



&lt;p&gt;&lt;em&gt;Reading the orders topic with a Void key deserializer and Avro value deserializer, expanding one record to its decoded JSON, then following the Schema tab through to the orders-value subject.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Note what the value deserializer is &lt;em&gt;not&lt;/em&gt; asked for: a subject. The decode&lt;br&gt;
succeeding &lt;em&gt;is&lt;/em&gt; the connection; the schema tab afterwards only names it.&lt;/p&gt;

&lt;p&gt;(&lt;code&gt;Void&lt;/code&gt; on the key side because these records genuinely have null keys —&lt;br&gt;
anything else renders noise where there is nothing.)&lt;/p&gt;
&lt;h2&gt;
  
  
  What goes in the boxes
&lt;/h2&gt;

&lt;p&gt;So what should &lt;code&gt;order-total-enrich&lt;/code&gt; produce? A customer, a product, and that&lt;br&gt;
total. In a Java project, you would write that as a lambda and compile it; here&lt;br&gt;
it is an expression, and this is the whole of it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;{'customer': value.get('customerId'),
 'product':  value.get('item'),
 'total':    value.get('quantity') * value.get('priceEur')}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Evaluated once per record, inside the same JVM that is running the topology.&lt;br&gt;
The parse happens up front, and the parsed expression is cached, so the&lt;br&gt;
per-record cost is evaluation, not parsing.&lt;/p&gt;

&lt;p&gt;Reaching for an expression language rather than something more powerful is not&lt;br&gt;
a simplification — it follows from &lt;em&gt;who writes it&lt;/em&gt;. A Kafka Streams lambda is&lt;br&gt;
compiled by whoever owns the deployment, so trust is implicit and never has to&lt;br&gt;
be examined. Move authoring into a console and the author is a user: the code&lt;br&gt;
arrives at runtime, from outside, and the process it runs in is yours. The&lt;br&gt;
obvious alternative — letting people upload compiled code — answers the same&lt;br&gt;
need by handing a stranger arbitrary execution inside that process.&lt;/p&gt;

&lt;p&gt;We use &lt;a href="https://docs.spring.io/spring-framework/reference/core/expressions.html" rel="noopener noreferrer"&gt;Spring's SpEL&lt;/a&gt;&lt;br&gt;
for this. It is embeddable, it has an evaluation context you can restrict&lt;br&gt;
deliberately rather than by accident, and in a Spring application it is already&lt;br&gt;
on the classpath.&lt;/p&gt;

&lt;p&gt;Which makes the sandbox a precondition rather than a feature. Expressions&lt;br&gt;
evaluate against a restricted context: no type references, no constructors, no&lt;br&gt;
bean resolution. The honest word for that is &lt;em&gt;containment&lt;/em&gt; — we can tell you&lt;br&gt;
exactly what is blocked, and we cannot prove that nobody will find a way&lt;br&gt;
past it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Drawing it, and watching where the type survives
&lt;/h2&gt;

&lt;p&gt;Now the graph. Four operators dragged out, wired, named, and pointed at&lt;br&gt;
&lt;code&gt;orders&lt;/code&gt;:&lt;/p&gt;



&lt;p&gt;&lt;em&gt;Building the topology on a canvas: dragging source, mapValues, filter and sink nodes, wiring them, naming them, and binding the source to the orders topic.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The beat worth pausing on is the source's serde section, about a third of the&lt;br&gt;
way in. The &lt;strong&gt;value&lt;/strong&gt; side fills itself in — &lt;code&gt;Avro&lt;/code&gt;, subject &lt;code&gt;orders-value&lt;/code&gt; —&lt;br&gt;
because a registered subject makes the value type a contract. The &lt;strong&gt;key&lt;/strong&gt; side&lt;br&gt;
stays empty until you say so, because there is no &lt;code&gt;orders-key&lt;/code&gt; subject and nothing&lt;br&gt;
to derive it from.&lt;/p&gt;

&lt;p&gt;From the source down, each operator either preserves the value or replaces it.&lt;br&gt;
&lt;code&gt;filter&lt;/code&gt;, &lt;code&gt;peek&lt;/code&gt;, &lt;code&gt;repartition&lt;/code&gt;, &lt;code&gt;toStream&lt;/code&gt; pass it along unchanged; &lt;code&gt;mapValues&lt;/code&gt;&lt;br&gt;
ends the contract, because after it the value is whatever your expression&lt;br&gt;
returned. That is not a property of any tool — it is how the DSL works, and it&lt;br&gt;
is the rule anything deriving types has to follow.&lt;/p&gt;

&lt;h2&gt;
  
  
  The loop
&lt;/h2&gt;

&lt;p&gt;With a contract at the source and a rule for how it propagates, the editor can&lt;br&gt;
say something useful while you type — and a real record underneath answers&lt;br&gt;
back. Type, look, adjust: that is the loop.&lt;/p&gt;



&lt;p&gt;&lt;em&gt;Writing a mapValues expression with completions drawn from the registry schema, watching a real record flow through the preview, then a filter whose completions offer the computed total field.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Two different kinds of knowledge appear in that clip, and the difference&lt;br&gt;
matters.&lt;/p&gt;

&lt;p&gt;In the &lt;code&gt;mapValues&lt;/code&gt; node, the completions are the &lt;strong&gt;schema's own fields&lt;/strong&gt; —&lt;br&gt;
&lt;code&gt;customerId&lt;/code&gt;, &lt;code&gt;item&lt;/code&gt;, &lt;code&gt;quantity&lt;/code&gt;, &lt;code&gt;priceEur&lt;/code&gt;, with their declared types. That&lt;br&gt;
is a contract; it came from the registry.&lt;/p&gt;

&lt;p&gt;One node downstream, the filter offers &lt;code&gt;total&lt;/code&gt;. There is no &lt;code&gt;total&lt;/code&gt; in any&lt;br&gt;
schema, in any topic, anywhere. It exists because the expression above computed&lt;br&gt;
it, and the shape was observed by evaluating that expression against a real&lt;br&gt;
record. The UI marks fields like this &lt;code&gt;(inferred)&lt;/code&gt; for exactly that reason: it&lt;br&gt;
is one record's observation, not a promise. If your data is heterogeneous, one&lt;br&gt;
sample will not tell you so.&lt;/p&gt;

&lt;p&gt;The record underneath is pulled from the topic, not fabricated. You can draw a&lt;br&gt;
different one, roll a fresh one from the schema, or type the case you are&lt;br&gt;
actually worried about — the order with the null field, the quantity nobody&lt;br&gt;
expected — and watch what your expression does to it. Whatever you put in&lt;br&gt;
there, every downstream node reads the same record, so one story flows through&lt;br&gt;
the whole chain.&lt;/p&gt;

&lt;p&gt;Inference reads one execution path of one sample, so there are nodes where it&lt;br&gt;
will be wrong and nodes where it has nothing to offer. Any node can &lt;strong&gt;declare&lt;br&gt;
its output&lt;/strong&gt; instead — a serde per side, and a subject if that shape is&lt;br&gt;
registered. A node that declares one becomes a fresh start: the walk resumes&lt;br&gt;
from it, and everything below reads what you stated rather than what was&lt;br&gt;
observed. What you declare beats what was inferred, and both beat a guess.&lt;/p&gt;

&lt;p&gt;It is deliberately not a free-form field list. Fields belong to schemas, and a&lt;br&gt;
shape worth describing is usually a shape worth registering — so declaring a&lt;br&gt;
node's output asks the same question binding a source does, and takes a&lt;br&gt;
contract if one exists.&lt;/p&gt;

&lt;p&gt;One thing the completions cannot warn you about: the values are runtime&lt;br&gt;
objects, not the Java types their names suggest. A field the schema calls a&lt;br&gt;
string does not arrive as a &lt;code&gt;String&lt;/code&gt; — it arrives as &lt;code&gt;org.apache.avro.util.Utf8&lt;/code&gt;,&lt;br&gt;
a &lt;code&gt;CharSequence&lt;/code&gt; wrapping the raw bytes. Equality still behaves, because SpEL&lt;br&gt;
compares &lt;code&gt;CharSequence&lt;/code&gt; content, so &lt;code&gt;value.get('item') == 'Kettle'&lt;/code&gt; is true when&lt;br&gt;
you expect it to be. But &lt;code&gt;.contains(...)&lt;/code&gt; is a &lt;code&gt;String&lt;/code&gt; method that&lt;br&gt;
&lt;code&gt;CharSequence&lt;/code&gt; does not declare, so it needs a &lt;code&gt;.toString()&lt;/code&gt; first. It is the&lt;br&gt;
kind of detail that lives in serde Javadoc rather than anywhere you would think&lt;br&gt;
to look.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why the loop can be trusted
&lt;/h2&gt;

&lt;p&gt;All of which is worth exactly nothing if the answers differ from what the&lt;br&gt;
topology does once it is deployed. A preview that disagrees with the runtime&lt;br&gt;
teaches you something false and lets you find out at deploy time — worse than&lt;br&gt;
showing you nothing at all.&lt;/p&gt;

&lt;p&gt;That is not something you can check from the outside, so here is how we check&lt;br&gt;
it. Every expression in the test suite runs through all three surfaces. Two you&lt;br&gt;
have already seen: the editor's validator, and the preview under the sample.&lt;br&gt;
The third is the topology itself — built by the same code that builds a&lt;br&gt;
deployed one, running the same serdes, fed a record, and asked what came out of the&lt;br&gt;
sink. That leg runs in-process rather than against a broker, which is the only&lt;br&gt;
way to do it per expression at test speed, but nothing about the expression's&lt;br&gt;
path is simulated.&lt;/p&gt;

&lt;p&gt;All three have to agree: same success or failure, same output once serialized,&lt;br&gt;
same verdict from a filter. The cases run across Avro, Protobuf, and JSON&lt;br&gt;
Schema, and inside those, nested records, unions, arrays, enums, and logical&lt;br&gt;
types — and the set only grows: every disagreement we find becomes a case&lt;br&gt;
before it becomes a fix. They are usually small and specific, like arithmetic&lt;br&gt;
on an Avro field that the editor flagged and the runtime handled perfectly&lt;br&gt;
well.&lt;/p&gt;

&lt;h2&gt;
  
  
  It runs
&lt;/h2&gt;

&lt;p&gt;Configuration, submit, and the deployed topology with live per-node numbers:&lt;/p&gt;



&lt;p&gt;&lt;em&gt;Setting a dead-letter topic in the stream configuration, submitting the topology, and watching per-node throughput badges appear on the deployed canvas.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Those badges exist because of a naming decision made much earlier. Runtime&lt;br&gt;
metrics are tagged with &lt;em&gt;operator names&lt;/em&gt;, so joining them back to the picture&lt;br&gt;
means knowing what each operator is called — and Kafka's own answer,&lt;br&gt;
&lt;code&gt;KSTREAM-MAPVALUES-0000000003&lt;/code&gt;, is generated from graph position and shifts the&lt;br&gt;
moment you insert an operator upstream. The names in this topology are derived&lt;br&gt;
from the nodes instead, which is why the &lt;code&gt;describe()&lt;/code&gt; output at the top of this&lt;br&gt;
post reads &lt;code&gt;order-total-enrich&lt;/code&gt;. That is worth more than legibility: those names&lt;br&gt;
also land in JMX, in log lines, and in the internal topic names on your&lt;br&gt;
cluster.&lt;/p&gt;

&lt;p&gt;Read them carefully, though. The source reports an exact count; everything&lt;br&gt;
downstream reports an &lt;em&gt;attributed&lt;/em&gt; one, taken from the surrounding subtopology.&lt;br&gt;
For a linear chain, the number is correct, but it cannot tell you which records&lt;br&gt;
survived a predicate — the filter's effect shows up on the sink topic's&lt;br&gt;
offsets, not on the node's badge.&lt;/p&gt;

&lt;h2&gt;
  
  
  What this is for
&lt;/h2&gt;

&lt;p&gt;There is a stretch of work at the start of any pipeline that is mostly&lt;br&gt;
questions: what is actually in these records, does this field mean what its&lt;br&gt;
name suggests, what does my transformation do to the awkward ones. Answering&lt;br&gt;
those in a project means writing an app to find out. Answering them here takes&lt;br&gt;
the time it takes to type an expression, and the answers come from records that&lt;br&gt;
are really on the topic.&lt;/p&gt;

&lt;p&gt;The loop also explains an architectural choice. Most Kafka consoles are&lt;br&gt;
readers: they ask the cluster questions — what topics exist, where are the&lt;br&gt;
consumer groups, what do the metrics say — and never execute anything&lt;br&gt;
themselves. Evaluating your expression against a real record and showing you&lt;br&gt;
what came out is not a question you can ask Kafka. It means building the&lt;br&gt;
topology and running it, so a Kafka Streams application deployed from this&lt;br&gt;
console runs inside the console's own process.&lt;/p&gt;

&lt;p&gt;Having everything in one place pays off after the deploy, too. The sink topic&lt;br&gt;
is a topic like any other: the browser that showed you &lt;code&gt;orders&lt;/code&gt; in the first&lt;br&gt;
clip will show you &lt;code&gt;orders-enriched&lt;/code&gt;, so you can read what your expression&lt;br&gt;
actually produced — not just how many records got there. That is the round trip&lt;br&gt;
closed: the schema you started from, the records you tested against, and the&lt;br&gt;
output you caused, all reachable without leaving. Consumer groups, connectors,&lt;br&gt;
and ksqlDB sit on the same sidebar for the same reason, and that is the&lt;br&gt;
direction the rest of it keeps moving.&lt;/p&gt;

&lt;p&gt;That is a genuine trade, not a free win: it puts your expressions in our JVM,&lt;br&gt;
which is why the sandbox above came first. If you already have a console you&lt;br&gt;
like for browsing a cluster, keep it — this one is shaped the way it is&lt;br&gt;
because authoring needed something that could actually run what you wrote.&lt;/p&gt;

&lt;h2&gt;
  
  
  What stays out of reach
&lt;/h2&gt;

&lt;p&gt;The derivation stops, and where it stops is the honest part.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;At a join, we said nothing when this was published.&lt;/strong&gt; A join emits when a&lt;br&gt;
pair co-occurs — same key, both sides, inside the window — and inventing the&lt;br&gt;
side that has not arrived asserts a match that may never happen. Since&lt;br&gt;
&lt;a href="https://www.alginte.com/releases/0.10.0/" rel="noopener noreferrer"&gt;0.10.0&lt;/a&gt; the answer is two journeys instead of silence: each&lt;br&gt;
input is evaluated on its own lane, &lt;code&gt;#leftValue&lt;/code&gt; and &lt;code&gt;#rightValue&lt;/code&gt; are bound to&lt;br&gt;
their own branch's record, the joiner runs over both with the deployed&lt;br&gt;
operator, and the nodes after the join get a chain like any other. What did&lt;br&gt;
not change is the refusal to guess: a join whose second input cannot be&lt;br&gt;
evaluated waits, and says so, rather than showing output for a pair that may&lt;br&gt;
never exist.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;One record at a time is not coverage.&lt;/strong&gt; You can cycle through real records,&lt;br&gt;
generate them from the schema, and write the awkward case by hand — but nothing&lt;br&gt;
enumerates the shapes your stream actually contains, and the loop will never&lt;br&gt;
tell you about the one you did not think to try. It is a probe, not a proof.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Protobuf values were opaque to all of this when this was published&lt;/strong&gt; — and&lt;br&gt;
that one was ours, not Kafka's. They arrive as &lt;code&gt;DynamicMessage&lt;/code&gt;, which has no&lt;br&gt;
&lt;code&gt;get(String)&lt;/code&gt;. It shipped in &lt;a href="https://www.alginte.com/releases/0.10.0/" rel="noopener noreferrer"&gt;0.10.0&lt;/a&gt;: a SpEL&lt;br&gt;
&lt;code&gt;MethodResolver&lt;/code&gt; gives &lt;code&gt;get('field')&lt;/code&gt; the same meaning on Protobuf as on Avro&lt;br&gt;
and JSON Schema, in the editor and deployed alike.&lt;/p&gt;

&lt;p&gt;Those three were not the same kind of limit, and two have since moved.&lt;br&gt;
Sampling one record at a time is not something a different tool fixes; it will&lt;br&gt;
still be true in whatever you author with instead. Protobuf access was a gap in&lt;br&gt;
what we had built, and it closed. The join was a decision — declining to guess&lt;br&gt;
— and the decision stood; what changed is that a second lane, evaluated on its&lt;br&gt;
own record, is not a guess.&lt;/p&gt;

&lt;p&gt;What a tool can do is derive what is derivable, say nothing where nothing is&lt;br&gt;
knowable, and let you run an expression against a record that actually exists&lt;br&gt;
before you commit to it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Try it, and tell us where it fits
&lt;/h2&gt;

&lt;p&gt;This started with an &lt;code&gt;orders&lt;/code&gt; topic and a transformation you could describe in one&lt;br&gt;
sentence, with a build and a deploy standing between you and knowing whether&lt;br&gt;
it was right. The four clips are that distance getting shorter: the schema&lt;br&gt;
read out of the records, the expression checked against one of them before it&lt;br&gt;
ships, the topology running with its own numbers on it.&lt;/p&gt;

&lt;p&gt;What comes out is a Kafka Streams topology that runs as it stands. And if what&lt;br&gt;
you wanted was a shape you now understand well enough to go and write in Java,&lt;br&gt;
the value has to survive you doing that, and it does: the fields you confirmed&lt;br&gt;
and the transformation you settled on do not care where they end up.&lt;/p&gt;

&lt;p&gt;Where it stops being useful is the part we cannot answer alone: the pipelines&lt;br&gt;
you would want to explore this way, and the ones where none of this would&lt;br&gt;
help.&lt;/p&gt;

&lt;p&gt;All of this is in the &lt;a href="https://docs.alginte.com/installation/playground" rel="noopener noreferrer"&gt;playground&lt;/a&gt;:&lt;br&gt;
the &lt;code&gt;orders&lt;/code&gt; topic with its registered schema, and the topology from these&lt;br&gt;
clips deployed and running against it. One command, and you can take it apart&lt;br&gt;
instead of taking our word for it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Updates
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;2026-09-07&lt;/strong&gt; — when first published (2026-08-19), this post said Protobuf
values were opaque to the expression editor and that we had not taught it to
read them. That shipped in &lt;a href="https://www.alginte.com/releases/0.10.0/" rel="noopener noreferrer"&gt;0.10.0&lt;/a&gt; on 2026-09-03:
&lt;code&gt;get('field')&lt;/code&gt; evaluates on Protobuf in the editor and deployed alike. The
passage above was corrected to say so.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;2026-09-08&lt;/strong&gt; — the same first version said the walk halts at a join and
everything after it is left unevaluated. Since 0.10.0 a join is evaluated on
two lanes and the nodes downstream get a chain; a join with an input that
cannot be evaluated still waits rather than guesses. The join passage and
the closing comparison were corrected to say so.&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>kafka</category>
      <category>kafkastreams</category>
      <category>spring</category>
      <category>java</category>
    </item>
    <item>
      <title>KafkaStreams.state() says REBALANCING. Every thread is dead.</title>
      <dc:creator>alginte software</dc:creator>
      <pubDate>Sun, 02 Aug 2026 00:00:00 +0000</pubDate>
      <link>https://dev.to/alginte/kafkastreamsstate-says-rebalancing-every-thread-is-dead-1nbh</link>
      <guid>https://dev.to/alginte/kafkastreamsstate-says-rebalancing-every-thread-is-dead-1nbh</guid>
      <description>&lt;p&gt;We found this one the embarrassing way: a seeded demo stream sat in&lt;code&gt;REBALANCING&lt;/code&gt; for a day. Not failing — &lt;em&gt;rebalancing&lt;/em&gt;. The UI said so. The Streams client said so. The only place the truth existed was a server log nobody was reading.&lt;/p&gt;

&lt;h2&gt;
  
  
  The trap
&lt;/h2&gt;

&lt;p&gt;Deploy a Kafka Streams topology whose &lt;strong&gt;source topic doesn't exist&lt;/strong&gt; , and here's the exact sequence (Kafka clients 4.x, but the behaviour is old):&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;The group leader's assignment fails with&lt;code&gt;INCOMPLETE_SOURCE_TOPIC_METADATA&lt;/code&gt;; the member receives the error in its assignment.&lt;/li&gt;
&lt;li&gt;The &lt;code&gt;StreamThread&lt;/code&gt; logs &lt;code&gt;MissingSourceTopicException&lt;/code&gt;, transitions&lt;code&gt;PENDING_SHUTDOWN → DEAD&lt;/code&gt;, and &lt;strong&gt;does not retry&lt;/strong&gt;. This is deliberate — a missing source topic is not a transient condition Kafka Streams can wait out.&lt;/li&gt;
&lt;li&gt;Crucially, the &lt;em&gt;client-level&lt;/em&gt; state machine never follows. The&lt;code&gt;StreamsUncaughtExceptionHandler&lt;/code&gt; isn't consulted (the thread shut down; it didn't throw), so there's no &lt;code&gt;PENDING_ERROR → ERROR&lt;/code&gt; transition.&lt;code&gt;KafkaStreams.state()&lt;/code&gt; last saw a rebalance start, and that's where it stays.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The result: a &lt;code&gt;KafkaStreams&lt;/code&gt; instance with &lt;strong&gt;zero live threads&lt;/strong&gt; that reports&lt;code&gt;REBALANCING&lt;/code&gt; indefinitely. It will never process a record, never error, and never change state again.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why every Kafka UI has this bug
&lt;/h2&gt;

&lt;p&gt;If your tool renders &lt;code&gt;KafkaStreams.state()&lt;/code&gt; — and that's the obvious, documented thing to render — you have this bug. The state enum simply has no value for "all my threads are dead but nobody told the coordinator layer."&lt;code&gt;REBALANCING&lt;/code&gt; is the truthful answer to the wrong question.&lt;/p&gt;

&lt;p&gt;The signal that &lt;em&gt;does&lt;/em&gt; exist is one call away:&lt;code&gt;metadataForLocalThreads()&lt;/code&gt; returns per-thread metadata including each thread's state. A client reporting &lt;code&gt;REBALANCING&lt;/code&gt; whose thread set is empty — or whose threads are all &lt;code&gt;DEAD&lt;/code&gt; — is not rebalancing. It's gone.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix, in two halves
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Surface it.&lt;/strong&gt; We derive the displayed state instead of trusting the raw one:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="kd"&gt;static&lt;/span&gt; &lt;span class="nc"&gt;KafkaStreams&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;State&lt;/span&gt; &lt;span class="nf"&gt;effectiveState&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;KafkaStreams&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;State&lt;/span&gt; &lt;span class="n"&gt;state&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;
                                         &lt;span class="nc"&gt;Collection&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;ThreadMetadata&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;threads&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;state&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="nc"&gt;KafkaStreams&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;State&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;REBALANCING&lt;/span&gt;
            &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;threads&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;isEmpty&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt;
                &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="n"&gt;threads&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;stream&lt;/span&gt;&lt;span class="o"&gt;().&lt;/span&gt;&lt;span class="na"&gt;allMatch&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;t&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="s"&gt;"DEAD"&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;equals&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;threadState&lt;/span&gt;&lt;span class="o"&gt;()))))&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;KafkaStreams&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;State&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;ERROR&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;state&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Genuine rebalances have live threads (&lt;code&gt;STARTING&lt;/code&gt;, &lt;code&gt;PARTITIONS_ASSIGNED&lt;/code&gt;, …) and pass through untouched. In our end-to-end test, deleting a running stream's source topic flips the reported state to &lt;code&gt;ERROR&lt;/code&gt; within seconds — where before it showed &lt;code&gt;REBALANCING&lt;/code&gt; until someone read the log.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Prevent the common case.&lt;/strong&gt; The most frequent way to hit this is a typo'd topic name at deploy time. Since a missing source topic is &lt;em&gt;unrecoverable by design&lt;/em&gt;, we now validate every source node's topics against the cluster before building the topology, and fail the deploy with the missing names — one &lt;code&gt;listTopics()&lt;/code&gt; round-trip. (Fail-open if the listing itself errors: a broker hiccup shouldn't block a deploy that would have succeeded; the state derivation above is the backstop.)&lt;/p&gt;

&lt;p&gt;Neither half needs anything from the broker that isn't already public API.&lt;/p&gt;

&lt;h2&gt;
  
  
  Takeaways
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;KafkaStreams.state()&lt;/code&gt; is the state of the &lt;em&gt;coordinator conversation&lt;/em&gt;, not the health of your processing. Dead threads don't move it.&lt;/li&gt;
&lt;li&gt;If you're operating Kafka Streams with your own dashboards: alert on thread liveness (&lt;code&gt;metadataForLocalThreads()&lt;/code&gt;, or the &lt;code&gt;alive-stream-threads&lt;/code&gt;metric), not on &lt;code&gt;state() != RUNNING&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;If you're building a tool: derive, don't relay. The raw state is truthful and useless at the same time.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Both fixes shipped in &lt;a href="https://www.alginte.com/releases/0.7.0/" rel="noopener noreferrer"&gt;Alginte 0.7.0&lt;/a&gt;. The stuck demo that taught us this now recovers in seconds — and deploying against a typo'd topic tells you the topic's name instead of miming a rebalance.&lt;/p&gt;

</description>
      <category>kafkastreams</category>
    </item>
  </channel>
</rss>
